,

Data visualization that doesn’t lie: choosing the right plot for the question

LEARN · EXPLORATORY DATA ANALYSIS & STATISTICS

A chart is not neutral merely because every number in it is technically correct.

You can take an accurate dataset, plot the correct values, make no arithmetic mistakes, and still produce a graphic that gives readers the wrong impression. The problem may be a truncated baseline, a carefully tuned second axis, an omitted category, an inappropriate pie chart, or simply a plot type that answers a different question from the one your audience thinks it answers.

Good visualization starts one step earlier than plotting:

What question should the reader be able to answer after looking at this chart?

Once that question is explicit, choosing the chart becomes much easier.

This tutorial develops that idea through runnable Python makeovers using current Matplotlib and seaborn APIs. The examples deliberately use ordinary business and telemetry data rather than specialized datasets, so you can reuse the patterns almost anywhere.

Set up a small visualization environment

Create an isolated environment and install the libraries we will use:

python -m venv .venv

On macOS or Linux:

source .venv/bin/activate

On Windows PowerShell:

.venv\Scripts\Activate.ps1

Then install the packages:

python -m pip install matplotlib seaborn pandas numpy

The current Matplotlib documentation exposes the object-oriented Axes APIs used throughout this article, while seaborn 0.13.2 documents the barplot() and lineplot() interfaces shown here. In particular, seaborn’s current barplot() documentation explicitly notes that bars include zero in their axis range because bar height encodes magnitude.

A useful starting template is:

import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid")

fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")

# Draw something on ax here.

ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.show()

Using an explicit fig, ax pair scales better than relying on global plotting state. It also makes it much harder to accidentally modify the wrong chart when a notebook grows beyond a few cells.

Choose the plot from the question, not from your favorite chart type

Most everyday visualization questions fall into a small number of families.

  • How much? Use bars when comparing magnitudes across discrete categories.

  • How has something changed over time? Usually use a line chart.

  • How are observations distributed? Use a histogram, box plot, violin plot, ECDF, or individual points depending on the detail required.

  • Are two numeric variables related? Start with a scatter plot.

  • How does a total break down? Consider stacked bars or ordinary bars before reaching for a pie.

  • How do groups compare across several conditions? Use grouped plots, facets, or small multiples.

  • How uncertain is this estimate? Plot uncertainty explicitly instead of showing only a point estimate.

The critical distinction is between the data you possess and the comparison you want the viewer to make.

Suppose an e-commerce table contains:

  • month,

  • visitors,

  • orders,

  • revenue,

  • average order value,

  • refund rate.

That does not imply all six fields belong on one dashboard chart.

If the question is “Did revenue rise?”, plot revenue over time.

If the question is “Did traffic growth explain the revenue increase?”, plot traffic and revenue in a form that makes their relative changes comparable.

If the question is “Which month converted visitors most efficiently?”, calculate and plot conversion rate.

A visualization should reduce cognitive work, not transfer analytical work to the reader.

Makeover 1: the truncated bar chart

Consider two fulfillment centers with almost identical on-time delivery rates:

  • North: 96.2%

  • South: 97.1%

Here is a perfectly legal but badly misleading chart:

import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid")

centers = ["North", "South"]
on_time = [96.2, 97.1]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")

ax.bar(centers, on_time)
ax.set_ylim(95.8, 97.3)
ax.set_ylabel("Orders delivered on time (%)")
ax.set_title("South dramatically outperforms North")

plt.show()

Nothing in the data is fabricated.

But the visual encoding is broken.

A bar represents magnitude through length. When the baseline starts at 95.8 instead of zero, the viewer no longer sees bars whose lengths are proportional to 96.2 and 97.1. They see the visible fragments above 95.8.

Those visible lengths are:

  • North: 0.4 percentage points

  • South: 1.3 percentage points

So South’s visible bar is more than three times as tall even though the underlying values differ by only 0.9 percentage points.

That is not a harmless aesthetic decision. It changes the visual statement.

The honest bar-chart version

If absolute performance is the comparison, preserve the zero baseline:

import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid")

centers = ["North", "South"]
on_time = [96.2, 97.1]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")

bars = ax.bar(centers, on_time)
ax.set_ylim(0, 100)
ax.set_ylabel("Orders delivered on time (%)")
ax.set_title("On-time delivery rates are similar")

ax.bar_label(bars, fmt="%.1f%%", padding=3)

plt.show()

Matplotlib’s current bar() API uses a default bottom of zero, and its bar_label() helper can label the resulting bars directly.

But what if the small difference really matters?

This is where “always start every axis at zero” becomes too simplistic.

Suppose a service-level agreement requires 97% on-time delivery. The difference between 96.2% and 97.1% may be operationally important even though it is visually small on a 0–100 scale.

The solution is not to distort bar lengths.

Change the encoding.

A dot plot can emphasize position rather than length:

import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid")

centers = ["North", "South"]
on_time = [96.2, 97.1]

fig, ax = plt.subplots(figsize=(7, 3.5), layout="constrained")

ax.scatter(on_time, centers, s=100)
ax.axvline(97.0, linestyle="--", label="SLA: 97%")

ax.set_xlim(95.5, 97.5)
ax.set_xlabel("Orders delivered on time (%)")
ax.set_title("South clears the 97% service target")
ax.legend()

plt.show()

Now the non-zero axis is defensible because the values are encoded by position, not by bar length.

That gives us a better rule:

When length represents magnitude, the baseline is part of the data encoding. When position represents value, a focused range can often be appropriate if it is clearly labeled.

Makeover 2: dual axes can manufacture a relationship

Dual-axis charts are seductive because they solve an obvious layout problem: two metrics use different units.

Suppose a retailer records monthly advertising spend and revenue:

import pandas as pd

df = pd.DataFrame(
    {
        "month": [
            "Jan",
            "Feb",
            "Mar",
            "Apr",
            "May",
            "Jun",
            "Jul",
            "Aug",
        ],
        "ad_spend_k": [45, 52, 58, 60, 68, 73, 77, 85],
        "revenue_k": [420, 415, 438, 430, 455, 451, 470, 468],
    }
)

print(df)

You can place one series on each vertical axis:

import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame(
    {
        "month": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
        "ad_spend_k": [45, 52, 58, 60, 68, 73, 77, 85],
        "revenue_k": [420, 415, 438, 430, 455, 451, 470, 468],
    }
)

fig, ax1 = plt.subplots(figsize=(9, 5), layout="constrained")
ax2 = ax1.twinx()

ax1.plot(df["month"], df["ad_spend_k"], marker="o", label="Ad spend")
ax2.plot(df["month"], df["revenue_k"], marker="s", label="Revenue")

ax1.set_ylabel("Advertising spend ($000)")
ax2.set_ylabel("Revenue ($000)")
ax1.set_title("Advertising spend and revenue")

plt.show()

Matplotlib supports this directly through Axes.twinx(): it creates another axes sharing the x-axis while retaining an independent y-scale.

Technically valid does not mean analytically safe.

Because the two vertical scales are independent, you can alter their limits until peaks, troughs, and slopes appear to match surprisingly well.

That visual alignment can imply a relationship much stronger than the raw values justify.

The chart does not literally say “advertising caused revenue growth.”

It does something more subtle: it encourages your visual system to reach that conclusion before your analytical system checks it.

Better option: small multiples

Give each measure its own panel while sharing the same time axis.

import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame(
    {
        "month": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
        "ad_spend_k": [45, 52, 58, 60, 68, 73, 77, 85],
        "revenue_k": [420, 415, 438, 430, 455, 451, 470, 468],
    }
)

fig, axes = plt.subplots(
    2,
    1,
    figsize=(9, 6),
    sharex=True,
    layout="constrained",
)

axes[0].plot(df["month"], df["ad_spend_k"], marker="o")
axes[0].set_ylabel("Ad spend ($000)")
axes[0].set_title("Advertising spend")

axes[1].plot(df["month"], df["revenue_k"], marker="o")
axes[1].set_ylabel("Revenue ($000)")
axes[1].set_title("Revenue")

fig.suptitle("Compare trends without competing y-scales")

plt.show()

Matplotlib specifically supports shared axes for plots intended to be compared across multiple panels.

Small multiples use a bit more vertical space, but they eliminate a major source of ambiguity.

That is usually a profitable trade.

Better option: normalize when relative change is the question

Sometimes the actual question is:

How much has each metric changed relative to where it started?

In that case, convert both series to an index where January = 100.

import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame(
    {
        "month": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
        "ad_spend_k": [45, 52, 58, 60, 68, 73, 77, 85],
        "revenue_k": [420, 415, 438, 430, 455, 451, 470, 468],
    }
)

df["ad_spend_index"] = df["ad_spend_k"] / df["ad_spend_k"].iloc[0] * 100
df["revenue_index"] = df["revenue_k"] / df["revenue_k"].iloc[0] * 100

fig, ax = plt.subplots(figsize=(9, 5), layout="constrained")

ax.plot(
    df["month"],
    df["ad_spend_index"],
    marker="o",
    label="Advertising spend",
)

ax.plot(
    df["month"],
    df["revenue_index"],
    marker="o",
    label="Revenue",
)

ax.axhline(100, linewidth=1, linestyle="--")
ax.set_ylabel("Index, January = 100")
ax.set_title("Advertising grew much faster than revenue")
ax.legend()

plt.show()

This chart puts both quantities into the same meaningful unit: percentage-like change relative to the starting month.

Notice how the story changes.

A dual-axis chart might have encouraged the conclusion that the curves “move together.” The indexed plot instead makes it obvious that advertising spend increased far more aggressively than revenue.

Same data.

Better question.

Better chart.

Makeover 3: pie-chart abuse

Suppose a support team categorizes 1,000 tickets:

ticket_counts = {
    "Billing": 280,
    "Login": 230,
    "Shipping": 190,
    "Returns": 170,
    "Other": 130,
}

A pie chart is possible:

import matplotlib.pyplot as plt

labels = ["Billing", "Login", "Shipping", "Returns", "Other"]
values = [280, 230, 190, 170, 130]

fig, ax = plt.subplots(figsize=(7, 5), layout="constrained")

ax.pie(
    values,
    labels=labels,
    autopct="%1.0f%%",
    startangle=90,
)

ax.set_title("Support ticket mix")

plt.show()

This is not mathematically false.

But ask what the reader is supposed to determine.

Can you tell instantly whether Shipping or Returns is larger?

Can you tell by how much?

Can you rank all five categories without repeatedly looking between wedges and labels?

A pie encodes values as angles and areas. Humans are generally much better at comparing positions along a common scale.

So if the question is which categories generate the most tickets?, use bars:

import matplotlib.pyplot as plt

ticket_counts = {
    "Billing": 280,
    "Login": 230,
    "Shipping": 190,
    "Returns": 170,
    "Other": 130,
}

items = sorted(
    ticket_counts.items(),
    key=lambda item: item[1],
)

labels = [item[0] for item in items]
values = [item[1] for item in items]

fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")

bars = ax.barh(labels, values)
ax.bar_label(bars, padding=3)

ax.set_xlabel("Tickets")
ax.set_title("Billing generates the most support tickets")
ax.set_xlim(0, max(values) * 1.15)

plt.show()

The ranking is immediate.

The difference between 190 and 170 is immediate.

The scale is visible.

And adding a sixth category does not turn the visualization into geometric soup.

When is a pie chart defensible?

Pie charts are most reasonable when all of the following are true:

  • you genuinely care about part-to-whole composition;

  • there are only a few categories;

  • the categories sum to a meaningful whole;

  • differences are large enough to see without precise comparison;

  • ranking is not the main analytical task.

Even then, bars often communicate the same information more efficiently.

Makeover 4: don’t turn raw distributions into averages too early

Suppose two server clusters have almost the same average latency.

Generate a reproducible dataset:

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

cluster_a = rng.normal(loc=105, scale=8, size=300)

cluster_b_fast = rng.normal(loc=92, scale=6, size=260)
cluster_b_slow = rng.normal(loc=190, scale=15, size=40)
cluster_b = np.concatenate([cluster_b_fast, cluster_b_slow])

df = pd.DataFrame(
    {
        "cluster": ["A"] * len(cluster_a) + ["B"] * len(cluster_b),
        "latency_ms": np.concatenate([cluster_a, cluster_b]),
    }
)

print(df.groupby("cluster")["latency_ms"].mean())

Imagine summarizing this with one bar per mean:

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

cluster_a = rng.normal(loc=105, scale=8, size=300)
cluster_b = np.concatenate(
    [
        rng.normal(loc=92, scale=6, size=260),
        rng.normal(loc=190, scale=15, size=40),
    ]
)

df = pd.DataFrame(
    {
        "cluster": ["A"] * 300 + ["B"] * 300,
        "latency_ms": np.concatenate([cluster_a, cluster_b]),
    }
)

fig, ax = plt.subplots(figsize=(7, 5), layout="constrained")

sns.barplot(
    data=df,
    x="cluster",
    y="latency_ms",
    errorbar=None,
    ax=ax,
)

ax.set_ylabel("Mean latency (ms)")
ax.set_title("Average latency by cluster")

plt.show()

The means compress 600 observations into two rectangles.

That may be exactly what you want if the business decision is explicitly based on arithmetic means.

But here it hides the important behavior: Cluster B contains a slow tail.

The distribution tells the operational story better:

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

cluster_a = rng.normal(loc=105, scale=8, size=300)
cluster_b = np.concatenate(
    [
        rng.normal(loc=92, scale=6, size=260),
        rng.normal(loc=190, scale=15, size=40),
    ]
)

df = pd.DataFrame(
    {
        "cluster": ["A"] * 300 + ["B"] * 300,
        "latency_ms": np.concatenate([cluster_a, cluster_b]),
    }
)

fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")

sns.boxplot(
    data=df,
    x="cluster",
    y="latency_ms",
    ax=ax,
)

sns.stripplot(
    data=df,
    x="cluster",
    y="latency_ms",
    alpha=0.2,
    size=3,
    ax=ax,
)

ax.set_ylabel("Request latency (ms)")
ax.set_title("Cluster B has a substantial high-latency tail")

plt.show()

This illustrates a broader rule:

Do not aggregate away the phenomenon you are trying to understand.

Seaborn’s categorical visualization APIs deliberately provide several levels of granularity, including individual-observation plots, distribution plots, and estimate plots. The appropriate choice depends on whether you need the observations, their distribution, or an aggregate.

Error bars are information, not decoration

Suppose you compare average order values between two acquisition campaigns.

A chart containing only two means makes both estimates look equally certain.

They may not be.

Current seaborn APIs use the errorbar parameter to specify uncertainty or spread around statistical estimates.

Here is a synthetic example:

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd

rng = np.random.default_rng(7)

campaign_a = rng.normal(75, 10, 500)
campaign_b = rng.normal(79, 24, 60)

df = pd.DataFrame(
    {
        "campaign": ["A"] * len(campaign_a) + ["B"] * len(campaign_b),
        "order_value": np.concatenate([campaign_a, campaign_b]),
    }
)

fig, ax = plt.subplots(figsize=(7, 5), layout="constrained")

sns.pointplot(
    data=df,
    x="campaign",
    y="order_value",
    errorbar=("ci", 95),
    capsize=0.2,
    ax=ax,
)

ax.set_ylabel("Order value")
ax.set_title("Mean order value with 95% confidence intervals")

plt.show()

The important question is not “Do error bars make this look more scientific?”

It is:

What uncertainty does this interval represent?

A confidence interval around an estimated mean is not the same thing as the spread of individual customer orders.

Seaborn distinguishes uncertainty intervals such as confidence intervals from measures describing the underlying data distribution, such as standard deviation or percentile intervals.

Label the concept, not merely the graphic.

Line charts do not inherit the bar-chart zero rule

A common overcorrection is:

“A truthful y-axis always starts at zero.”

Not so.

Consider CPU temperature varying between 68°C and 74°C.

import matplotlib.pyplot as plt

minutes = [0, 10, 20, 30, 40, 50, 60]
temperature = [68.2, 69.0, 70.8, 72.5, 73.8, 72.9, 71.7]

fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")

ax.plot(minutes, temperature, marker="o")
ax.set_ylim(65, 76)

ax.set_xlabel("Minutes")
ax.set_ylabel("CPU temperature (°C)")
ax.set_title("CPU temperature during the load test")

plt.show()

Starting at zero would compress meaningful variation into a nearly flat line.

That is not inherently more truthful.

The reason is again the encoding.

With bars, visible length conventionally represents the value relative to a baseline.

With a line chart, values are primarily represented by positions along the y-axis. A focused axis can therefore be legitimate.

What matters is that:

  • the scale is clearly labeled;

  • intervals are uniform;

  • the chosen range does not conceal relevant context;

  • annotations do not exaggerate what the numeric change means.

The correct axis depends on the semantic question, not on a universal zero-baseline commandment.

Don’t let an automatically chosen axis decide your story

Autoscaling is convenient during exploration.

It is not an editorial policy.

Matplotlib lets you explicitly control view limits, and setting limits disables autoscaling on that axis.

Before publishing, inspect them deliberately:

import matplotlib.pyplot as plt

values = [48, 51, 50, 52, 53]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")

ax.plot(values, marker="o")

print("x limits:", ax.get_xlim())
print("y limits:", ax.get_ylim())

plt.show()

Then ask:

  • Is this range analytically justified?

  • Would a different reasonable range materially change the apparent story?

  • Does zero have semantic significance?

  • Is there a threshold the viewer needs to see?

  • Are comparable charts using comparable scales?

For dashboards, the last question is particularly important.

A chart showing Region A from 0–1,000 next to Region B from 450–550 can make Region B appear wildly volatile while Region A appears stable, even if the absolute fluctuations are similar.

Shared axes solve this when direct comparison is the goal.

Color should carry meaning only when you need it

Color is powerful enough that readers assume it means something.

If every bar has a different color but color does not encode a variable, the chart quietly asks the viewer to decode a nonexistent legend.

Prefer one visual emphasis at a time.

For example:

import matplotlib.pyplot as plt

products = ["Basic", "Standard", "Plus", "Pro"]
renewal = [72, 78, 81, 91]

fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")

bars = ax.bar(products, renewal)

for index, bar in enumerate(bars):
    if products[index] != "Pro":
        bar.set_alpha(0.45)

ax.set_ylim(0, 100)
ax.set_ylabel("Renewal rate (%)")
ax.set_title("Pro has the highest renewal rate")

plt.show()

Here emphasis has a purpose: direct the reader toward the category discussed in the title.

If color represents a true category, use it consistently across the report.

If color represents numeric magnitude, use an ordered scale.

If the same distinction can be expressed redundantly with markers or line styles, that can improve accessibility. Seaborn’s current line-plot documentation specifically notes that redundant semantics such as combining color and style for the same grouping can be useful for accessibility.

Cherry on the cake: a real 2026 chart that misled without falsifying its displayed numbers

One of the most useful recent examples demonstrates why “the numbers shown are correct” is an inadequate standard.

On February 25, 2026, Full Fact examined a UK by-election bar chart shared by Labour about polling in Gorton and Denton.

The accompanying message emphasized that there was one percentage point between Labour and Reform UK.

Those two displayed figures were accurate.

The problem was the category that was not displayed.

The underlying Opinium poll showed:

  • Green Party: 28%

  • Labour: 28%

  • Reform UK: 27%

Yet the published comparison omitted the Greens, even though they were level with Labour. Full Fact concluded that this missing context risked giving voters the impression that Labour alone was Reform’s closest challenger. It also reported that all three leading figures were within the poll’s approximately five-percentage-point margin of error.

This is visualization malpractice at a deeper level than axis manipulation.

You cannot repair it by changing fonts.

You cannot repair it by starting the y-axis at zero.

You cannot repair it by choosing a nicer palette.

The dataset supplied to the chart is incomplete for the claim being made.

A faithful simplified reconstruction would include all three leading results:

import matplotlib.pyplot as plt

parties = ["Green", "Labour", "Reform"]
poll_share = [28, 28, 27]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")

bars = ax.bar(parties, poll_share)

ax.set_ylim(0, 35)
ax.set_ylabel("Voting intention (%)")
ax.set_title("Poll shows a statistical three-way contest")

ax.bar_label(bars, fmt="%.0f%%", padding=3)

ax.text(
    0.5,
    -0.18,
    "Underlying poll sample and uncertainty matter; differences are small.",
    transform=ax.transAxes,
    ha="center",
)

plt.show()

This example should permanently change the question you ask during chart review.

Do not ask only:

“Is every plotted value correct?”

Also ask:

“What relevant values did we choose not to plot?”

Full Fact’s wider April 2026 investigation found more than 50 charts or graphics in the local-election leaflets it reviewed and judged at least 14 examples to be unsupported, unsourced, or misleading in some respect. The problems included national polling presented as evidence about local contests, outdated election results, unsourced figures, and other data-selection issues.

The lesson travels far beyond politics.

A SaaS company can show customer growth while omitting churn.

A marketplace can show gross merchandise value while omitting refunds.

An engineering team can chart mean latency while hiding the p99 tail.

A marketing dashboard can show conversions while hiding the denominator.

Data selection is itself a visual encoding decision.

Titles can lie even when the chart does not

Compare these titles:

  • “Revenue by month”

  • “Revenue continues its rapid expansion”

  • “Revenue increases only slightly despite higher advertising spend”

The first is descriptive.

The second makes an interpretive claim.

The third makes an interpretive comparison.

Interpretive titles can be excellent because they tell readers what matters. But once a title asserts a conclusion, the chart must actually support it.

Avoid titles that introduce:

  • causality that was not established;

  • certainty that the data does not contain;

  • adjectives such as “dramatic” without a meaningful baseline;

  • selective comparisons;

  • conclusions dependent on omitted categories.

A strong title should survive this test:

If someone saw only the chart and title, without the surrounding article, would the conclusion still be defensible?

Use direct labels when the legend makes readers work

Consider three time series.

The standard approach is a legend:

import matplotlib.pyplot as plt

months = [1, 2, 3, 4, 5, 6]

basic = [120, 126, 130, 137, 139, 145]
plus = [90, 98, 108, 119, 128, 141]
pro = [55, 62, 72, 85, 101, 122]

fig, ax = plt.subplots(figsize=(9, 5), layout="constrained")

ax.plot(months, basic, marker="o", label="Basic")
ax.plot(months, plus, marker="o", label="Plus")
ax.plot(months, pro, marker="o", label="Pro")

ax.set_xlabel("Month")
ax.set_ylabel("Active accounts")
ax.set_title("Active accounts by plan")
ax.legend()

plt.show()

There is nothing wrong with this.

But the reader must repeatedly move between line and legend.

Direct labeling reduces that lookup cost:

import matplotlib.pyplot as plt

months = [1, 2, 3, 4, 5, 6]

series = {
    "Basic": [120, 126, 130, 137, 139, 145],
    "Plus": [90, 98, 108, 119, 128, 141],
    "Pro": [55, 62, 72, 85, 101, 122],
}

fig, ax = plt.subplots(figsize=(9, 5), layout="constrained")

for name, values in series.items():
    line = ax.plot(months, values, marker="o")[0]
    ax.text(
        months[-1] + 0.08,
        values[-1],
        name,
        va="center",
        color=line.get_color(),
    )

ax.set_xlim(1, 6.6)
ax.set_xlabel("Month")
ax.set_ylabel("Active accounts")
ax.set_title("Plus and Pro are catching Basic")

plt.show()

This is a small design change with a large communication payoff.

A chart-review checklist

Before shipping a visualization, inspect it in roughly this order.

1. State the question

Finish the sentence:

“A reader should be able to use this chart to determine…”

If you cannot finish it cleanly, the chart probably lacks a purpose.

2. Check the denominator

Rates and percentages are meaningless without knowing what population they describe.

Ask:

  • percent of what?

  • over what period?

  • among which observations?

  • after which exclusions?

3. Check what was omitted

Inspect categories, date ranges, failed observations, zero values, and comparison groups.

A selective chart can be more misleading than an incorrectly styled chart.

4. Identify the visual encoding

What represents quantity?

  • length,

  • position,

  • area,

  • angle,

  • color,

  • size?

Then ask whether changes in that visual property remain proportional to changes in the data.

5. Inspect axis limits

For bar lengths, preserve a meaningful baseline.

For positional encodings such as lines and dots, decide whether a focused range is justified.

Never hide the scale.

6. Look for competing scales

If you use a secondary y-axis, ask whether small changes to either scale could make the series appear more or less correlated.

If yes, try small multiples or normalization.

7. Show uncertainty when the chart contains estimates

Do not present an estimate as though it were an exact measurement.

8. Preserve important distributions

Do not replace skew, multimodality, or outliers with a mean unless the mean is genuinely what the decision requires.

9. Read the title as a claim

Verify that the graphic supports every substantive word in it.

10. Remove decoration that competes with data

Every legend, grid line, label, marker, annotation, and color should either help answer the question or get out of the way.

Build charts as arguments with audit trails

For production work, it is useful to separate data preparation from plotting.

Instead of embedding calculations inside chart commands, make the transformations explicit:

import pandas as pd

orders = pd.DataFrame(
    {
        "channel": ["Search", "Social", "Email"],
        "sessions": [12000, 9000, 4500],
        "orders": [720, 405, 360],
    }
)

orders["conversion_rate"] = (
    orders["orders"] / orders["sessions"] * 100
)

print(orders)

Then visualize the already-defined measure:

import matplotlib.pyplot as plt
import pandas as pd

orders = pd.DataFrame(
    {
        "channel": ["Search", "Social", "Email"],
        "sessions": [12000, 9000, 4500],
        "orders": [720, 405, 360],
    }
)

orders["conversion_rate"] = (
    orders["orders"] / orders["sessions"] * 100
)

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")

bars = ax.bar(
    orders["channel"],
    orders["conversion_rate"],
)

ax.set_ylim(0, 10)
ax.set_ylabel("Conversion rate (%)")
ax.set_title("Email has the highest conversion rate")

ax.bar_label(bars, fmt="%.1f%%", padding=3)

plt.show()

This separation matters because reviewers can audit the analytical definition independently from the visual design.

If someone disputes the chart, you can ask whether the disagreement concerns:

  1. the raw data,

  2. the transformation,

  3. the statistical measure,

  4. the visual encoding,

  5. or the written interpretation.

That is far easier than debugging all five at once.

The deeper principle: optimize for faithful comparison

Great charts are not necessarily minimalist.

They are not necessarily colorful.

They are not necessarily interactive.

They are not necessarily made with the fanciest plotting library.

A great chart makes the relevant comparison easy and the irrelevant comparison difficult.

If the reader needs to compare category magnitudes, align them on a common baseline.

If the reader needs to compare trends, align time.

If the reader needs to compare relative change, normalize deliberately.

If the reader needs to understand variability, show the distribution.

If the reader needs to understand uncertainty, show an interval.

If the reader needs context, do not crop that context out of the dataset.

And if the chart seems to tell an astonishing story, try actively to destroy that story before publishing it:

  • extend the axis;

  • change the timeframe;

  • include excluded categories;

  • separate dual axes;

  • plot raw observations;

  • calculate the denominator;

  • add uncertainty;

  • replace the chart type.

If the conclusion survives those transformations, it is far more likely to be a property of the data rather than a property of your design.

Your next step

Open one chart you created recently and do a deliberate adversarial review.

Ask:

What conclusion does this graphic encourage before the reader examines the numbers?

Then remake it once:

  • restore a misleading baseline;

  • replace dual axes with small multiples;

  • replace a pie with sorted bars;

  • reveal the underlying distribution;

  • include an omitted comparison group;

  • or add the uncertainty the original chart hid.

Do not judge the remake by whether it looks more impressive.

Judge it by whether a careful reader and a hurried reader are now more likely to reach the same, defensible conclusion.

That is the standard worth optimizing for: not charts that merely contain true numbers, but visualizations that make the truth difficult to misunderstand.