The Grammar of Graphics Explained: Layers, Aesthetics, and Geoms

โฑ๏ธ 12 min read ๐Ÿ“Š Visualization

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:

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:

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.

ComponentWhat it decidesggplot2 example
DataThe table being plotted, ideally one row per observationggplot(mpg)
Aesthetic mappingsWhich variable drives which visual property: x, y, color, fill, size, shape, alpha, linetypeaes(x = displ, y = hwy, colour = class)
GeomThe mark that represents each row or summary: point, line, bar, area, textgeom_point(), geom_line(), geom_col()
StatA computation run before drawing: identity, counts, bins, smoothers, box plot summariesstat_count() (default for geom_bar()), stat_bin(), stat_smooth()
Position adjustmentHow overlapping marks are arrangedposition_stack(), position_dodge(), position_jitter()
ScalesHow data values become aesthetic values, plus the axes and legends that decode themscale_x_log10(), scale_colour_brewer()
CoordinatesThe space the marks are drawn incoord_cartesian(), coord_flip(), coord_polar(), coord_sf()
FacetsHow the data splits into panels (small multiples)facet_wrap(~ drv), facet_grid(drv ~ cyl)
ThemeNon-data styling: fonts, gridlines, background, legend positiontheme_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:

ComponentSetting in this chart
Datampg, 234 rows, set at the plot level and inherited by both layers
Mappingsdispl to x and hwy to y at the plot level (inherited); class to colour in the point layer only
Layer 1Geom: point. Stat: identity (the raw rows). Position: identity.
Layer 2Geom: 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.
ScalesContinuous x and y; discrete colour with one hue for each of the 7 classes. All defaults, never written.
CoordinatesCartesian (the default)
Facetsfacet_wrap(~ drv): three panels with shared axes
Themetheme_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 partggplot2 / plotnineVega-Lite / AltairObservable Plot
Dataggplot(df), or data= per layer"data" / alt.Chart(df)First argument of each mark
Aesthetic mappingaes()"encoding" / .encode()Channel options, e.g. {x: "displ", fill: "class"}
Geomgeom_point(), geom_line()"mark" / .mark_point()Marks: Plot.dot(), Plot.line(), Plot.barY()
Statstat_*(), or a geom's default stat"transform" (aggregate, bin, regression, loess, density), or aggregate/bin inside an encodingTransforms such as Plot.binX(), Plot.groupX(); some stats are marks, such as Plot.linearRegressionY()
Positionposition_stack(), position_dodge()"stack" on an encoding; xOffset channel for grouped barsStack transform (implicit in Plot.barY() and Plot.areaY())
Scalesscale_*()"scale" inside each encoding channelTop-level scale options, e.g. x: {type: "log"}
Coordinatescoord_*(); plotnine has only the Cartesian family (no coord_polar() as of version 0.15)Cartesian; theta/radius channels on the arc mark; projection for mapsCartesian; projection for maps
Facetsfacet_wrap(), facet_grid()"facet", "row", "column" / .facet()fx and fy channels
Themetheme(), theme_*()"config" / .configure_*()style option, CSS
InteractionNone 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:

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.

โ† Back to Visualization Tips