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
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:
- Build six misleading graphs, each with a fixed version
- Write chartlint, about 130 lines with seven rules
- Test every rule against the misleading and the fixed chart
- Run it as a report over all charts
- Preview a chart the way a colorblind reader sees it
- 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
- PixelPlex: Good and Bad Examples of Data Visualization
- Correll, Bertini, Franconeri (CHI 2020): Truncating the Y-Axis: Threat or Menace?
- Datawrapper: Why not to use two axes, and what to use instead
- Stephen Few, Perceptual Edge: Save the Pies for Dessert
- Matplotlib: Choosing Colormaps in Matplotlib
- Matplotlib: Named color sequences
- Okabe & Ito: Color Universal Design: How to make figures and presentations that are friendly to colorblind people
- colorspacious: Reference (CVD simulation and deltaE)
- National Eye Institute: Color Blindness
- pytest: How to use fixtures
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.