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.
Search across all documentation pages
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.
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.
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",
)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 |
hue=/col= API mimics grammar-of-graphics encoding, but the underlying object you get back is still a matplotlib Axes you can further mutate..melt() or similar reshaping before an encoding-based call will produce the intended chart.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.
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.
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.
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.
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.
px.scatter, px.line) is the declarative, grammar-of-graphics-style entry point - you pass a DataFrame and column names for encodings.go.Figure, go.Scatter) is the lower-level, imperative API that express itself is built on.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.
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.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 15, 2026