Grammar of Graphics vs. Imperative Plotting
Python's visualization libraries look like a pile of unrelated tools - matplotlib, seaborn, plotly, altair - but they actually split into two mental models for describing a chart.
One model is imperative: you draw a figure, add axes, place artists on it, and mutate that state until it looks right.
The other is declarative, following the grammar of graphics: you describe a mapping from data columns to visual properties, and a rendering engine decides how to draw it.
Visualization Basics shows the concrete syntax for picking a chart type; this page is about which model a library follows, and why that choice matters more than which library has the prettiest default theme.
Summary
- A chart is fundamentally a mapping from data columns to visual encodings (position, color, size, shape); libraries differ in whether you build that mapping declaratively (grammar of graphics) or draw it imperatively (figure/axes/artists).
- Insight: Choosing the wrong model for a task means fighting the library - hand-placing every element in a tool built for declarative encoding, or fighting a rigid grammar when you need one-off imperative control.
- Key Concepts: encoding, tidy (long-format) data, figure/axes state, declarative spec, faceting.
- When to Use: Reach for the declarative model for exploratory, multi-variable, faceted charts built from tidy data; reach for the imperative model when you need pixel-level control, custom annotations, or publication-specific layout.
- Limitations/Trade-offs: Declarative grammars are fast for standard chart shapes but resist bespoke, one-off layouts; imperative libraries give total control but every element - legends, ticks, spacing - is your responsibility to place correctly.
- Related Topics: matplotlib's figure/axes model, seaborn's statistical layer over matplotlib, Altair's Vega-Lite grammar, plotly's interactive/declarative hybrid.
Foundations
Every chart, no matter the library, is answering the same underlying question: which visual property represents which column of data?
The grammar of graphics (the theory behind ggplot2, and in Python behind Altair and plotly express) names this mapping explicitly: a data column is encoded onto an x position, a y position, a color, a size, or a shape, and the library figures out the rest.
Because the mapping is declared rather than drawn, describing "color by category, facet by region" produces a full small-multiples grid without you writing a loop to place each subplot.
This model works best on tidy (long-format) data, where each row is one observation and each column is one variable - the encoding step needs columns to map onto, and wide, spreadsheet-shaped data has to be reshaped first.
matplotlib, by contrast, follows an imperative model built around a figure (the canvas) and one or more axes (a plotting region on that canvas): you explicitly create these objects, then call methods on them - ax.plot(), ax.set_xlabel(), ax.legend() - to build the chart artist by artist.
A simple analogy: the declarative model is like describing a recipe to a chef ("a salad with tomato, dressed lightly") and trusting them to plate it; the imperative model is plating the salad yourself, ingredient by ingredient, in the exact position you choose.
Neither model is strictly "better" - they trade automation for control, and most real Python visualization work in a section like this one moves between both depending on the task.
Mechanics & Interactions
The mechanical difference shows up most clearly in what happens when you add a new variable to a chart.
In the declarative model, adding a color= or facet= encoding to an existing chart spec is a one-line change, because the renderer (Vega-Lite, under Altair; plotly's own renderer, under plotly express) recomputes scales, legends, and layout from the updated spec.
In the imperative model, the same change usually means writing a loop over group values, calling ax.plot() once per group with a manually chosen color, and manually building a legend - the library has no concept of "a variable mapped to color" to update on your behalf.
seaborn sits in between: it's built directly on matplotlib's imperative axes, but its functions (sns.scatterplot, sns.relplot) accept a hue=/col=/row= interface that mimics grammar-of-graphics encoding, generating the per-group loops and legend construction for you while still returning matplotlib objects you can further mutate imperatively.
This is also why performance and delivery differ: matplotlib renders to a static raster or vector image with no runtime, ideal for print and PDF reports; plotly and Altair render to interactive JavaScript (Plotly.js and Vega-Lite respectively) that ships hover, zoom, and pan to a browser at the cost of a much heavier output artifact.
import seaborn as sns
# declarative-style encoding on top of matplotlib's imperative engine:
# hue= and col= describe mappings; seaborn generates the loop and legend
sns.relplot(
data=df, x="date", y="value",
hue="category", col="region", # facet by region, one subplot per value
kind="line",
)Advanced Considerations & Applications
Choosing a library is really choosing an audience and a delivery medium first, and a chart type second.
A static report or academic paper favors matplotlib or seaborn's PNG/SVG/PDF output, where every pixel is reproducible and no JavaScript runtime is required to view it.
An internal dashboard or exploratory notebook favors plotly or Altair, where hover tooltips and zoom let a reader interrogate the data without a second chart.
Faceted, multi-variable exploratory work - "show me this metric broken out by three categorical variables" - is where the declarative grammar earns its keep, because adding a facet is a one-line spec change instead of a restructured loop.
Scale is a real constraint on both models: rendering tens of thousands of points as individual SVG or JavaScript-tracked marks slows or crashes browser-based renderers, which is why large datasets are usually downsampled, binned (hexbin/2D histogram), or rasterized before reaching either kind of library.
Dashboards built with Streamlit, Dash, or Gradio don't replace this choice - they're app frameworks that embed matplotlib, plotly, or Altair figures inside a web UI, so the underlying grammar-versus-imperative decision still has to be made per chart.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Imperative (matplotlib) | Full pixel-level control, mature print/PDF output, no runtime needed to view | Every legend/facet/color mapping is manual code | Publication figures, custom annotated static charts |
| Statistical layer (seaborn) | Grammar-like hue/col encodings with statistical estimators built in | Still returns matplotlib objects - you inherit its imperative escape hatches | Fast statistical exploration (distributions, regressions) on tidy data |
| Declarative grammar (Altair) | Concise encoding-based spec, automatic legends/facets/scales | Vega-Lite has to be able to express what you want, or you're stuck | Multi-variable exploratory charts, reproducible spec-driven reports |
| Interactive/hybrid (plotly) | Interactive by default (zoom/hover/pan), works both as express (declarative) and graph_objects (imperative) | Larger output artifacts; can be overkill for a static one-off chart | Dashboards, web-embedded interactive charts |
Common Misconceptions
- "seaborn is a totally different model from matplotlib." - seaborn is a statistical layer built directly on matplotlib's imperative figure/axes objects; its
hue=/col=API mimics grammar-of-graphics encoding, but the underlying object you get back is still a matplotlibAxesyou can further mutate. - "Declarative libraries can't be customized." - Altair and plotly express both allow imperative-style overrides on top of the declarative spec (custom scales, manual layout tweaks); the grammar is the default path, not the only path.
- "Interactive charts are always the better choice." - Interactivity adds a JavaScript runtime dependency and a much larger output artifact, which is actively worse for print, email-embedded, or accessibility-constrained delivery where a static image is simpler and more portable.
- "Any DataFrame shape works with any plotting call." - Grammar-of-graphics-style libraries expect tidy (long) data with one row per observation; wide, pivoted data usually needs
.melt()or similar reshaping before an encoding-based call will produce the intended chart. - "More chart types shown means more insight delivered." - Choosing a chart is a communication decision about the audience's question, not a technical one about which library supports the most chart kinds; a bar chart a stakeholder can read in five seconds often beats a technically richer chart nobody parses correctly.
FAQs
What does "grammar of graphics" actually mean?
- It's a theory that describes any chart as a mapping from data columns to visual encodings: x, y, color, size, shape.
- Rather than drawing shapes directly, you declare the mapping, and a renderer decides layout, scales, and legends.
- In Python, Altair (via Vega-Lite) and plotly express both follow this model; ggplot2 in R is the model's most famous implementation.
Is matplotlib "worse" because it's imperative?
No - imperative control is exactly what publication-quality, precisely annotated static figures need; the trade-off is that every element (legend placement, tick formatting, spacing) has to be coded explicitly rather than inferred from a data mapping.
Why does seaborn feel more like a grammar-of-graphics tool even though it's built on matplotlib?
seaborn's functions accept hue=, col=, and row= arguments that describe encodings the way a declarative grammar would, and it internally generates the per-group loops, color assignment, and legend that you'd otherwise write by hand in raw matplotlib - but the object it returns is still a matplotlib Axes, so you can drop back into imperative calls at any point.
Why does my Altair or plotly express chart look wrong until I reshape my data?
Both libraries expect tidy, long-format data - one row per observation, one column per variable - because the encoding step maps columns directly onto visual channels; wide/pivoted data usually needs .melt() (or an equivalent reshape) before a color=/hue= encoding will group correctly.
When should I pick an interactive library over a static one?
Pick interactive (plotly, Altair with its interactive selections, or a dashboard framework) when the audience needs to explore the data themselves - zoom into a time range, hover for exact values - and pick static (matplotlib, seaborn) when the chart is a fixed deliverable like a report, paper, or presentation slide where interactivity adds no value and a runtime dependency is a liability.
Do declarative charting libraries scale to large datasets the same way?
Not well by default - both grammar-of-graphics-style and interactive renderers track every data point as an individual mark or DOM/canvas object, so tens of thousands of points slow rendering; large data is typically downsampled, binned, or rasterized before being handed to either kind of library.
What's the difference between plotly express and plotly graph_objects?
- plotly express (
px.scatter,px.line) is the declarative, grammar-of-graphics-style entry point - you pass a DataFrame and column names for encodings. - graph_objects (
go.Figure,go.Scatter) is the lower-level, imperative API that express itself is built on. - You typically start with express and drop into graph_objects only for customization express doesn't expose.
Why do dashboards (Streamlit/Dash/Gradio) not replace this choice of mental model?
Those tools are app frameworks for laying out widgets and reactivity in a browser; the charts embedded inside them are still built with matplotlib, plotly, or Altair, so the grammar-versus-imperative decision is made per chart, not superseded by the dashboard framework.
How do I decide which chart type to use for a given question?
Match the encoding to the question type: comparison across categories favors bars, a trend over time favors a line, a relationship between two numeric variables favors a scatter, and a distribution favors a histogram or box/violin plot - the library and model you use is a separate decision from this chart-type choice.
Is Altair's declarative model limited compared to full imperative control?
Altair (via Vega-Lite) covers the vast majority of standard statistical chart types and layered/faceted compositions declaratively, and supports layering and configuration overrides for finer control, but truly bespoke, pixel-specific layouts are still easier to achieve with an imperative tool like matplotlib.
Why does the same dataset sometimes need two different plotting libraries in one project?
Exploratory work benefits from a fast, encoding-driven tool (seaborn or Altair) to iterate through many variable combinations quickly, while the final report or dashboard delivery often needs either matplotlib's precise static control or plotly's interactivity - so a project commonly explores in one model and delivers in another.
What's the biggest audience-related mistake in choosing a chart or library?
Optimizing for what looks impressive to the author rather than what a specific audience can correctly and quickly read - an interactive 3D plotly chart is rarely more useful to a stakeholder than a clearly labeled static bar chart answering their actual question.
Related
- Visualization Basics - the concrete chart-type and library syntax this page's mental model sits underneath.
- matplotlib - the imperative figure/axes model in practice.
- Seaborn - the statistical, encoding-style layer built on matplotlib.
- Altair - the declarative grammar-of-graphics library in practice.
- Plotly - the interactive, hybrid declarative/imperative library.
- Plotting Large Data - what happens to both models at scale.
Stack versions: This page was written for Python 3.14 (stable) / 3.13 (maintenance); it is otherwise conceptual and not tied to a specific plotting-library version.