The Grammar of Graphics Explained: Layers, Aesthetics, and Geoms
The grammar of graphics describes a chart as a set of independent components instead of a named chart type. Those components are the data, the mappings from variables to visual properties, geometric marks, statistical transformations, scales, a coordinate system, and facets. Leland Wilkinson formalized the idea in The Grammar of Graphics (Springer, 1999; second edition 2005). Hadley Wickham's 2010 paper "A Layered Grammar of Graphics" reworked it into the layer-based design behind ggplot2, and the same idea underpins plotnine, Vega-Lite, Altair, and Observable Plot.
Quick answer: In the grammar of graphics, a chart maps data to the aesthetic attributes (x, y, color, size, shape) of geometric objects (points, lines, bars). The data can pass through a statistical transformation first, such as counting, binning, or smoothing. The result is drawn through scales in a coordinate system and can be split into facets. Wilkinson defined the system in 1999. Wickham's layered grammar (2010) made the layer the building block: data, mapping, stat, geom, and position adjustment. ggplot2 and plotnine work that way. Vega-Lite and Altair express the same parts as data, mark, encoding, and transform. Observable Plot uses marks, channels, transforms, and scales.
What is the grammar of graphics?
It is a formal system for specifying charts by their parts instead of by their names. A named-chart library gives you a menu (bar chart, scatter plot, pie chart) with one function per item. A grammar gives you parts of speech and rules for combining them. A scatter plot becomes "points, with one variable mapped to x and another to y, in Cartesian coordinates." A chart nobody put on the menu is just a new combination of parts, not a missing feature. In his 2010 paper, Wickham describes the payoff as moving beyond named graphics to the deeper structure that statistical graphics share.
Wilkinson's specification builds a graphic from six parts:
- DATA: operations that create variables from a dataset.
- TRANS: variable transformations, such as ranking.
- SCALE: scale transformations, such as log.
- COORD: the coordinate system, such as polar.
- ELEMENT: the graph itself, such as points, plus its aesthetic attributes, such as color.
- GUIDE: axes and legends.
He also defined an algebra with three operators (cross, nest, and blend) for combining variables into the frame a graphic is drawn in.
What did Wickham's layered grammar change?
Wickham's article (Journal of Computational and Graphical Statistics, vol. 19, no. 1, 2010) describes the refinements he made while building ggplot2 for R. The "gg" in the name stands for grammar of graphics. The central change is the layer. A plot is a stack of layers, and each layer bundles a dataset, a set of aesthetic mappings, one statistical transformation (stat), one geometric object (geom), and one position adjustment. The other refinements follow from that:
- Layers are the unit of composition. Points plus a trend line is two layers, added with
+. - Defaults are hierarchical. Every layer inherits the plot-level data and mappings unless it overrides them. Every geom also has a default stat and every stat a default geom, so short code still produces a complete chart.
- Scales own their guides. An axis or legend is how a reader translates a scale back into data values, so ggplot2 builds guides from scales. They are not a separate component.
- Faceting is its own component. ggplot2 does not expose Wilkinson's algebra.
facet_wrap(),facet_grid(), and multiple layers cover most of what it is used for in practice. - The grammar lives inside a programming language. A plot is an ordinary R object built with functions, so you can store it, modify it, and generate it in a loop.
What are the components of the grammar of graphics?
In ggplot2's version, the first five components below belong to each layer and the rest apply to the whole plot. The theme is ggplot2's way of controlling non-data styling. It changes how a chart looks, never what it says about the data, and it is not one of the components Wickham's paper defines.
| Component | What it decides | ggplot2 example |
|---|---|---|
| Data | The table being plotted, ideally one row per observation | ggplot(mpg) |
| Aesthetic mappings | Which variable drives which visual property: x, y, color, fill, size, shape, alpha, linetype | aes(x = displ, y = hwy, colour = class) |
| Geom | The mark that represents each row or summary: point, line, bar, area, text | geom_point(), geom_line(), geom_col() |
| Stat | A computation run before drawing: identity, counts, bins, smoothers, box plot summaries | stat_count() (default for geom_bar()), stat_bin(), stat_smooth() |
| Position adjustment | How overlapping marks are arranged | position_stack(), position_dodge(), position_jitter() |
| Scales | How data values become aesthetic values, plus the axes and legends that decode them | scale_x_log10(), scale_colour_brewer() |
| Coordinates | The space the marks are drawn in | coord_cartesian(), coord_flip(), coord_polar(), coord_sf() |
| Facets | How the data splits into panels (small multiples) | facet_wrap(~ drv), facet_grid(drv ~ cyl) |
| Theme | Non-data styling: fonts, gridlines, background, legend position | theme_minimal(), theme(legend.position = "bottom") |
The stat is the component people most often overlook. geom_bar() does not plot a column from your table. Its default stat, stat_count(), first builds a new table with one row per category and a count. Give it the values A, A, B, C, C, C and it draws bars of height 2, 1, and 3. You can map the variables a stat computes with after_stat(), as in geom_histogram(aes(y = after_stat(density))).
Aesthetic mappings are where chart design decisions happen. The grammar lets you map any variable to any aesthetic, but not all aesthetics are read equally well. The visual encoding hierarchy covers which channels people read most accurately.
How does one chart break down into layers?
Take a common exploratory chart built from ggplot2's mpg dataset of 234 cars. It plots highway fuel economy (hwy) against engine displacement in liters (displ), colors the points by vehicle class, and adds one linear trend line per panel. There is one panel for each drive type: 4 (four-wheel drive), f (front), and r (rear).
library(ggplot2)
ggplot(mpg, aes(x = displ, y = hwy)) + # data + default mappings
geom_point(aes(colour = class)) + # layer 1
geom_smooth(method = "lm", se = FALSE, # layer 2
colour = "black") +
facet_wrap(~ drv) + # facets
theme_minimal() # theme
Every component of the grammar shows up in this chart, whether you wrote it or a default filled it in:
| Component | Setting in this chart |
|---|---|
| Data | mpg, 234 rows, set at the plot level and inherited by both layers |
| Mappings | displ to x and hwy to y at the plot level (inherited); class to colour in the point layer only |
| Layer 1 | Geom: point. Stat: identity (the raw rows). Position: identity. |
| Layer 2 | Geom: smooth (a line). Stat: smooth with method = "lm", which fits hwy ~ displ and returns 80 predicted points per fit. Position: identity. Colour is set to black, not mapped. |
| Scales | Continuous x and y; discrete colour with one hue for each of the 7 classes. All defaults, never written. |
| Coordinates | Cartesian (the default) |
| Facets | facet_wrap(~ drv): three panels with shared axes |
| Theme | theme_minimal() |
Notice what the code leaves out: there is no axis code, legend code, loop over panels, or regression code. The scales produced the axes and the legend. The facet split the data. The stat ran once per panel, so each panel gets its own fit. The slopes are about −2.9 mpg per liter for four-wheel drive, −3.6 for front-wheel drive, and −0.9 for rear-wheel drive.
The chart also shows the difference between mapping and setting. colour = class inside aes() maps a variable and creates a legend. colour = "black" outside aes() sets a constant.
Why does the grammar treat a pie chart as a bar chart?
In the grammar, a pie chart is a single stacked bar drawn in polar coordinates, with the bar's length mapped to angle. The data, mapping, geom, and stat stay the same, so changing only the coordinate system turns one chart into the other.
grades <- data.frame(grade = c("A", "A", "B", "C", "C", "C"))
ggplot(grades, aes(x = "", fill = grade)) +
geom_bar(width = 1) + # one stacked bar, total height 6
coord_polar(theta = "y") # bar length becomes angle
Without coord_polar(), this draws one stacked bar with segments of 2, 1, and 3. With it, the segments become wedges of 120°, 60°, and 180° (each share of 6, times 360°). The chart changes because one component changed, not because you switched to a different chart function.
How do ggplot2, plotnine, Vega-Lite, Altair, and Observable Plot implement it?
All five express the same core components, but they name them differently and disagree about what belongs in the grammar. ggplot2 and plotnine follow the layered grammar almost name for name. Vega-Lite uses data, marks, encodings, and transforms, and adds interaction to the grammar through selections. Altair is a Python API that generates Vega-Lite JSON. Observable Plot keeps marks, channels, scales, and transforms, but its only alternative to Cartesian coordinates is the projection option built for maps.
| Grammar part | ggplot2 / plotnine | Vega-Lite / Altair | Observable Plot |
|---|---|---|---|
| Data | ggplot(df), or data= per layer | "data" / alt.Chart(df) | First argument of each mark |
| Aesthetic mapping | aes() | "encoding" / .encode() | Channel options, e.g. {x: "displ", fill: "class"} |
| Geom | geom_point(), geom_line() | "mark" / .mark_point() | Marks: Plot.dot(), Plot.line(), Plot.barY() |
| Stat | stat_*(), or a geom's default stat | "transform" (aggregate, bin, regression, loess, density), or aggregate/bin inside an encoding | Transforms such as Plot.binX(), Plot.groupX(); some stats are marks, such as Plot.linearRegressionY() |
| Position | position_stack(), position_dodge() | "stack" on an encoding; xOffset channel for grouped bars | Stack transform (implicit in Plot.barY() and Plot.areaY()) |
| Scales | scale_*() | "scale" inside each encoding channel | Top-level scale options, e.g. x: {type: "log"} |
| Coordinates | coord_*(); plotnine has only the Cartesian family (no coord_polar() as of version 0.15) | Cartesian; theta/radius channels on the arc mark; projection for maps | Cartesian; projection for maps |
| Facets | facet_wrap(), facet_grid() | "facet", "row", "column" / .facet() | fx and fy channels |
| Theme | theme(), theme_*() | "config" / .configure_*() | style option, CSS |
| Interaction | None built in (static output) | "params" selections / alt.selection_point(), alt.selection_interval() | tip option, Plot.pointer() transform |
Here is the same faceted chart in the other libraries. ggplot2 code ports to plotnine almost character for character. Column names become strings, and parentheses around the whole expression let the + chain span several lines:
# plotnine (Python)
from plotnine import ggplot, aes, geom_point, geom_smooth, facet_wrap, theme_minimal
from plotnine.data import mpg # a copy of ggplot2's mpg
(
ggplot(mpg, aes(x="displ", y="hwy"))
+ geom_point(aes(color="class"))
+ geom_smooth(method="lm", se=False, color="black")
+ facet_wrap("~drv")
+ theme_minimal()
)
In Altair, the shared encoding goes on a base chart, and each layer adds a mark and whatever it needs. The trend line gets a regression transform. The data is passed to alt.layer() because Altair needs it at the top level of a faceted layered chart:
# Altair (Python), compiles to Vega-Lite
import altair as alt
import pandas as pd
mpg = pd.read_csv("mpg.csv") # same columns: displ, hwy, class, drv
base = alt.Chart().encode(x="displ:Q", y="hwy:Q")
points = base.mark_point().encode(color="class:N")
trend = base.transform_regression("displ", "hwy").mark_line(color="black")
alt.layer(points, trend, data=mpg).facet(column="drv:N")
The Vega-Lite JSON is the same structure written out: a facet wrapping a layer, with the shared encoding at the layer level so both marks inherit it, the way ggplot2 layers inherit plot-level mappings:
{
"data": {"url": "mpg.json"},
"facet": {"column": {"field": "drv", "type": "nominal"}},
"spec": {
"encoding": {
"x": {"field": "displ", "type": "quantitative"},
"y": {"field": "hwy", "type": "quantitative"}
},
"layer": [
{"mark": "point",
"encoding": {"color": {"field": "class", "type": "nominal"}}},
{"transform": [{"regression": "hwy", "on": "displ"}],
"mark": {"type": "line", "color": "black"}}
]
}
}
Observable Plot has no plot-level mappings for marks to inherit, so each mark lists its own data and channels, including the facet channel fx. The regression is a mark, and ci: 0 hides its confidence band:
// Observable Plot (JavaScript)
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
const mpg = await d3.csv("mpg.csv", d3.autoType);
const chart = Plot.plot({
color: {legend: true},
marks: [
Plot.dot(mpg, {x: "displ", y: "hwy", stroke: "class", fx: "drv"}),
Plot.linearRegressionY(mpg, {x: "displ", y: "hwy", fx: "drv", ci: 0})
]
});
document.body.append(chart);
All four versions compute one regression per panel, matching ggplot2. One default differs. Vega-Lite includes zero in quantitative x and y scales, so the Altair and Vega-Lite versions start both axes at 0, while ggplot2, plotnine, and Plot fit the axes to the data. To match, add "scale": {"zero": false} to the channel in Vega-Lite, or write alt.X("displ:Q").scale(zero=False) in Altair.
When is a grammar of graphics library the wrong tool?
It is the wrong tool when the graphic is not really a statistical chart, or when you need something the grammar has no component for. Bespoke, heavily customized, or animated graphics are usually easier in a low-level toolkit such as D3, where you control every element. Drag-and-drop dashboards for non-technical users are a different job entirely.
Also, the grammar only guarantees that a chart is well formed, not that it is readable. It will map a quantitative variable to shading as readily as to position, even though Cleveland and McGill's ranking of elementary perceptual tasks puts position along a common scale at the top and shading at the bottom.
What are the most common grammar of graphics mistakes?
Most bugs in layered-grammar code come from putting a setting in the wrong component, or from a layer inheriting a default you did not intend it to have. These are the ones that produce plausible-looking but wrong charts:
- Mapping an aesthetic globally when one layer needed it.
ggplot(mpg, aes(displ, hwy, colour = class)) + geom_point() + geom_smooth(method = "lm")passescolourto the smooth as well, so it fits seven lines, one per class, instead of one. Put the mapping in the layer that uses it. - Mapping a constant.
geom_point(aes(colour = "blue"))creates a one-value variable named "blue". The points get the first default palette color (a salmon red), plus a legend with a single "blue" key. Set constants outsideaes():geom_point(colour = "blue"). - Zooming with scale limits.
scale_y_continuous(limits = c(20, 30))removes every row outside the range before the stats run. In a box plot ofhwyby class, that drops 100 of the 234 cars and moves the pickup median from 17 to 20 mpg.coord_cartesian(ylim = c(20, 30))zooms without touching the data. - Transforming at the wrong stage. A scale transformation such as
scale_x_log10()is applied before the stat, so a smoother is fitted to the logged values. A coordinate transformation is applied after the stat, so the fit uses raw values and only the drawing is warped. The two give different fitted lines, so choose the one you mean. - Using
geom_bar()on data that is already summarized.geom_bar()counts rows. If your table already has one row per category with a count column, every bar comes out with height 1. Usegeom_col(), which uses the identity stat. - Letting the variable type pick the wrong scale.
cylinmpgis an integer, soaes(colour = cyl)gets a continuous gradient legend instead of four distinct colors. Usefactor(cyl). In Vega-Lite and Altair, the declared type (:Q,:O,:N,:T) sets the scale the same way. - Forgetting the group on lines. Lines connect points within a group. With a discrete x and no grouping variable, ggplot2 warns that "each group consists of only one observation" and draws no line. Add
group = 1, or map the series variable.
Pro Tip: When a layered chart looks wrong, debug it one component at a time by inspecting what each layer actually drew. In ggplot2, layer_data(p, 2) returns the data frame for the second layer after its stat, position adjustment, and scales ran. That shows you counts, fitted values, and group ids directly, and it answers most "why is this bar or line here?" questions. In Altair, chart.to_dict() shows the Vega-Lite spec your Python produced.