Choosing The Right Chart A Decision Framework Not
You’ve stared at a dataset for an hour. You click through every chart type in your tool — bar, line, pie, scatter, area, bubble — and somehow end up with a pie chart that looks like a clown exploded. The colors clash, the slices are too many to read, and you know deep down this isn’t the right choice. But what is?
Every chart-chooser infographic you’ve ever seen feels like a horoscope — vaguely true but never quite right. “Never use pie charts.” “Always start bars at zero.” These rules sound like wisdom, but they’re just style-guide dogma. They don’t help you think.
The real problem is simple: we pick a chart type based on what’s popular or what we already know, not on what the data is trying to say. We treat chart choice like picking a shirt — what looks good? — instead of like writing a sentence — what do I need to communicate?
There’s a better way. It’s called a decision framework, and it flips the question from “what chart should I use?” to “what message am I trying to convey?” The chart type becomes a consequence, not a starting point.
The Financial Times Visual Vocabulary — a tool originally built to stop newsroom designers from picking charts by aesthetics — proves this works. Their designers ask “what story are we telling?” first. The chart follows.
So how do we build a framework that actually works?
Intuition: Two Ways to Enter the Same Room
Think of chart choice like entering a room. You can walk in through the front door (message-first) or through the back door (data-shape-first). Both get you inside, but which door you choose changes what you see first.
Message-first means you start with a sentence: “I want to show that sales in Q3 deviated from the trend.” That’s a Deviation message in the FT Visual Vocabulary. The recommended chart? A slope chart or dumbbell plot.
Data-shape-first means you start with the structure of your data: “I have one numeric variable and one categorical variable.” That’s a Num & Cat shape in the data-to-viz.com decision tree. The recommended chart? A bar chart or box plot.
Here’s the catch: the same data shape can serve different messages. A numeric + categorical dataset could be used to show ranking (bar chart) or distribution (box plot). The chart type changes even though the data format is identical.
Plain English: knowing you have flour and eggs doesn’t tell you whether to bake a cake or an omelet. The dish depends on what you’re hungry for, not just what’s in the pantry.
Let’s see what happens when we apply both approaches to the same small dataset.
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Create a small dataset: sales by region (categorical) and revenue (numeric)
data = pd.DataFrame({
'region': ['North', 'South', 'East', 'West', 'Central'],
'revenue': [120, 95, 150, 110, 130]
})
# Message-first: ranking message -> bar chart sorted descending
data_sorted = data.sort_values('revenue', ascending=False)
plt.figure(figsize=(8, 4))
sns.barplot(data=data_sorted, x='region', y='revenue', palette='Blues_d')
plt.title('Revenue by Region (Ranking Message)')
plt.ylabel('Revenue ($K)')
plt.show()
# Same data, different message: distribution message -> box plot (but we need multiple values per region)
# So we'll generate a more detailed dataset for distribution
import numpy as np
np.random.seed(42)
dist_data = pd.DataFrame({
'region': np.repeat(['North', 'South', 'East', 'West', 'Central'], 20),
'revenue': np.concatenate([
np.random.normal(120, 15, 20),
np.random.normal(95, 10, 20),
np.random.normal(150, 20, 20),
np.random.normal(110, 12, 20),
np.random.normal(130, 18, 20)
])
})
plt.figure(figsize=(8, 4))
sns.boxplot(data=dist_data, x='region', y='revenue', palette='Set2')
plt.title('Revenue Distribution by Region (Distribution Message)')
plt.ylabel('Revenue ($K)')
plt.show()
The first chart says “here’s the ranking — East is highest, South is lowest.” The second says “here’s how spread out each region’s revenue is — East has the widest range.” Same data shape, different messages, different charts. That’s why a framework, not a style guide, is necessary.
The Hard Part: Why ‘Always Start at Zero’ Is a Lie
But wait — this is the hardest part of causal inference. Some rules we treat as gospel — like “always start bar charts at zero” — are actually context-dependent. The blanket rule is wrong.
The “always start at zero” rule comes from Edward Tufte’s concept of the lie factor: if you truncate the y-axis, the visual difference between bars is exaggerated, misleading the viewer. That’s true when the message is “look how big the difference is.”
But what if your message is “look at this small but meaningful difference”?
In 2019, researchers Correll, Bertini, and Franconeri published a paper titled “Truncating the Y-Axis: Threat or Menace?” They found that y-axis truncation can actually be helpful when you want to highlight a small effect size — like a 2% increase in conversion rate. A full-axis bar chart makes the bars look nearly identical; you’d miss the point entirely.
Plain English: If you’re showing a 2% lift in an A/B test, a full-axis bar chart hides the story. A truncated axis is the right choice if your message is the small effect size.
Let’s see this in action.
import matplotlib.pyplot as plt
import numpy as np
# Conversion rates: 5.1% vs 5.3% (a 0.2 percentage point difference)
categories = ['Control', 'Treatment']
rates = [5.1, 5.3]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
# Full-axis bar chart (start at 0)
ax1.bar(categories, rates, color=['gray', 'blue'])
ax1.set_ylim(0, 6)
ax1.set_title('Full Axis (0 to 6%)\nBars look nearly identical')
ax1.set_ylabel('Conversion Rate (%)')
# Truncated axis bar chart (start at 5%)
ax2.bar(categories, rates, color=['gray', 'blue'])
ax2.set_ylim(5, 5.4)
ax2.set_title('Truncated Axis (5% to 5.4%)\nDifference is visible')
ax2.set_ylabel('Conversion Rate (%)')
plt.tight_layout()
plt.show()
Both charts are technically correct. Which one tells the story you need? If your message is “the treatment barely improved anything,” the full-axis chart is honest. If your message is “the treatment produced a small but real improvement,” the truncated chart is better. The framework forces you to ask “what am I trying to communicate?” before applying the rule.
Building the Framework: A Three-Question Decision Tree
Let’s combine the message-first and data-shape-first approaches into a single workflow. You’ll ask three questions, in order.
Question 1: What is the primary relationship I want to show?
Pick one from the FT Visual Vocabulary’s eight categories (or Andrew Abela’s four): Comparison, Distribution, Relationship, Composition, Change over Time, Deviation, Ranking, Magnitude, Spatial. This is your message.
Question 2: What is the shape of my data?
Pick from data-to-viz.com’s format tree: One numeric? One categorical? Two numeric? Numeric + categorical? Time series? Network? Map? This is your constraint.
Question 3: What is the effect size I need to communicate?
Large differences → full-axis bar chart. Small differences → truncated axis or dot plot. Rank changes → bump chart. This is where the Correll et al. insight lives.
Here’s a Python function that implements a simplified version of this decision tree. It won’t cover every case, but it shows the logic.
def recommend_chart(message, data_shape, effect_size):
"""
Returns a shortlist of recommended chart types based on the three-question framework.
"""
recommendations = []
# Question 1: Message
if message == 'ranking':
recommendations.append('bar chart (sorted)')
elif message == 'distribution':
recommendations.append('box plot or histogram')
elif message == 'change over time':
recommendations.append('line chart')
elif message == 'composition':
recommendations.append('stacked bar chart or treemap')
elif message == 'relationship':
recommendations.append('scatter plot')
elif message == 'deviation':
recommendations.append('slope chart or dumbbell plot')
else:
recommendations.append('use your judgment')
# Question 2: Data shape (narrows the list)
if data_shape == 'numeric + categorical':
if message == 'ranking':
recommendations = ['bar chart (sorted)']
elif message == 'distribution':
recommendations = ['box plot', 'violin plot']
elif data_shape == 'time series':
recommendations = ['line chart']
elif data_shape == 'two numeric':
recommendations = ['scatter plot']
# Question 3: Effect size (adjusts axis or chart type)
if effect_size == 'small':
if 'bar chart' in recommendations:
recommendations.append('truncated axis bar chart or dot plot')
elif effect_size == 'large':
if 'bar chart' in recommendations:
recommendations.append('full-axis bar chart')
return recommendations
# Example usage
print(recommend_chart('ranking', 'numeric + categorical', 'large'))
print(recommend_chart('distribution', 'numeric + categorical', 'small'))
The function is a starting point, not a final answer. When the combination is ambiguous, it falls back to “use your judgment.” That’s intentional — the framework is a guide, not a straitjacket.
Common Mistakes This Framework Helps You Avoid
Let’s look at five common chart-choice mistakes. Each one violates the framework’s logic.
Mistake 1: Pie chart with too many categories
Violates Q1: Composition with >5 categories. Perceptual accuracy plummets (Cleveland & McGill, 1984).
Fix: Bar chart or treemap.
import matplotlib.pyplot as plt
# Bad: pie chart with 8 categories
categories = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
values = [20, 18, 15, 12, 10, 9, 8, 8]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
ax1.pie(values, labels=categories, autopct='%1.1f%%')
ax1.set_title('Bad: Pie Chart (8 slices - hard to compare)')
# Good: horizontal bar chart
ax2.barh(categories, values, color='steelblue')
ax2.set_title('Good: Bar Chart (easy to compare)')
ax2.set_xlabel('Value')
plt.tight_layout()
plt.show()
Mistake 2: Line chart with categorical x-axis
Violates Q2: Data shape is categorical, not time series.
Fix: Bar chart or dot plot.
import pandas as pd
import seaborn as sns
# Data: categories (not time)
data = pd.DataFrame({'category': ['Low', 'Medium', 'High', 'Very High'],
'score': [45, 62, 78, 91]})
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
# Bad: line chart (implies continuity)
sns.lineplot(data=data, x='category', y='score', marker='o', ax=ax1)
ax1.set_title('Bad: Line Chart (implies trend where none exists)')
# Good: bar chart
sns.barplot(data=data, x='category', y='score', ax=ax2, palette='viridis')
ax2.set_title('Good: Bar Chart (honors categorical nature)')
plt.tight_layout()
plt.show()
Mistake 3: Truncated y-axis when message is “big difference”
Violates Q3: Effect size is large, so full axis is honest.
Fix: Start at zero.
# Same data as earlier but with a large difference
categories = ['Control', 'Treatment']
rates = [5.0, 15.0] # 10 percentage point difference
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
# Bad: truncated axis exaggerates difference
ax1.bar(categories, rates, color=['gray', 'blue'])
ax1.set_ylim(4, 16)
ax1.set_title('Bad: Truncated Axis (exaggerates difference)')
ax1.set_ylabel('Conversion Rate (%)')
# Good: full axis
ax2.bar(categories, rates, color=['gray', 'blue'])
ax2.set_ylim(0, 20)
ax2.set_title('Good: Full Axis (honest representation)')
ax2.set_ylabel('Conversion Rate (%)')
plt.tight_layout()
plt.show()
Mistake 4: 3D chart for any purpose
Violates Q1: Introduces perceptual distortion without adding information.
Fix: 2D version of the same chart.
# Bad: 3D bar chart (simulated with matplotlib's 3D)
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure(figsize=(12, 5))
ax1 = fig.add_subplot(121, projection='3d')
x = [0, 1, 2, 3]
y = [0, 0, 0, 0]
z = [0, 0, 0, 0]
dx = dy = 0.5
dz = [3, 5, 2, 4]
ax1.bar3d(x, y, z, dx, dy, dz, color=['red', 'blue', 'green', 'orange'])
ax1.set_title('Bad: 3D Bar Chart (distorts perception)')
ax2 = fig.add_subplot(122)
ax2.bar(['A', 'B', 'C', 'D'], dz, color=['red', 'blue', 'green', 'orange'])
ax2.set_title('Good: 2D Bar Chart (accurate comparison)')
plt.tight_layout()
plt.show()
Mistake 5: Radar chart when a bar chart would do
Violates Q2: Multiple numeric variables → radar is hard to read; bar chart is more accurate.
Fix: Grouped bar chart or small multiples.
# Radar chart is complex to show here, so we'll skip the bad version.
# Instead, show the good alternative: grouped bar chart
import numpy as np
categories = ['Speed', 'Power', 'Accuracy', 'Durability', 'Cost']
product_a = [8, 6, 7, 5, 9]
product_b = [6, 9, 5, 8, 4]
x = np.arange(len(categories))
width = 0.35
fig, ax = plt.subplots(figsize=(8, 4))
ax.bar(x - width/2, product_a, width, label='Product A')
ax.bar(x + width/2, product_b, width, label='Product B')
ax.set_xticks(x)
ax.set_xticklabels(categories)
ax.set_ylabel('Score')
ax.set_title('Good: Grouped Bar Chart (easy to compare across categories)')
ax.legend()
plt.show()
Each mistake is a violation of the framework, not a style error. Fix the framework question, and the chart fixes itself.
Putting It All Together: A Worked Example
Let’s apply the three-question framework to a real-world dataset: monthly sales for three regions over two years.
Step 1 (Q1): Message is “Change over Time” — specifically, which region grew fastest?
Step 2 (Q2): Data shape is time series (date + numeric for each region).
Step 3 (Q3): Effect size is moderate — differences are visible but not huge. Full-axis line chart is appropriate.
First, let’s generate the data and try a stacked area chart (a common first attempt).
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Generate monthly sales data for 3 regions over 2 years
dates = pd.date_range('2022-01-01', periods=24, freq='M')
np.random.seed(42)
region_a = np.random.normal(100, 10, 24).cumsum() + 200
region_b = np.random.normal(80, 8, 24).cumsum() + 150
region_c = np.random.normal(90, 12, 24).cumsum() + 180
df = pd.DataFrame({
'date': dates,
'Region A': region_a,
'Region B': region_b,
'Region C': region_c
})
# Wrong first attempt: stacked area chart (shows composition, not change over time)
plt.figure(figsize=(10, 5))
plt.stackplot(df['date'], df['Region A'], df['Region B'], df['Region C'],
labels=['Region A', 'Region B', 'Region C'], alpha=0.7)
plt.legend(loc='upper left')
plt.title('Wrong: Stacked Area Chart (shows composition, not growth comparison)')
plt.ylabel('Cumulative Sales')
plt.show()
This chart violates Q1: the message is “which region grew fastest?” but the stacked area chart emphasizes the total and the parts, not the individual trends. It’s hard to see which line is steepest.
Now the right chart: a multi-line chart with annotations.
# Right chart: multi-line chart with annotations
plt.figure(figsize=(10, 5))
plt.plot(df['date'], df['Region A'], label='Region A', linewidth=2)
plt.plot(df['date'], df['Region B'], label='Region B', linewidth=2)
plt.plot(df['date'], df['Region C'], label='Region C', linewidth=2)
# Annotate the fastest-growing region
# Calculate growth rates
growth_a = (df['Region A'].iloc[-1] - df['Region A'].iloc[0]) / df['Region A'].iloc[0]
growth_b = (df['Region B'].iloc[-1] - df['Region B'].iloc[0]) / df['Region B'].iloc[0]
growth_c = (df['Region C'].iloc[-1] - df['Region C'].iloc[0]) / df['Region C'].iloc[0]
fastest = max([('A', growth_a), ('B', growth_b), ('C', growth_c)], key=lambda x: x[1])
plt.annotate(f'Region {fastest[0]} grew {fastest[1]*100:.1f}%',
xy=(df['date'].iloc[-1], df[f'Region {fastest[0]}'].iloc[-1]),
xytext=(df['date'].iloc[-5], df[f'Region {fastest[0]}'].iloc[-5] + 20),
arrowprops=dict(arrowstyle='->'),
fontsize=12, color='darkred')
plt.legend()
plt.title('Right: Multi-Line Chart (shows growth comparison clearly)')
plt.ylabel('Cumulative Sales')
plt.xlabel('Date')
plt.show()
print(f"Region A growth: {growth_a*100:.1f}%")
print(f"Region B growth: {growth_b*100:.1f}%")
print(f"Region C growth: {growth_c*100:.1f}%")
The slope of Region A’s line is steeper — in plain English, it grew about 1.5x faster per month than Region B. The line chart makes this obvious at a glance. The framework led us to the right chart.
Recap: What You Learned
- Chart choice starts from your message, not your data’s shape — but both matter.
- The “always start at zero” rule is context-dependent; the framework gives you permission to think about intent.
- Common mistakes (pie charts for many categories, 3D charts, truncated axes for large effects) are violations of the framework, not style errors.
- The framework is a decision tree, not a lookup table — use your judgment.
Check Your Understanding
Remember: What are the three questions in the decision framework?
Understand: Why does the Correll et al. paper complicate the “always start at zero” rule?
Apply: You have monthly revenue data for five product categories. Your message is “Product A’s share of revenue grew over time.” Which chart type do you choose and why?
Analyze: The chart below shows a pie chart with 12 slices. Which framework question does it violate? What would you recommend instead?
Evaluate: Two charts of the same data — a truncated bar chart and a full-axis bar chart. Which one is more honest for a message of “small but meaningful difference”?
Create: Design a chart for a dataset of your choice (real or imagined) using the three-question framework. Write a paragraph explaining your choices.
Apply What You Learned is for Supporter and Insider subscribers.
Subscribe to unlock the exercises on this post.
See plansRelated articles
- Statistics Under review
Machine Learning In Education Adaptive Learning An
Before any math, let's build a mental model. Think of a student as a bundle of **latent traits** — things you can't directly observe, like their knowledge of calculus, their motivation level, or their risk of dropping out. What you *can* observe are **visible actions**: quiz answers, time spent on a page, login frequency, grade changes.
- Statistics Under review
How to Properly Handle Missing Data (Is Imputation Always the Right Answer?)
Learn to handle missing data by identifying MCAR, MAR, and MNAR patterns, choosing between deletion, imputation, and indicators to avoid biased models.
- Statistics Under review
Designing A Valid A B Test Common Mistakes That In
This is the most common pre-test mistake. A team decides to 'test the new button' without specifying what success looks like. They'll measure everything and see what sticks.
- Statistics Under review
ANOVA, Explained for People Who Just Want to Compare Three Groups
You have three landing page designs — A, B, and C — and you need to know which one gets the highest click-through rate. You've already run a few hundred visitors through each page.
Looking for something else?
Search every article by title, summary or topic.