Python & Data Science

Data Visualization Libraries Reference Matplotlib

You open a colleague’s Jupyter notebook and see this at the top of the first cell:

import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import plotly.graph_objects as go
import altair as alt
from bokeh.plotting import figure, show

Your stomach drops. Which one actually draws the plot? Why are there five? Do I need to know all of them?

You’re not alone. The Python visualization landscape is famously crowded, and most tutorials dive straight into syntax without ever answering the question you’re really asking: When do I use which library, and why?

This article isn’t a feature list. It’s a mental model. By the time you finish, you’ll know exactly which library to reach for — whether you’re building a static PDF report, an interactive dashboard, a faceted statistical exploration, or a streaming real-time display. We’ll use a single, simple dataset across all five libraries so you can see how each one thinks about the same problem.

Here’s our running example — a tiny e-commerce transactions table:

datecategoryrevenuecustomer_segment
2024-01-01Electronics245.00Premium
2024-01-01Home & Garden89.50Standard
2024-01-02Electronics310.00Premium
2024-01-02Clothing55.25New
2024-01-03Home & Garden120.00Standard

Five rows, four columns. That’s all we need to understand the entire landscape.


The Three Rendering Engines That Actually Matter

Before we compare APIs, we need to talk about something most tutorials skip entirely: what happens when you call .show()?

Under the hood, these five libraries don’t all draw pictures the same way. They fall into three distinct output models, and understanding this is the single most important structural fact about Python visualization. Let’s map it out.

LIBRARY LAYER              RENDERING ENGINE              OUTPUT TYPE
─────────────────────────────────────────────────────────────────────
Matplotlib ─────────────── Matplotlib C++ backend ────── PNG, SVG, PDF
Seaborn ────────────────── (uses Matplotlib) ─────────── PNG, SVG, PDF

Plotly ─────────────────── plotly.js (browser) ───────── interactive HTML
Bokeh ──────────────────── BokehJS (browser) ─────────── interactive HTML

Altair ─────────────────── Vega-Lite (browser) ───────── interactive HTML

Let’s unpack this in plain English.

Group 1 — Matplotlib and Seaborn: Static Raster and Vector Matplotlib has its own rendering engine written in C++. When you call plt.show(), Matplotlib draws every pixel itself and hands you back a PNG, SVG, or PDF. Seaborn isn’t a separate renderer at all — it’s a high-level API that sits on top of Matplotlib and calls the same C++ backend. Think of Seaborn as a smart assistant that writes Matplotlib code for you.

Group 2 — Plotly and Bokeh: Browser-Rendered Interactive Plotly and Bokeh don’t draw pixels themselves. Instead, they generate a JSON specification — a big structured dictionary describing your plot — and hand it to a JavaScript library (plotly.js or BokehJS) that runs in your browser. That JavaScript library does the actual rendering. This is why hover tooltips, zoom, and pan work out of the box: your browser already knows how to do those things. But it also means that exporting a high-resolution PNG or PDF requires an extra step — you need a headless browser or a dedicated export tool like Kaleido.

Group 3 — Altair: Declarative Grammar, Rendered by Vega-Lite Altair is the odd one out. It doesn’t generate a plot-object JSON like Plotly or Bokeh. It generates a Vega-Lite specification — a formal declaration of how data columns map to visual channels. Vega-Lite is a grammar of graphics, conceptually much closer to R’s ggplot2 than to any of the other four libraries. The Vega-Lite JavaScript library reads that spec and renders it in your browser. Altair is declarative from the ground up: you describe what you want, not how to draw it.

Why this matters. If you need a PDF for a printed report, Plotly, Bokeh, and Altair are the wrong tools — or at least they require extra export steps that Matplotlib handles natively. If you need hover tooltips in a Jupyter notebook, Matplotlib alone is the wrong tool. The rendering engine dictates what’s easy and what’s hard.

This is the hardest conceptual part of the article. Once you internalize these three output models, every API decision becomes easier. You’re no longer choosing between five libraries — you’re choosing between three fundamentally different ways of turning data into pictures.

If you want to follow along, here are the install commands for each group:

# Group 1: Static rendering
pip install matplotlib seaborn

# Group 2: Browser-rendered interactive
pip install plotly bokeh

# Group 3: Declarative grammar
pip install altair vega_datasets

Now let’s meet each library on its own terms.


Matplotlib: The Assembly Language of Python Visualization

Matplotlib is the foundation. It’s been around since 2003, and almost every other Python visualization tool either builds on it or was created because of its limitations. If you’ve ever written plt.plot(x, y), you’ve used Matplotlib.

But here’s what most people don’t realize: Matplotlib has two completely different interfaces living side by side, and mixing them is the #1 source of “my plot isn’t showing up” bugs.

The Pyplot State-Machine Interface

The first interface is the one everyone learns first. It’s called the pyplot interface, and it works like a state machine — you call functions that implicitly operate on a “current figure” and “current axes” that Matplotlib tracks behind the scenes.

# Self-contained: imports, data, and plot all in one block
import matplotlib.pyplot as plt
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Pyplot state-machine interface — 5 lines
plt.figure(figsize=(8, 4))          # Create a new figure (implicitly sets it as 'current')
plt.plot(daily['date'], daily['revenue'], marker='o')  # Plot on the 'current' axes
plt.title('Daily Revenue')           # Set title on 'current' axes
plt.xlabel('Date')                   # Set x-label on 'current' axes
plt.ylabel('Revenue ($)')            # Set y-label on 'current' axes
plt.show()                           # Render and display

This works fine for quick scripts. But the moment you need to create a figure with multiple subplots, or update a plot after it’s been created, the implicit state machine becomes a liability — you’re never quite sure which axes is “current.”

The Object-Oriented Interface

The second interface — and the one you should use for anything non-trivial — is the object-oriented (OO) interface. You explicitly create Figure and Axes objects, then call methods directly on them. Nothing is implicit.

# Self-contained: imports, data, and plot all in one block
import matplotlib.pyplot as plt
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Object-oriented interface — 7 lines
fig, ax = plt.subplots(figsize=(8, 4))  # Explicitly create Figure and Axes objects
ax.plot(daily['date'], daily['revenue'], marker='o')  # Call plot on the Axes object
ax.set_title('Daily Revenue')           # Set title on the Axes object
ax.set_xlabel('Date')                   # Set x-label on the Axes object
ax.set_ylabel('Revenue ($)')            # Set y-label on the Axes object
ax.grid(True, alpha=0.3)                # Add a subtle grid
plt.show()                              # Render and display

Seven lines instead of five, but every single one is explicit about which axes it’s operating on. When you have a figure with six subplots, this explicitness saves you from silent bugs where your title ends up on the wrong chart.

Plain-English translation: Matplotlib is like a pencil — you can draw anything, but you have to draw every line yourself. That’s both its superpower (total control) and its weakness (verbosity).

Now watch what happens when we hand the same job to Seaborn.

# Self-contained: imports, data, and plot all in one block
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Seaborn — 2 lines
sns.lineplot(data=daily, x='date', y='revenue', marker='o')
plt.show()

Two lines. Seaborn inferred the axis labels from the column names, added a subtle background grid, and chose a color palette — all automatically. But underneath, it’s still calling Matplotlib’s C++ renderer. That’s the key insight: Seaborn is a high-level API on top of Matplotlib, not a replacement for it.


Seaborn: Statistical Plots Without the Boilerplate

Seaborn’s promise is simple: your data is already in a tidy pandas DataFrame, and you want to explore relationships — distributions, categories, regressions — with sensible defaults. You tell Seaborn which columns map to x, y, and hue, and it handles the rest.

But here’s the catch. Seaborn has two different kinds of functions, and mixing them is the #1 source of Seaborn bugs in the wild. This is the hardest part of Seaborn, so let’s make it crystal clear.

Axes-Level Functions

Axes-level functions return a Matplotlib Axes object. They’re designed to be composed into larger figures — you can put a sns.boxplot in one subplot and a sns.scatterplot in another, just like you would with raw Matplotlib.

# Self-contained: imports, data, and plot all in one block
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Axes-level function: sns.boxplot returns a matplotlib Axes
fig, ax = plt.subplots(figsize=(8, 4))
sns.boxplot(data=df, x='customer_segment', y='revenue', ax=ax)  # Explicitly target the ax
ax.set_title('Revenue Distribution by Customer Segment')
plt.show()

Notice the ax=ax parameter — we’re telling Seaborn exactly which Axes object to draw on. This is the OO pattern from Matplotlib, and it works seamlessly because axes-level Seaborn functions are Matplotlib under the hood.

Figure-Level Functions

Figure-level functions are different. They create their own entire figure — complete with a FacetGrid that handles subplot layout — and they cannot be combined with other plots on the same figure.

# Self-contained: imports, data, and plot all in one block
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# Recreate the running dataset (with more rows to make faceting meaningful)
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03',
                            '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-03', '2024-01-04']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden',
                 'Clothing', 'Electronics', 'Home & Garden', 'Clothing', 'Electronics'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00, 75.00, 290.00, 140.00, 60.00, 330.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard',
                         'New', 'Premium', 'Standard', 'New', 'Premium']
})

# Figure-level function: sns.relplot creates its own FacetGrid
sns.relplot(data=df, x='date', y='revenue', col='category', kind='line', marker='o', height=3, aspect=1.5)
plt.show()

This is beautiful — one line of code creates a faceted plot with separate subplots for each category, shared axes, and automatic titles. But here’s the trap.

The FacetGrid Gotcha

What happens if you try to add a second plot to a figure-level figure?

# Self-contained: imports, data, and plot all in one block — demonstrates the gotcha
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03',
                            '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-03', '2024-01-04']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden',
                 'Clothing', 'Electronics', 'Home & Garden', 'Clothing', 'Electronics'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00, 75.00, 290.00, 140.00, 60.00, 330.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard',
                         'New', 'Premium', 'Standard', 'New', 'Premium']
})

# Create a figure-level plot
sns.relplot(data=df, x='date', y='revenue', col='category', kind='line', marker='o', height=3, aspect=1.5)

# Try to add a title — this works on the FacetGrid object
plt.suptitle('Revenue by Category Over Time', y=1.02)

# But try to add a second plot, and it silently creates a NEW figure
# (This line is commented out because it would create a confusing second blank figure)
# sns.boxplot(data=df, x='category', y='revenue')  # This goes to a NEW figure, not the FacetGrid!

plt.show()

If you uncommented that last line, you’d get two separate figures — the FacetGrid and a new boxplot — instead of one combined figure. The figure-level function owns its figure, and it doesn’t share. This is the #1 Seaborn gotcha: axes-level functions compose; figure-level functions don’t.

Plain-English translation: Seaborn is like a good camera in auto mode — it makes smart choices so you don’t have to, but you can still switch to manual (Matplotlib) when needed. Just know which mode you’re in.


Plotly: Interactive Plots That Work in a Browser

Now we cross into the second rendering group. Plotly generates JSON specs that plotly.js renders in your browser, which means interactivity — hover tooltips, zoom, pan, click-to-select — is free. You don’t configure it; it’s just there.

But Plotly has two APIs, and the choice between them determines how much control you have.

Plotly Express: High-Level, One-Line Plots

Plotly Express is the Seaborn of the interactive world — you pass a DataFrame, name your columns, and get a beautiful interactive plot in one line.

# Self-contained: imports, data, and plot all in one block
import plotly.express as px
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Plotly Express — one line, interactive by default
fig = px.line(daily, x='date', y='revenue', markers=True, title='Daily Revenue')
fig.show()

When you run this in a Jupyter notebook, you get a plot where hovering over any point shows the exact date and revenue value. You can zoom by dragging a rectangle, pan by clicking and dragging, and save the plot as a standalone HTML file. All of that is built into plotly.js — you didn’t write a single line of interactivity code.

Graph Objects: Full Control, More Verbosity

Plotly Express is actually a factory that creates graph_objects under the hood. When you need fine-grained control — custom annotations, multiple y-axes, specific trace configurations — you drop down to graph_objects directly.

# Self-contained: imports, data, and plot all in one block
import plotly.graph_objects as go
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue and compute cumulative
daily = df.groupby('date')['revenue'].sum().reset_index()
daily['cumulative_revenue'] = daily['revenue'].cumsum()

# Graph objects — explicit trace creation
fig = go.Figure()

# Add daily revenue as a bar trace on the primary y-axis
fig.add_trace(go.Bar(
    x=daily['date'],
    y=daily['revenue'],
    name='Daily Revenue',
    marker_color='steelblue'
))

# Add cumulative revenue as a line trace on a secondary y-axis
fig.add_trace(go.Scatter(
    x=daily['date'],
    y=daily['cumulative_revenue'],
    name='Cumulative Revenue',
    mode='lines+markers',
    marker_color='darkorange',
    yaxis='y2'  # Assign to secondary y-axis
))

# Configure the layout with two y-axes and an annotation
fig.update_layout(
    title='Daily and Cumulative Revenue',
    xaxis_title='Date',
    yaxis_title='Daily Revenue ($)',
    yaxis2=dict(
        title='Cumulative Revenue ($)',
        overlaying='y',
        side='right'
    ),
    annotations=[
        dict(
            x=daily['date'].iloc[-1],
            y=daily['cumulative_revenue'].iloc[-1],
            text='Total',
            showarrow=True,
            arrowhead=1
        )
    ]
)

fig.show()

This is more code, but you get exactly what you want: dual y-axes, a custom annotation pointing to the final cumulative value, and full control over colors and trace names. The tradeoff is the same one we saw with Matplotlib vs. Seaborn — power vs. convenience.

Plain-English translation: Plotly is like a touchscreen display — it’s built for interaction, not for printing. If you need a PDF for a printed report, you’ll need to use Plotly’s write_image() with the Kaleido engine. If you need hover tooltips in a notebook, Plotly is the right tool.


Bokeh: Interactive Plots with a Live Server Option

At first glance, Bokeh looks a lot like Plotly. Both generate browser-rendered interactive plots. Both support hover tools, zoom, and pan. Both can output standalone HTML files.

But Bokeh has a killer feature that Plotly’s core library doesn’t: a live Python-backed server that can push streaming updates to a plot in real time. If you’re visualizing sensor data, live market feeds, or any data that arrives continuously, Bokeh is the tool you reach for.

Standalone Mode: The Plotly Competitor

Let’s start with the mode that’s most comparable to Plotly — a standalone HTML file with embedded data.

# Self-contained: imports, data, and plot all in one block
from bokeh.plotting import figure, show, output_notebook
from bokeh.models import HoverTool
import pandas as pd

# Enable Bokeh output in Jupyter (use output_file('plot.html') for a standalone file)
output_notebook()

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Bokeh standalone — create a figure with hover tools
p = figure(
    x_axis_type='datetime',
    title='Daily Revenue',
    width=800,
    height=400,
    tools='pan,wheel_zoom,box_zoom,reset,save'
)

# Add hover tooltips
p.add_tools(HoverTool(
    tooltips=[
        ('Date', '@x{%F}'),
        ('Revenue', '$@y{0.00}')
    ],
    formatters={'@x': 'datetime'}
))

# Draw the line
p.line(x=daily['date'], y=daily['revenue'], line_width=2, color='steelblue')
p.circle(x=daily['date'], y=daily['revenue'], size=8, color='steelblue', alpha=0.6)

# Label axes
p.xaxis.axis_label = 'Date'
p.yaxis.axis_label = 'Revenue ($)'

show(p)

Bokeh’s standalone API is more verbose than Plotly Express — you explicitly create the figure, add tools, and draw glyphs (lines, circles). But the interactivity is comparable: hover tooltips, zoom, pan, and save all work out of the box.

Bokeh Server: Streaming Live Data

Now here’s where Bokeh pulls ahead. The Bokeh server keeps a Python process running, and your plot can update in real time as new data arrives.

# Self-contained Bokeh server app — save as a separate .py file and run with: bokeh serve --show filename.py
# This block cannot be run inline in a notebook; it's a standalone server script
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource
from bokeh.driving import linear
import pandas as pd
from datetime import datetime, timedelta
import random

# Initial data — start with our running dataset
dates = [datetime(2024, 1, 1), datetime(2024, 1, 2), datetime(2024, 1, 3)]
revenues = [334.50, 365.25, 120.00]  # Summed daily revenues from our dataset

# ColumnDataSource is Bokeh's data container — it enables streaming updates
source = ColumnDataSource(data={'date': dates, 'revenue': revenues})

# Create the figure
p = figure(
    x_axis_type='datetime',
    title='Streaming Daily Revenue (Live)',
    width=800,
    height=400
)
p.line(x='date', y='revenue', source=source, line_width=2, color='steelblue')
p.circle(x='date', y='revenue', source=source, size=8, color='steelblue')

# This callback runs every second — it simulates a new data point arriving
@linear(m=1, b=0)
def update(step):
    new_date = dates[-1] + timedelta(days=1)
    new_revenue = random.uniform(50, 400)
    # stream() appends new data to the ColumnDataSource efficiently
    source.stream({'date': [new_date], 'revenue': [new_revenue]})
    # Keep track of dates for the next callback
    dates.append(new_date)

# Add the periodic callback to the Bokeh server's document
curdoc().add_periodic_callback(update, 1000)  # 1000 ms = 1 second
curdoc().add_root(p)

This is a completely different paradigm. The curdoc() (current document) is the Bokeh server’s central object — you add your plot to it, and you register callbacks that the server runs on a schedule. When you run this with bokeh serve, you get a live-updating plot in your browser that streams new data points every second. Plotly’s core library can’t do this without Dash or an external streaming infrastructure.

Plain-English translation: Bokeh is like a live dashboard display — it can update in real time as new data arrives, not just show a static snapshot. If you don’t need streaming, Plotly Express will get you to an interactive plot faster. If you do need streaming, Bokeh is irreplaceable.


Altair: Declarative Visualization as a Grammar

Altair is fundamentally different from the other four libraries. It’s not about drawing shapes or constructing plot objects. It’s about declaring a mapping from data columns to visual channels — x, y, color, size — and letting the library figure out the rest.

This is the grammar of graphics approach, and it’s the closest Python gets to R’s ggplot2. You don’t tell Altair how to draw a line. You tell it: “Map date to the x-axis, revenue to the y-axis, and use a line mark.” Altair compiles that declaration into a Vega-Lite JSON spec, and the Vega-Lite JavaScript library renders it.

Declarative in Practice

# Self-contained: imports, data, and plot all in one block
import altair as alt
import pandas as pd

# Recreate the running dataset
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard']
})

# Aggregate daily revenue
daily = df.groupby('date')['revenue'].sum().reset_index()

# Altair — the entire plot is a single chained expression
chart = alt.Chart(daily).mark_line(point=True).encode(
    x='date:T',           # :T specifies temporal type
    y='revenue:Q',        # :Q specifies quantitative type
    tooltip=['date', 'revenue']
).properties(
    title='Daily Revenue',
    width=600,
    height=400
)

chart

Look at what’s happening here. There’s no plt.figure(), no fig, ax =, no add_trace(). The entire plot is a single expression: alt.Chart(data).mark_line().encode(x=..., y=...). Every piece — the data, the mark type, the encoding channels, the properties — is declared, not constructed.

Declarative Interactivity

Even interactivity in Altair is declarative. You don’t write callback functions. You declare a selection, then declare what that selection should filter or highlight.

# Self-contained: imports, data, and plot all in one block
import altair as alt
import pandas as pd

# Recreate the running dataset (with more rows to make brushing meaningful)
df = pd.DataFrame({
    'date': pd.to_datetime(['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03',
                            '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-03', '2024-01-04']),
    'category': ['Electronics', 'Home & Garden', 'Electronics', 'Clothing', 'Home & Garden',
                 'Clothing', 'Electronics', 'Home & Garden', 'Clothing', 'Electronics'],
    'revenue': [245.00, 89.50, 310.00, 55.25, 120.00, 75.00, 290.00, 140.00, 60.00, 330.00],
    'customer_segment': ['Premium', 'Standard', 'Premium', 'New', 'Standard',
                         'New', 'Premium', 'Standard', 'New', 'Premium']
})

# Declare a brush selection on the scatterplot
brush = alt.selection_interval()

# Scatterplot with brush selection
scatter = alt.Chart(df).mark_point().encode(
    x='date:T',
    y='revenue:Q',
    color='category:N'
).add_params(
    brush
).properties(
    title='Revenue by Date (Brush to Filter)',
    width=600,
    height=300
)

# Histogram that filters based on the brush selection
hist = alt.Chart(df).mark_bar().encode(
    x='category:N',
    y='count():Q',
    color='category:N'
).transform_filter(
    brush  # This histogram only shows data within the brushed region
).properties(
    title='Category Count (Filtered by Brush)',
    width=600,
    height=200
)

# Vertically concatenate the two charts
scatter & hist

This is remarkable. In about 20 lines of declarative code, we have a linked interactive visualization: drag a rectangle on the scatterplot, and the histogram below updates to show only the selected points. There are no callbacks, no event handlers, no state management. The brush selection is declared as alt.selection_interval(), and the filter is declared as .transform_filter(brush). Altair and Vega-Lite handle the rest.

But here’s the catch. Altair has a hard default limit of 5,000 rows. Vega-Lite was designed to send the entire dataset to the browser, and for performance reasons, it refuses to render more than 5,000 rows by default. If your dataset is larger, you’ll need to either aggregate before plotting, enable server-side data transforms via Altair’s data server, or switch to a different library.

Plain-English translation: Altair is like describing a painting to an artist — you specify what you want, not how to paint each brushstroke. It’s incredibly concise for complex faceted and interactive plots, but it’s not designed for large datasets or pixel-level customization.


The Decision Matrix: Which Library When?

We’ve covered a lot of ground. Let’s synthesize everything into a practical decision framework you can use the next time you’re staring at a blank notebook.

Here’s the honest truth: most professionals use 2-3 of these libraries regularly. Matplotlib+Seaborn for static reports, Plotly for interactive notebooks, and Altair for quick declarative exploration. Bokeh is more niche but irreplaceable for streaming.

GoalBest LibraryRunner-UpWhy
Static report figure (PDF, PNG)Matplotlib + Seaborn—Native PDF/SVG output; no browser dependency; total control over every pixel
Interactive notebook explorationPlotly ExpressBokeh (standalone)Hover tooltips, zoom, and pan work out of the box with one-line syntax
Streaming dashboardBokeh (server)Plotly + DashBokeh server pushes live updates from Python; Plotly needs Dash for equivalent
Faceted statistical plotsSeaborn (figure-level)Altairsns.relplot(col='category') does faceting in one line; Altair’s declarative faceting is equally concise
Publication-quality custom figureMatplotlib (OO)Plotly graph_objectsPixel-level control over ticks, spines, annotations; export to any format
Quick data check in a scriptSeabornPlotly ExpressMinimal boilerplate; sensible defaults; works in any Python environment
Declarative grammar-of-graphicsAltair—The only Python library with a true grammar-of-graphics approach; closest to ggplot2
3D plots or geographic mapsPlotly—Built-in support for 3D scatter, surface, and choropleth maps
Large dataset (>100k points)MatplotlibBokeh (with WebGL)Matplotlib handles large static plots efficiently; Bokeh can use WebGL for large interactive plots

The decision flow in plain English:

  1. Does it need to be interactive? If no → Matplotlib or Seaborn. If yes → keep going.
  2. Is the data streaming live? If yes → Bokeh server. If no → keep going.
  3. Do you want declarative, grammar-of-graphics syntax? If yes → Altair (but check your row count). If no → keep going.
  4. Do you want the quickest path to a good interactive plot? → Plotly Express.
  5. Do you need total control over every element? → Plotly graph_objects or Matplotlib OO.

Gotchas and Hard-Won Lessons

Every library has sharp edges that experienced users learn the hard way. This section collects the most common pain points — the things that silently fail, produce confusing errors, or generate plots that look right but are wrong. Bookmark this section.

Matplotlib: Mixing Pyplot and OO Interfaces

# GOTCHA: Mixing pyplot and OO — the title goes to the wrong axes
import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})

fig, ax = plt.subplots()
ax.plot(df['x'], df['y'])     # OO: plot on the explicit Axes
plt.title('My Title')          # Pyplot: sets title on... which axes?
# Fix: always use ax.set_title() when using the OO interface
plt.show()

Fix: Pick one interface and stick with it. For anything with subplots or custom layouts, use the OO interface exclusively — fig, ax = plt.subplots() and then ax.method() for everything.

Seaborn: Mixing Axes-Level and Figure-Level Functions

# GOTCHA: Figure-level function creates its own figure, ignoring your existing one
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

df = pd.DataFrame({'x': [1, 2, 3, 1, 2, 3], 'y': [4, 5, 6, 7, 8, 9], 'cat': ['A', 'A', 'A', 'B', 'B', 'B']})

fig, ax = plt.subplots()
sns.scatterplot(data=df, x='x', y='y', ax=ax)  # Axes-level: draws on ax
sns.relplot(data=df, x='x', y='y', col='cat')   # Figure-level: creates a NEW figure, ignores ax!
# The scatterplot and the relplot end up in separate figures
plt.show()

Fix: Use axes-level functions (sns.scatterplot, sns.boxplot, sns.histplot) when you want to compose multiple plots on one figure. Use figure-level functions (sns.relplot, sns.catplot, sns.displot) when you want automatic faceting and don’t need to combine with other plots.

Plotly: Express vs. Graph Objects — Different Update Syntax

# GOTCHA: Updating a trace property uses different syntax in Express vs. graph_objects
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd

df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})

# Plotly Express — returns a Figure, but updating traces is unintuitive
fig_ex = px.line(df, x='x', y='y')
# fig_ex.data[0].line.color = 'red'  # This works but is fragile

# Graph objects — explicit trace objects, direct property access
fig_go = go.Figure()
fig_go.add_trace(go.Scatter(x=df['x'], y=df['y'], mode='lines'))
fig_go.data[0].line.color = 'red'  # Clear and intentional

# Fix: if you need fine-grained control, use graph_objects from the start

Fix: Use Plotly Express for quick exploration. If you find yourself reaching into fig.data[0] to tweak properties, refactor to graph_objects — the API is designed for that level of control.

Bokeh: Standalone vs. Server Mode

# GOTCHA: show() works in standalone mode; curdoc().add_root() is required for server mode
# These are NOT interchangeable — using show() in a server app silently does nothing

# Standalone (works in notebook or script):
from bokeh.plotting import figure, show
p = figure()
p.line([1, 2, 3], [4, 5, 6])
show(p)  # Correct for standalone

# Server (only works in a .py file run with 'bokeh serve'):
# from bokeh.plotting import curdoc
# curdoc().add_root(p)  # Correct for server — show() would do nothing here

Fix: Know which mode you’re in. Standalone: show(). Server: curdoc().add_root(). They don’t mix.

Altair: The 5,000-Row Limit

# GOTCHA: Altair silently fails or refuses to render with >5000 rows
import altair as alt
import pandas as pd

# Create a 6000-row DataFrame
df = pd.DataFrame({'x': range(6000), 'y': range(6000)})

# This will raise MaxRowsError or silently not render in some environments
# chart = alt.Chart(df).mark_line().encode(x='x', y='y')

# Fix 1: Aggregate before plotting
# Fix 2: Disable the limit (use cautiously — large datasets will slow your browser)
alt.data_transformers.disable_max_rows()

# Fix 3: Enable server-side transforms (requires altair_data_server package)
# alt.data_transformers.enable('data_server')

Fix: For datasets over 5,000 rows, either aggregate first, disable the limit with alt.data_transformers.disable_max_rows() (and accept the performance hit), or enable the data server for server-side transforms.

Cross-Library: Color Handling

There is no unified color specification across these libraries. Matplotlib uses named colors ('steelblue') or hex codes. Seaborn uses named palettes ('viridis', 'muted'). Plotly uses hex codes or CSS names. Bokeh uses hex codes or named colors. Altair uses Vega-Lite color schemes. When porting a plot between libraries, expect to re-specify colors from scratch.

Cross-Library: Figure Sizing

LibraryUnitExample
Matplotlibinchesfigsize=(8, 4)
Seaborninches (via Matplotlib)height=4, aspect=2
Plotlypixelswidth=800, height=400
Bokehpixelswidth=800, height=400
Altairpixelswidth=600, height=400

There’s no conversion constant — you just have to know which unit each library expects.


Recap and What’s Next

Let’s lock in the mental model.

The three rendering engines:

  • Static (Matplotlib C++ backend): Matplotlib and Seaborn produce PNG, SVG, PDF natively. No browser required.
  • Browser-interactive (JavaScript): Plotly and Bokeh generate JSON specs rendered by plotly.js or BokehJS. Interactive by default; static export requires extra steps.
  • Declarative grammar (Vega-Lite): Altair generates a Vega-Lite spec — a formal declaration of data-to-visual mappings. Rendered by the Vega-Lite JavaScript library.

The five libraries in one sentence each:

  • Matplotlib: The low-level foundation — total control, maximum verbosity.
  • Seaborn: High-level statistical plots on top of Matplotlib — beautiful defaults, tight pandas integration.
  • Plotly: Browser-rendered interactive plots — hover, zoom, pan out of the box; two APIs (Express for speed, graph_objects for control).
  • Bokeh: Browser-rendered interactive plots with a live Python server for streaming data — irreplaceable for real-time dashboards.
  • Altair: Declarative grammar-of-graphics — describe what you want, not how to draw it; 5,000-row default limit.

The decision flow: Goal → output type → abstraction level → special needs. If you need a static PDF, reach for Matplotlib+Seaborn. If you need interactive exploration, reach for Plotly Express. If you need streaming, reach for Bokeh server. If you want declarative faceting, reach for Altair or Seaborn figure-level.

In the next article, we’ll do a deep dive on Matplotlib’s object-oriented interface — the foundation everything else builds on. You’ll learn how to create publication-quality multi-panel figures with complete control over every tick, spine, and annotation.


Check Your Understanding

Remember:

  1. Which rendering engine does Seaborn use under the hood?
  2. What is the default row limit in Altair, and why does it exist?

Understand: 3. In your own words, explain the difference between Matplotlib’s pyplot interface and its object-oriented interface. Why does the OO interface prevent bugs? 4. Why can’t you combine a Seaborn figure-level plot with an axes-level plot on the same figure?

Apply: 5. You need to create a static PDF report with a faceted scatterplot showing revenue vs. date, colored by customer segment, with a separate subplot for each product category. Which library (or combination) do you use, and which specific function? 6. You’re building a Jupyter notebook for exploratory data analysis, and you want hover tooltips showing exact values on every point. Which library do you reach for first?

Analyze: 7. A colleague’s code imports both plotly.express and plotly.graph_objects. Under what circumstances would you recommend refactoring to use only one, and which one? 8. Compare Bokeh’s standalone mode and Plotly Express for a simple interactive line chart. What are the tradeoffs in terms of code verbosity, default styling, and ecosystem?

Evaluate: 9. You’re designing a real-time monitoring dashboard that displays sensor data updating every 500 milliseconds. Evaluate Bokeh server vs. Plotly+Dash for this use case. What are the key differences in architecture and development experience? 10. A team wants to standardize on a single visualization library for all their work — static reports, interactive dashboards, and quick data checks. Is this a good idea? Justify your answer using the rendering engine model from this article.

Create: 11. Using the e-commerce dataset from this article, write an Altair specification that creates a faceted bar chart of total revenue by category, with an interactive dropdown that lets the user filter by customer segment. Describe what each encoding channel represents.


Apply What You Learned

Take the e-commerce transactions dataset from this article (or your own dataset with at least three columns — a date, a numeric value, and a categorical variable). Create three different visualizations of the same data, each using a different library, and write a short paragraph justifying which one you’d choose for each of these scenarios:

  1. A static figure for a printed quarterly report
  2. An interactive chart for a Jupyter notebook shared with stakeholders
  3. A faceted exploration to understand category-level trends

Deliverable: A single Jupyter notebook with three cells (one per library), each producing a plot, plus a markdown cell with your justifications.

Self-check rubric:

  • Did you use Matplotlib/Seaborn for the static figure? (If you used Plotly, did you explain the extra export step?)
  • Did your interactive chart include hover tooltips that work in the notebook?
  • Did your faceted exploration use either Seaborn’s figure-level functions or Altair’s declarative faceting?
  • Did your justifications reference the rendering engine model (static vs. browser-interactive vs. declarative)?
  • Python Engineering Under review

    A Data Science Glossary 100 Terms Explained In Pla

    Before we talk about models or metrics, we need to agree on what “data” actually looks like in Python. When you load a CSV file, you’re not staring at raw numbers — you’re staring at a carefully labeled structure.

  • Python Engineering Under review

    Andrew Ng S Machine Learning Courses Which One Sho

    Before we talk about what exists today, we need to talk about what doesn't. Because half the confusion around 'Andrew Ng's ML course' comes from a ghost.

  • Python Engineering Under review

    Talking To Apis The Requests Patterns Every Data S

    Let's paint a picture. You're a data scientist at a mid-sized e-commerce company. Every morning, you need to pull yesterday's sales numbers from the company's internal dashboard.

  • Python Engineering Under review

    Data Flow Decomposition Why Every Pandas Sklearn P

    Here's a realistic pandas pipeline. It loads customer transaction data, cleans it, groups it, and merges it with customer info. You've written something like this before:

Looking for something else?

Search every article by title, summary or topic.