Dev.to WebDev 🛠 Dev 👁 0 📖 25 min read

Misleading Graphs in Python

Most misleading graphs aren’t made by liars. They’re made by a quick ax.set_ylim(9800, 10150) to “zoom in”, a default color cycle nobody checked, or a second y-axis added because two series didn’t fit. In the example thi

Misleading Graphs in Python

Most misleading graphs aren’t made by liars. They’re made by a quick ax.set_ylim(9800, 10150) to “zoom in”, a default color cycle nobody checked, or a second y-axis added because two series didn’t fit. In the example this post keeps returning to, signups grew 3.2% over the year, yet the Q4 bar is drawn 32 times taller than the Q1 bar.

That matters more than it sounds. In crowd-sourced experiments published at CHI 2020, Correll, Bertini and Franconeri found that bar charts with a y-axis not starting at zero visually exaggerate effect sizes, and that the effect persisted even when the design signalled the truncation.

Code review rarely catches misleading charts like this, because reviewers read the plotting code, not the picture. So in this post we’ll treat misleading graphs like any other bug: write a small linter that inspects matplotlib Figure objects, give it a rule per problem, and run it from pytest.

What we’ll do:

  1. Build six misleading graphs, each with a fixed version
  2. Write chartlint, about 130 lines with seven rules
  3. Test every rule against the misleading and the fixed chart
  4. Run it as a report over all charts
  5. Preview a chart the way a colorblind reader sees it
  6. Lint every figure your existing tests create

Stack: Python 3.13, matplotlib 3.11.2, NumPy 2.5.4, colorspacious 1.1.2, pytest 9.1.1. All outputs below come from real runs. The data in the charts is made up; the rendering problems are not.

The seven rules

<!DOCTYPE html>



table {
border-collapse: collapse;
width: 100%;
font-family: Arial, sans-serif;
font-size: 14px;
color: #000;
}
th, td {
border: 1px solid #b0c4de;
padding: 8px;
text-align: left;
vertical-align: top;
}
th {
background-color: #e6eefaa8; /* Светло-синий фон для заголовков */
font-weight: bold;
}
code {
font-family: Consolas, monospace;
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 3px;
}

Rule Flags Why it misleads Fix
CL01 Bar chart whose value axis doesn't include 0 Bar length encodes the value, so a cut axis inflates differences Start at 0, or use a dot/line chart and label the change
CL02 Two scales on one plot area (twinx or twiny) Both scales are arbitrary; any two series can be made to "track" Two panels, or an index (start = 100)
CL03 Pie with more than 5 slices Angles and areas are judged poorly; small slices blur Sorted horizontal bars
CL04 jet, turbo, rainbow, hsv and similar colormaps, reversed ones too Lightness goes up and down, which creates false edges viridis, cividis
CL05 Colors of labelled line and bar series that merge for red-green colorblind readers About 8% of Caucasian men have red-green color blindness A colorblind-safe palette plus markers or direct labels
CL06 Missing title or axis label The reader has to guess the unit Title and units on the value axis
CL07 3D axes Perspective distorts length and position 2D chart

The 5-slice limit and the color-distance threshold in CL05 are my choices, not standards. Both are parameters.


The seven chartlint rules and the matplotlib API each one inspects, with the source behind each rule

Each function in charts.py takes fixed and returns a matplotlib Figure. Having both versions side by side gives the linter a positive and a negative case for every rule.

"""Six charts, each in a misleading and a fixed version. The data is made up."""
import matplotlib

matplotlib.use("Agg")
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np

QUARTERS = ["Q1", "Q2", "Q3", "Q4"]
SIGNUPS = [9810, 9905, 9960, 10120]  # +3.2% over the year
SHARE = {"Chrome": 41, "Safari": 19, "Edge": 11, "Firefox": 8, "Samsung": 6,
         "Opera": 5, "Brave": 4, "Vivaldi": 3, "Other": 3}
MONTHS = np.arange(1, 13)
USERS = np.array([120, 135, 150, 170, 185, 205, 230, 250, 270, 300, 320, 345])  # thousands
REVENUE = np.array([40, 41, 41, 42, 43, 43, 44, 44, 45, 45, 46, 46])  # $k
rng = np.random.default_rng(42)
LATENCY = rng.gamma(2.0, 30.0, size=(7, 24))  # ms, day x hour
TEAMS = {"API": [210, 190, 180, 150], "Web": [230, 215, 205, 190], "Mobile": [260, 240, 230, 225]}


def signups_bars(fixed):
    fig, ax = plt.subplots(figsize=(5, 3.2), layout="constrained")
    ax.bar(QUARTERS, SIGNUPS, color="#0F766E")
    if not fixed:
        ax.set_ylim(9800, 10150)  # makes +3.2% look like 32x
    else:
        ax.set_ylim(0, SIGNUPS[-1] * 1.15)  # headroom for the label
        change = SIGNUPS[-1] / SIGNUPS[0] - 1
        ax.annotate(f"{change:+.1%} Q1 to Q4", xy=(3, SIGNUPS[-1]), xytext=(0, 6),
                    textcoords="offset points", ha="center")
    ax.set_title("New signups per quarter")
    ax.set_ylabel("Signups")
    return fig


def browser_pie(fixed):
    fig, ax = plt.subplots(figsize=(5, 3.2), layout="constrained")
    if not fixed:
        ax.pie(SHARE.values(), labels=SHARE.keys())
    else:
        names, values = zip(*sorted(SHARE.items(), key=lambda kv: kv[1]))
        ax.barh(names, values, color="#0F766E")
        ax.set_xlabel("Share of sessions, %")
    ax.set_title("Browser share")
    return fig


def users_vs_revenue(fixed):
    if not fixed:
        fig, ax = plt.subplots(figsize=(5, 3.2), layout="constrained")
        ax.plot(MONTHS, USERS, color="#0F766E")
        ax.set_ylabel("Users, k")
        twin = ax.twinx()
        twin.plot(MONTHS, REVENUE, color="#B45309")
        twin.set_ylabel("Revenue, $k")
        twin.set_ylim(39, 47)  # both lines now "grow" alike
    else:
        fig, (ax, ax2) = plt.subplots(1, 2, figsize=(7.5, 3.2), sharex=True, layout="constrained")
        ax.plot(MONTHS, USERS / USERS[0] * 100, color="#0F766E", label="Users")
        ax.plot(MONTHS, REVENUE / REVENUE[0] * 100, color="#B45309", label="Revenue")
        ax.set_ylabel("Index, January = 100")
        ax.set_title("Growth since January")
        ax.legend()
        ax2.plot(MONTHS, REVENUE / USERS, color="#0F766E")  # $k / k users = $ per user
        ax2.set_ylabel("Revenue per user, $")
        ax2.set_xlabel("Month")
        ax2.set_title("Revenue per user")
    ax.set_title(ax.get_title() or "Users and revenue")
    ax.set_xlabel("Month")
    ax.set_xticks([1, 4, 7, 10])
    return fig


def latency_heatmap(fixed):
    fig, ax = plt.subplots(figsize=(6, 2.6), layout="constrained")
    im = ax.imshow(LATENCY, aspect="auto", cmap="viridis" if fixed else "jet")
    fig.colorbar(im, ax=ax, label="p50 latency, ms")
    ax.set_title("Latency by day and hour")
    ax.set_xlabel("Hour (UTC)")
    ax.set_ylabel("Day")
    return fig


def team_lines(fixed):
    fig, ax = plt.subplots(figsize=(5, 3.2), layout="constrained")
    colors = mpl.color_sequences["okabe_ito"][1:] if fixed else [None] * len(TEAMS)  # None = default cycle
    for (team, values), color in zip(TEAMS.items(), colors):
        ax.plot(QUARTERS, values, label=team, color=color, marker="o" if fixed else None)
    ax.set_title("Build time by team")
    ax.set_ylabel("Median build, s")
    ax.legend()
    return fig


def unlabeled_bars(fixed):
    fig, ax = plt.subplots(figsize=(5, 3.2), layout="constrained")
    ax.bar(QUARTERS, [3.1, 2.4, 1.9, 1.2], color="#0F766E")
    if fixed:
        ax.set_title("Incidents per 1,000 deploys")
        ax.set_ylabel("Incidents")
    return fig


CHARTS = [signups_bars, browser_pie, users_vs_revenue, latency_heatmap, team_lines, unlabeled_bars]

The interesting lines are small. The truncated bar chart is one call:

ax.set_ylim(9800, 10150) # makes +3.2% look like 32x

And the dual-axis chart needs only twinx() plus a hand-picked range for the second axis, which is exactly the problem: the author picks the range, and the range decides the story.


Three misleading graphs next to their fixed versions, rendered by charts.py: truncated bars, a dual-axis chart, and a 9-slice pie

Step 2. The linter, rule by rule

chartlint walks fig.axes, runs each check on each Axes and returns a list of Findings. Everything it inspects is public matplotlib API: containers, patches, images, shared axes and positions.

CL01: misleading bar graphs with a truncated y-axis

def check_bar_baseline(ax):
    """CL01: bar length encodes value, so the value axis must include zero."""
    for c in ax.containers:
        if not isinstance(c, BarContainer):
            continue
        horizontal = c.orientation == "horizontal"
        if (ax.get_xscale() if horizontal else ax.get_yscale()) == "log":
            continue  # a log axis can't contain 0
        lo, hi = ax.get_xlim() if horizontal else ax.get_ylim()
        if not (min(lo, hi) <= 0 <= max(lo, hi)):
            axis = "x" if horizontal else "y"
            yield Finding("CL01", _name(ax), f"bar chart {axis}-axis starts at {min(lo, hi):g}, not 0")
            return

ax.containers holds a BarContainer for every ax.bar() or ax.barh() call, and its orientation tells which axis carries the values. The rule checks that 0 falls inside the limits, so it also covers all-negative bars. ax.hist() creates a BarContainer too, so histograms are checked as well. Log-scale axes are skipped, since 0 can’t appear on them.
Line charts are deliberately exempt. That is a convention, not a finding: Correll et al. measured the exaggeration in line charts too. But a line encodes values by position, not length, and the authors argue that whether truncation helps or hurts depends on what the chart is meant to show. A test below pins that decision down.

CL02: dual axis charts

def check_dual_axis(ax, seen):
    """CL02: two scales on one plot area (twinx/twiny). sharex subplots are fine."""
    for shared, axis in ((ax.get_shared_x_axes(), "y"), (ax.get_shared_y_axes(), "x")):
        for other in shared.get_siblings(ax):
            if other is ax or (other, ax) in seen:
                continue
            if np.allclose(other.get_position().bounds, ax.get_position().bounds):
                seen.add((ax, other))
                yield Finding("CL02", _name(ax), f"two {axis}-axes share one plot area; use two panels or an index")

The first version of this rule only checked shared x-axes, and it would have flagged every plt.subplots(2, 1, sharex=True). Stacked panels with a shared x-axis are the recommended fix, not the problem. The difference is position: a twinx() axes sits exactly on top of its parent, so the rule compares get_position().bounds. It checks shared y-axes the same way, so twiny() is caught too.
Lisa Charlotte Muth’s Datawrapper article lays out the case against dual axes: the two scales are arbitrary, so they can make any two series look related. Here, revenue grew 15% and users 188%, yet with a hand-picked right axis the two lines climb together.

CL03: pies with too many slices

def check_pie(ax, max_slices):
    """CL03: angles and areas are hard to compare; many slices make it worse."""
    wedges = [p for p in ax.patches if isinstance(p, Wedge)]
    if len(wedges) > max_slices:
        yield Finding("CL03", _name(ax), f"pie has {len(wedges)} slices (max {max_slices}); use sorted bars")

ax.pie() adds one matplotlib.patches.Wedge per slice. Stephen Few’s “Save the Pies for Dessert” explains why bars win: people judge lengths well and angles and areas poorly, and a slice is only easy to read near 0%, 25%, 50%, 75% or 100%.

CL04: rainbow colormaps

def check_colormap(ax):
    """CL04: rainbow colormaps have non-monotonic lightness."""
    for artist in [*ax.images, *[c for c in ax.collections if isinstance(c, Collection)]]:
        cmap = getattr(artist, "get_cmap", lambda: None)()
        if cmap is not None and artist.get_array() is not None and cmap.name.removesuffix("_r") in RAINBOW_CMAPS:
            yield Finding("CL04", _name(ax), f"colormap '{cmap.name}' is not perceptually uniform; try viridis or cividis")

matplotlib’s own colormap guide says the L* (lightness) values in jet “vary widely throughout the colormap”, which makes it a poor choice for data. In a latency heatmap that shows up as bright bands that look like thresholds but aren’t. The perceptually uniform maps (viridis, plasma, inferno, magma, cividis) increase lightness monotonically.
The check strips the _r suffix, so jet_r is caught, and it includes turbo, which matplotlib files under Miscellaneous next to jet. hsv is in the list because it’s cyclic: fine for angles or hours of the day, wrong for latency. If you plot cyclic data, pass a narrower set.

CL05: colors that merge for colorblind readers

def _series_colors(ax):
    colors = [line.get_color() for line in ax.get_lines() if not line.get_label().startswith("_")]
    colors += [c.patches[0].get_facecolor() for c in ax.containers if isinstance(c, BarContainer) and c.get_label()
               and not c.get_label().startswith("_")]
    return [to_rgb(c) for c in colors]

def check_cvd(ax, min_delta):
    """CL05: labelled line and bar series whose colors merge for red-green colorblind viewers."""
    colors = _series_colors(ax)
    for cvd in CVD_TYPES:
        space = {"name": "sRGB1+CVD", "cvd_type": cvd, "severity": 100}
        sim = np.clip(cspace_convert(np.array(colors), space, "sRGB1"), 0, 1) if colors else []
        for (i, a), (j, b) in combinations(enumerate(sim), 2):
            d = float(deltaE(a, b))
            if d < min_delta:
                yield Finding("CL05", _name(ax), f"series {i} and {j} differ by only {d:.1f} ΔE' under {cvd}")
                return

The National Eye Institute notes that the most common type of color vision deficiency makes red and green hard to tell apart, and that men are at much higher risk than women. Okabe and Ito put red-green color blindness at about 8% of Caucasian men.
colorspacious implements the Machado et al. (2009) model of color vision deficiency. With severity: 100 it simulates full dichromacy, and deltaE measures distance in the CAM02-UCS space, where Euclidean distance approximates perceived difference. The rule simulates both red-green types and fails if any two series end up closer than 10 ΔE’.
Why 10? This script computes the closest pair among the first N colors of some of matplotlib’s built-in color sequences:

"""Worst-case color distance among the first N colors of matplotlib's color sequences."""
import itertools

import matplotlib as mpl
import numpy as np
from colorspacious import cspace_convert, deltaE


def worst(colors, cvd):
    if cvd != "normal":
        space = {"name": "sRGB1+CVD", "cvd_type": cvd, "severity": 100}
        colors = np.clip(cspace_convert(np.array(colors), space, "sRGB1"), 0, 1)
    return min(float(deltaE(a, b)) for a, b in itertools.combinations(colors, 2))


print(f"{'palette':<12}{'N':>3}{'normal':>9}{'deutan':>9}{'protan':>9}")
for name in ["tab10", "okabe_ito", "petroff6", "petroff10"]:
    for n in (3, 6):
        colors = [c[:3] for c in mpl.color_sequences[name]][:n]
        print(f"{name:<12}{n:>3}" + "".join(f"{worst(colors, t):>9.1f}" for t in ["normal", "deuteranomaly", "protanomaly"]))
palette       N   normal   deutan   protan
tab10         3     46.8     17.3      1.6
tab10         6     23.7      5.2      1.6
okabe_ito     3     56.8     54.7     51.2
okabe_ito     6     20.8     15.9     20.0
petroff6      3     34.5     21.3     33.5
petroff6      6     23.9     18.2     15.1
petroff10     3     41.4     33.6     41.9
petroff10     6     22.8     14.9     20.8

matplotlib’s default cycle, tab10, fails already at three series: its orange and green are 1.6 ΔE’ apart for a protanope, while with typical vision no pair of the three is closer than 46.8. The colorblind-oriented sequences (okabe_ito, petroff6, petroff10) never drop below 14.9 in this table, even at six colors. A threshold of 10 sits between the two groups. The okabe_ito rows include its first color, black; the fixed chart skips black and stays above 10 as well.

CL06 and CL07: labels and 3D

def check_labels(ax):
    """CL06: a chart without a title or any axis label makes readers guess."""
    is_pie = any(isinstance(p, Wedge) for p in ax.patches)
    has_data = ax.has_data()
    if has_data and not ax.get_title():
        yield Finding("CL06", _name(ax), "missing title")
    zlabel = ax.get_zlabel() if ax.name == "3d" else ""
    if has_data and not is_pie and not (ax.get_ylabel() or ax.get_xlabel() or zlabel):
        yield Finding("CL06", _name(ax), "missing axis labels")

CL07 is a one-liner inside lint(): ax.name == "3d".

Putting it together

def _colorbar_axes(fig):
    """Axes created by fig.colorbar(): helpers, not charts."""
    mappables = [m for ax in fig.axes for m in [*ax.images, *ax.collections]]
    return {m.colorbar.ax for m in mappables if getattr(m, "colorbar", None) is not None}


def _is_twin(ax, earlier):
    """An axes drawn on top of an earlier one with a shared axis (ax.twinx() or ax.twiny())."""
    siblings = [*ax.get_shared_x_axes().get_siblings(ax), *ax.get_shared_y_axes().get_siblings(ax)]
    return any(o in siblings and np.allclose(o.get_position().bounds, ax.get_position().bounds) for o in earlier)


def lint(fig, *, max_pie_slices=5, min_cvd_delta=10.0):
    findings, seen = [], set()  # no fig.canvas.draw(): get_xlim()/get_ylim() apply pending autoscaling
    helpers = _colorbar_axes(fig)
    charts = [ax for ax in fig.axes if ax not in helpers]
    for n, ax in enumerate(charts):
        if _is_twin(ax, charts[:n]):
            continue  # the dual-axis finding is reported on the main axes
        if ax.name == "3d":
            findings.append(Finding("CL07", _name(ax), "3D axes distort length and position"))
        findings += check_bar_baseline(ax)
        findings += check_dual_axis(ax, seen)
        findings += check_pie(ax, max_pie_slices)
        findings += check_colormap(ax)
        findings += check_cvd(ax, min_cvd_delta)
        findings += check_labels(ax)
    return findings

The two helpers exist because of the first test run. fig.axes contains every Axes in the figure, including the one fig.colorbar() creates and the one twinx() creates. Both are helpers without titles, so the fixed heatmap failed with CL06 [p50 latency, ms] missing title, which is the colorbar’s label. A colorbar is found through the public ScalarMappable.colorbar attribute, and a twin by the same position test as CL02.

"""chartlint: flag misleading or unreadable matplotlib figures before they ship."""
from dataclasses import dataclass
from itertools import combinations

import numpy as np
from colorspacious import cspace_convert, deltaE
from matplotlib.collections import Collection
from matplotlib.colors import to_rgb
from matplotlib.container import BarContainer
from matplotlib.patches import Wedge

RAINBOW_CMAPS = {"jet", "turbo", "rainbow", "gist_rainbow", "nipy_spectral", "hsv"}
CVD_TYPES = ("deuteranomaly", "protanomaly")


@dataclass(frozen=True)
class Finding:
    rule: str
    where: str
    message: str

    def __str__(self):
        return f"{self.rule} [{self.where}] {self.message}"


def _name(ax):
    return ax.get_title() or ax.get_ylabel() or f"axes {ax.figure.axes.index(ax)}"


def check_bar_baseline(ax):
    """CL01: bar length encodes value, so the value axis must include zero."""
    for c in ax.containers:
        if not isinstance(c, BarContainer):
            continue
        horizontal = c.orientation == "horizontal"
        if (ax.get_xscale() if horizontal else ax.get_yscale()) == "log":
            continue  # a log axis can't contain 0
        lo, hi = ax.get_xlim() if horizontal else ax.get_ylim()
        if not (min(lo, hi) <= 0 <= max(lo, hi)):
            axis = "x" if horizontal else "y"
            yield Finding("CL01", _name(ax), f"bar chart {axis}-axis starts at {min(lo, hi):g}, not 0")
            return


def check_dual_axis(ax, seen):
    """CL02: two scales on one plot area (twinx/twiny). sharex subplots are fine."""
    for shared, axis in ((ax.get_shared_x_axes(), "y"), (ax.get_shared_y_axes(), "x")):
        for other in shared.get_siblings(ax):
            if other is ax or (other, ax) in seen:
                continue
            if np.allclose(other.get_position().bounds, ax.get_position().bounds):
                seen.add((ax, other))
                yield Finding("CL02", _name(ax), f"two {axis}-axes share one plot area; use two panels or an index")


def check_pie(ax, max_slices):
    """CL03: angles and areas are hard to compare; many slices make it worse."""
    wedges = [p for p in ax.patches if isinstance(p, Wedge)]
    if len(wedges) > max_slices:
        yield Finding("CL03", _name(ax), f"pie has {len(wedges)} slices (max {max_slices}); use sorted bars")


def check_colormap(ax):
    """CL04: rainbow colormaps have non-monotonic lightness."""
    for artist in [*ax.images, *[c for c in ax.collections if isinstance(c, Collection)]]:
        cmap = getattr(artist, "get_cmap", lambda: None)()
        if cmap is not None and artist.get_array() is not None and cmap.name.removesuffix("_r") in RAINBOW_CMAPS:
            yield Finding("CL04", _name(ax), f"colormap '{cmap.name}' is not perceptually uniform; try viridis or cividis")


def _series_colors(ax):
    colors = [line.get_color() for line in ax.get_lines() if not line.get_label().startswith("_")]
    colors += [c.patches[0].get_facecolor() for c in ax.containers if isinstance(c, BarContainer) and c.get_label()
               and not c.get_label().startswith("_")]
    return [to_rgb(c) for c in colors]


def check_cvd(ax, min_delta):
    """CL05: labelled line and bar series whose colors merge for red-green colorblind viewers."""
    colors = _series_colors(ax)
    for cvd in CVD_TYPES:
        space = {"name": "sRGB1+CVD", "cvd_type": cvd, "severity": 100}
        sim = np.clip(cspace_convert(np.array(colors), space, "sRGB1"), 0, 1) if colors else []
        for (i, a), (j, b) in combinations(enumerate(sim), 2):
            d = float(deltaE(a, b))
            if d < min_delta:
                yield Finding("CL05", _name(ax), f"series {i} and {j} differ by only {d:.1f} ΔE' under {cvd}")
                return


def check_labels(ax):
    """CL06: a chart without a title or any axis label makes readers guess."""
    is_pie = any(isinstance(p, Wedge) for p in ax.patches)
    has_data = ax.has_data()
    if has_data and not ax.get_title():
        yield Finding("CL06", _name(ax), "missing title")
    zlabel = ax.get_zlabel() if ax.name == "3d" else ""
    if has_data and not is_pie and not (ax.get_ylabel() or ax.get_xlabel() or zlabel):
        yield Finding("CL06", _name(ax), "missing axis labels")


def _colorbar_axes(fig):
    """Axes created by fig.colorbar(): helpers, not charts."""
    mappables = [m for ax in fig.axes for m in [*ax.images, *ax.collections]]
    return {m.colorbar.ax for m in mappables if getattr(m, "colorbar", None) is not None}


def _is_twin(ax, earlier):
    """An axes drawn on top of an earlier one with a shared axis (ax.twinx() or ax.twiny())."""
    siblings = [*ax.get_shared_x_axes().get_siblings(ax), *ax.get_shared_y_axes().get_siblings(ax)]
    return any(o in siblings and np.allclose(o.get_position().bounds, ax.get_position().bounds) for o in earlier)


def lint(fig, *, max_pie_slices=5, min_cvd_delta=10.0):
    findings, seen = [], set()  # no fig.canvas.draw(): get_xlim()/get_ylim() apply pending autoscaling
    helpers = _colorbar_axes(fig)
    charts = [ax for ax in fig.axes if ax not in helpers]
    for n, ax in enumerate(charts):
        if _is_twin(ax, charts[:n]):
            continue  # the dual-axis finding is reported on the main axes
        if ax.name == "3d":
            findings.append(Finding("CL07", _name(ax), "3D axes distort length and position"))
        findings += check_bar_baseline(ax)
        findings += check_dual_axis(ax, seen)
        findings += check_pie(ax, max_pie_slices)
        findings += check_colormap(ax)
        findings += check_cvd(ax, min_cvd_delta)
        findings += check_labels(ax)
    return findings

Step 3. Test each rule both ways

Every misleading chart must trigger exactly its own rule, and every fixed chart must come back clean. “Exactly” matters: it caught the colorbar false positive above. Seven more tests pin edge cases, several of them added after a review of the first version: log-scale bars, jet_r and twiny().

import matplotlib.pyplot as plt
import pytest

from charts import (browser_pie, latency_heatmap, signups_bars, team_lines,
                    unlabeled_bars, users_vs_revenue)
from chartlint import lint

EXPECTED = [
    (signups_bars, "CL01"),
    (users_vs_revenue, "CL02"),
    (browser_pie, "CL03"),
    (latency_heatmap, "CL04"),
    (team_lines, "CL05"),
    (unlabeled_bars, "CL06"),
]


@pytest.fixture(autouse=True)
def close_figures():
    yield
    plt.close("all")


@pytest.mark.parametrize("build, rule", EXPECTED, ids=[r for _, r in EXPECTED])
def test_misleading_version_is_flagged(build, rule):
    rules = {f.rule for f in lint(build(fixed=False))}
    assert rules == {rule}


@pytest.mark.parametrize("build", [b for b, _ in EXPECTED], ids=[r for _, r in EXPECTED])
def test_fixed_version_is_clean(build):
    assert lint(build(fixed=True)) == []


def test_sharex_subplots_are_not_dual_axes():
    fig, (a, b) = plt.subplots(2, 1, sharex=True)
    a.plot([1, 2], [1, 2]); b.plot([1, 2], [5, 3])
    assert not [f for f in lint(fig) if f.rule == "CL02"]


def test_line_chart_may_start_above_zero():
    fig, ax = plt.subplots()
    ax.plot([1, 2, 3], [98.1, 98.4, 98.2])
    ax.set_title("Uptime, %"); ax.set_ylabel("%")
    assert lint(fig) == []


def test_negative_bars_need_zero_too():
    fig, ax = plt.subplots()
    ax.bar(["a", "b"], [-5, -7]); ax.set_ylim(-8, -4)
    ax.set_title("Net change"); ax.set_ylabel("units")
    assert [f.rule for f in lint(fig)] == ["CL01"]


def test_3d_axes_are_flagged():
    fig = plt.figure()
    ax = fig.add_subplot(projection="3d")
    ax.bar3d([0, 1], [0, 0], [0, 0], 0.5, 0.5, [3, 5])
    ax.set_title("Deploys"); ax.set_zlabel("count")
    assert [f.rule for f in lint(fig)] == ["CL07"]


def test_log_scale_bars_are_not_truncated():
    fig, ax = plt.subplots()
    ax.bar(["a", "b"], [10, 1000]); ax.set_yscale("log")
    ax.set_title("Requests"); ax.set_ylabel("count")
    assert lint(fig) == []


def test_reversed_rainbow_is_still_rainbow():
    fig, ax = plt.subplots()
    ax.imshow([[1, 2], [3, 4]], cmap="jet_r")
    ax.set_title("Heat"); ax.set_ylabel("row")
    assert [f.rule for f in lint(fig)] == ["CL04"]


def test_twiny_is_a_dual_axis_too():
    fig, ax = plt.subplots()
    ax.plot([1, 2], [1, 2]); ax.set_title("Two x scales"); ax.set_ylabel("y")
    ax.twiny().plot([10, 20], [1, 2])
    assert [f.rule for f in lint(fig)] == ["CL02"]
python -m pytest -v test_chartlint.py
============================= test session starts ==============================
platform linux -- Python 3.13.16, pytest-9.1.1, pluggy-1.7.0 -- /srv/chartlint/.venv/bin/python
cachedir: .pytest_cache
rootdir: /srv/chartlint
configfile: pytest.ini
collecting ... collected 19 items

test_chartlint.py::test_misleading_version_is_flagged[CL01] PASSED       [  5%]
test_chartlint.py::test_misleading_version_is_flagged[CL02] PASSED       [ 10%]
test_chartlint.py::test_misleading_version_is_flagged[CL03] PASSED       [ 15%]
test_chartlint.py::test_misleading_version_is_flagged[CL04] PASSED       [ 21%]
test_chartlint.py::test_misleading_version_is_flagged[CL05] PASSED       [ 26%]
test_chartlint.py::test_misleading_version_is_flagged[CL06] PASSED       [ 31%]
test_chartlint.py::test_fixed_version_is_clean[CL01] PASSED              [ 36%]
test_chartlint.py::test_fixed_version_is_clean[CL02] PASSED              [ 42%]
test_chartlint.py::test_fixed_version_is_clean[CL03] PASSED              [ 47%]
test_chartlint.py::test_fixed_version_is_clean[CL04] PASSED              [ 52%]
test_chartlint.py::test_fixed_version_is_clean[CL05] PASSED              [ 57%]
test_chartlint.py::test_fixed_version_is_clean[CL06] PASSED              [ 63%]
test_chartlint.py::test_sharex_subplots_are_not_dual_axes PASSED         [ 68%]
test_chartlint.py::test_line_chart_may_start_above_zero PASSED           [ 73%]
test_chartlint.py::test_negative_bars_need_zero_too PASSED               [ 78%]
test_chartlint.py::test_3d_axes_are_flagged PASSED                       [ 84%]
test_chartlint.py::test_log_scale_bars_are_not_truncated PASSED          [ 89%]
test_chartlint.py::test_reversed_rainbow_is_still_rainbow PASSED         [ 94%]
test_chartlint.py::test_twiny_is_a_dual_axis_too PASSED                  [100%]

============================== 19 passed in 0.82s ==============================

Step 4. Run it as a report

"""Lint every chart in both versions and save the PNGs."""
import sys
from pathlib import Path

import matplotlib.pyplot as plt

from charts import CHARTS
from chartlint import lint

out = Path("figures")
out.mkdir(exist_ok=True)
failed = 0
for build in CHARTS:
    for fixed in (False, True):
        fig = build(fixed=fixed)
        findings = lint(fig)
        tag = "fixed" if fixed else "misleading"
        fig.savefig(out / f"{build.__name__}-{tag}.png", dpi=150, bbox_inches="tight")
        plt.close(fig)
        status = "ok  " if not findings else "FAIL"
        print(f"{status} {build.__name__:<18} {tag:<10} " + ("; ".join(map(str, findings)) or "-"))
        failed += bool(findings) and fixed
sys.exit(1 if failed else 0)
python report.py
FAIL signups_bars       misleading CL01 [New signups per quarter] bar chart y-axis starts at 9800, not 0
ok   signups_bars       fixed      -
FAIL browser_pie        misleading CL03 [Browser share] pie has 9 slices (max 5); use sorted bars
ok   browser_pie        fixed      -
FAIL users_vs_revenue   misleading CL02 [Users and revenue] two y-axes share one plot area; use two panels or an index
ok   users_vs_revenue   fixed      -
FAIL latency_heatmap    misleading CL04 [Latency by day and hour] colormap 'jet' is not perceptually uniform; try viridis or cividis
ok   latency_heatmap    fixed      -
FAIL team_lines         misleading CL05 [Build time by team] series 1 and 2 differ by only 1.6 ΔE' under protanomaly
ok   team_lines         fixed      -
FAIL unlabeled_bars     misleading CL06 [axes 0] missing title; CL06 [axes 0] missing axis labels
ok   unlabeled_bars     fixed      -

The exit code is 0 because only fixed charts count as failures here. In your own project, any finding should fail.

Step 5. See what a colorblind reader sees

A rule that says “1.6 ΔE’” is easy to dismiss. A picture isn’t. This script runs the same simulation over a saved PNG:

"""Preview a saved chart the way a protanope or deuteranope would see it."""
import sys

import matplotlib.image as mpimg
import numpy as np
from colorspacious import cspace_convert

src, cvd = sys.argv[1], sys.argv[2]  # e.g. figures/team_lines-misleading.png protanomaly
rgb = mpimg.imread(src)[..., :3]
space = {"name": "sRGB1+CVD", "cvd_type": cvd, "severity": 100}
mpimg.imsave(src.replace(".png", f"-{cvd}.png"), np.clip(cspace_convert(rgb, space, "sRGB1"), 0, 1))
python simulate_cvd.py figures/team_lines-misleading.png protanomaly
python simulate_cvd.py figures/team_lines-fixed.png protanomaly


The build-time chart with matplotlib’s default colors, the same chart simulated for protanopia where Web and Mobile look the same, and the fixed Okabe-Ito version simulated for protanopia

The fix uses two things at once: the Okabe-Ito colors (built into matplotlib as color_sequences["okabe_ito"], skipping its first color, black) and markers. Okabe and Ito’s own guide recommends not relying on color alone, and suggests direct labels and different shapes or line styles.

Step 6. Lint every figure your tests already create

You probably have tests that call plotting code and only check that it doesn’t crash. One autouse fixture turns them into chart checks. It sees figures created through pyplot; a matplotlib.figure.Figure() built directly isn’t registered there.

# example/conftest.py: lint every figure a test leaves open
import matplotlib.pyplot as plt
import pytest

from chartlint import lint


@pytest.fixture(autouse=True)
def lint_open_figures():
    yield
    figures = [plt.figure(n) for n in plt.get_fignums()]
    plt.close("all")
    findings = [f for fig in figures for f in lint(fig)]
    assert not findings, "\n".join(map(str, findings))
# example/test_dashboard.py: an ordinary test of plotting code
from charts import signups_bars, team_lines


def test_dashboard_renders():
    signups_bars(fixed=True)
    team_lines(fixed=False)  # someone forgot the palette
The project’s pytest.ini sets pythonpath = ., so example/ can import chartlint and charts from the project root.
python -m pytest -q example
...
E       AssertionError: CL05 [Build time by team] series 1 and 2 differ by only 1.6 ΔE' under protanomaly
E       assert not [Finding(rule='CL05', where='Build time by team', message="series 1 and 2 differ by only 1.6 ΔE' under protanomaly")]

=========================== short test summary info ============================
ERROR example/test_dashboard.py::test_dashboard_renders - AssertionError: CL0...
1 passed, 1 error in 0.07s

Note the result: 1 passed, 1 error, not 1 failed. An assertion in fixture teardown is reported as an error in the test’s teardown. The exit code is still non-zero (exit code 1), so CI fails, but the wording surprises people the first time.

Gotchas

You don’t need fig.canvas.draw(). My first version drew the canvas before checking, to make sure autoscaled limits were final. get_ylim() already applies pending autoscaling. That’s how matplotlib implements it (viewLim calls _unstale_viewLim()), not a documented promise, so keep a test that checks limits. Removing the draw cut the 19-test suite from 2.65s to 0.83s with the same results.

Helper axes look like charts. Colorbars and twins live in fig.axes. Filter them before checking titles and labels (see Step 2).

Shared x is not dual axis. Detect twins by position, not by get_shared_x_axes() alone.

Thresholds are heuristics. The 5-slice and 10 ΔE’ limits are mine. Tune them, and keep the tests that show where they sit.

A linter can’t read intent. Correll et al. argue there is no universal answer to how big an effect should look, and a non-zero baseline can be justified when a small but important change would otherwise be invisible, such as a 0.3-point change in uptime. Exempting line charts handles the common case. For the rest, add an ignore parameter rather than deleting the rule.

Known blind spots. Bars drawn with bottom= or left= offsets (waterfalls, Gantt charts) and the top panel of a broken-axis chart trip CL01. CL05 ignores unlabelled lines and scatter plots. Axes created with ax.inset_axes() aren’t in fig.axes, and CL06 wants a title on every panel even when fig.suptitle() covers them. Each of these is a few lines to add once your charts need it.

What a linter can’t catch

chartlint inspects how data is drawn, not whether the data is honest. It won’t notice a cherry-picked date range, an average that hides a bimodal distribution, a chart that implies causation, or a wrong number in the source table. Those still need a human who knows the data.

Checklist: data visualization best practices you can automate

  • ☐ Bars start at zero; if the change is small, use dots or a line and label the change
  • ☐ One y-axis per plot area; use panels or an index chart for two series
  • ☐ No pies with more than a handful of slices; sorted bars instead
  • ☐ Sequential data uses viridis, cividis or another perceptually uniform colormap
  • ☐ Series colors survive a protanopia and deuteranopia simulation
  • ☐ Color is never the only cue: add markers, line styles or direct labels
  • ☐ Every chart has a title and units on the value axis
  • ☐ No 3D for 2D data
  • ☐ Plotting tests lint the figures they create

Wrapping up

The six charts here are textbook bad data visualization examples, and none of them needed bad intent. Each was one default or one convenience call away from the honest version. Once that call is a failing test, nobody has to remember it in review.
Which misleading graph pattern do you run into most in your team’s dashboards? I’d like to know what rule you’d add next.

Sources

  1. PixelPlex: Good and Bad Examples of Data Visualization
  2. Correll, Bertini, Franconeri (CHI 2020): Truncating the Y-Axis: Threat or Menace?
  3. Datawrapper: Why not to use two axes, and what to use instead
  4. Stephen Few, Perceptual Edge: Save the Pies for Dessert
  5. Matplotlib: Choosing Colormaps in Matplotlib
  6. Matplotlib: Named color sequences
  7. Okabe & Ito: Color Universal Design: How to make figures and presentations that are friendly to colorblind people
  8. colorspacious: Reference (CVD simulation and deltaE)
  9. National Eye Institute: Color Blindness
  10. pytest: How to use fixtures
📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.