| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| Afghanistan | Asia | 2007 | 43.828 | 31889923 | 974.5803 |
| Albania | Europe | 2007 | 76.423 | 3600523 | 5937.0295 |
| Algeria | Africa | 2007 | 72.301 | 33333216 | 6223.3675 |
| Angola | Africa | 2007 | 42.731 | 12420476 | 4797.2313 |
| Argentina | Americas | 2007 | 75.320 | 40301927 | 12779.3796 |
28 Faceting
How do you compare the same plot across groups? A facet splits one plot into small multiples: one panel per category of a column. Wilkinson calls a facet a frame of frames. A frame is the space a plot is drawn in, with its own x and y axes. The facet column’s categories make an outer frame, and every cell of it holds a copy of the inner one. Nothing else about the plot changes. The marks, channels, and transforms you have already learned draw each panel exactly as they would draw the whole.
The picture has been invented more than once, so you may know it under another name. Becker and Cleveland’s group at Bell Labs built it into S as Trellis display (Becker et al., 1996). The name comes from the garden trellis, whose rows and columns it resembles, and most people first saw it in S-PLUS. R draws it that way still in the lattice package (Sarkar, 2026), and its own book is where the display is set out in full (Sarkar, 2008). Base R’s coplot() is the same idea under a third name, the conditioning plot, because what a trellis conditions on is the facet column. That is also why the operator in the next section is |, the sign statistics already writes for conditioning. Composition finishes that history. Tufte’s small multiples (Tufte, 1990) names the result rather than the operation, which is the division this book keeps: small multiples for the picture, facet for the thing you write. And facet is Wilkinson’s own word (Wilkinson, 2005), from the Latin facies, a face: one side of a many-sided thing, like one face of a cut diamond. gog takes Wilkinson’s word because it is the most general of them. To him a trellis is one arrangement a facet can take, beside the scatterplot matrix and the row plot. A grammar needs one term that covers all three. The word every one of these systems shares is panel, the cell a split produces, and this chapter uses it the same way.
28.1 |: panels side by side
The scatter of income against life expectancy holds five continents in one cloud. You want to see each on its own without losing the comparison between them. The facet() atom names the column that does the splitting, and | arranges the panels in columns:
data(gapminder_2007) + point + x(gdp) + y(life) | facet(continent)data(gapminder_2007) + point + x(col.gdp) + y(col.life) | facet(col.continent)data(gapminder_2007) + point + x(:gdp) + y(:life) | facet(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
across(col.continent))“Given gapminder 2007: points, x is gdp, y is life, split into panel columns by continent.”
Reading across, the panels share both axes, so a shift between continents reads as plainly as a pattern within one. The shared scale is what faceting is for. One plot per continent, each with its own scale, looks much the same and breaks the comparison, and nothing on the page warns the reader.
28.2 /: panels stacked
Are lives in Africa shorter than in Europe, and by how much? That is a question about where each distribution sits along one axis. / arranges the panels in rows. Stacking aligns the x axis, which is the better direction when the comparison is where things sit along x:
gm_ae <- gapminder_2007[gapminder_2007$continent %in% c("Africa", "Americas", "Europe"), ]
data(gm_ae) + bar * bin + x(life) / facet(continent)“Given the gm ae table: bars derived by bin, x is life, split into panel rows by continent.”
The three distributions line up bar for bar: Africa’s life expectancies spread wide toward the low end, and Europe’s cluster in a narrow band at the high end.
That reading only works because the bars are the same width in all three panels, and a histogram under a facet is where the count and the cut have to be decided separately. Each panel counts its own rows, because a statistic always sees the panel’s subset as if it were the whole table. That is true of every transform on every mark, which is the No Exceptions law applied one panel at a time. But bin is not only a statistic. It cuts the axis into cells and tallies what lands in them, and the cut is a description of the axis rather than a measurement of the rows, so it belongs with the shared scale. One set of bins is cut from all three panels’ rows at once, and each panel then counts only its own into them.
It helps to see what the alternative would draw. Cut per panel, Europe’s narrow spread gets narrow bins and Africa’s wide one gets wide bins, so bars of different widths stand against a single axis. A bar four years wide holding nine countries and a bar one year wide holding eight then draw at nearly the same height. The two rates per year differ by more than three times. The panels are still aligned and the axis still looks correct, and yet the comparison this chapter began with has stopped working.
28.3 Crossing into a grid
Two columns can both matter at once. Here the question is how each continent’s countries moved between 1957 and 2007, which is a split by continent and by period together. Write both operators to cross two columns into a grid. Each operator applies to the facet written after it, read left to right:
gm_years <- gm_all[gm_all$year %in% c(1957, 2007), ]
gm_years$period <- as.character(gm_years$year)
data(gm_years) + point + x(gdp) + y(life) | facet(continent) / facet(period)“Given the gm years table: points, x is gdp, y is life, split into panel columns by continent and panel rows by period.”
Fifty years and five continents in one grid: every panel column is a continent, and every panel row a period. The shared scale lets you measure the rise in life expectancy panel by panel.
The grid is a crossing in Wilkinson’s sense: every row × column combination gets a panel, and a combination with no rows still gets an empty one. The grid shows that the combination is possible, so an empty panel is information rather than an accident. Wilkinson’s other operation, nesting, draws only the combinations that exist. These operators do not do that.
28.4 Folding a long line of panels
One column with many categories makes a long line of panels, and a long line of panels makes each one narrow. Here are the ten most populous countries of 2007, each with its own half-century of life expectancy:
ranked <- gapminder_2007[base::order(-gapminder_2007$population), ]
gm_top <- gm_all[gm_all$country %in% utils::head(ranked$country, 10), ]
data(gm_top) + line + group(country) + x(year) + y(life) | facet(country)“Given the gm top table: lines, x is year, y is life, grouped by country, split into panel columns by country.”
The shapes are still readable and nothing else on the panel is. Read the axis: the tick labels touch and print as one run of digits, 196019802000, and the last panel’s name is wider than the strip that prints it. Ten categories is a common number, and one line of ten panels is already too narrow to read.
wrap folds that line into a rectangle. The number is how many panels to draw before the line turns:
data(gm_top) + line + group(country) + x(year) + y(life) | facet(country, wrap = 4)data(gm_top) + line + group(col.country) + x(col.year) + y(col.life) | facet(col.country, wrap = 4)data(gm_top) + line + group(:country) + x(:year) + y(:life) |
facet(:country, wrap = 4)plot(data(gm_top), line, group(col.country), x(col.year), y(col.life),
across(col.country, { wrap: 4 }))“Given the gm top table: lines, x is year, y is life, grouped by country, split into panel columns by country, wrapped at 4.”
Same ten panels, same shared scale, same sentence with one setting added. The years are readable, United States fits its strip, and each country’s rise is now large enough to look at.
Notice what wrap does not say: which way the panels run. The operator already settles it. | runs the line across, so four to a row; / runs it down, so four to a column:
data(gm_top) + line + group(country) + x(year) + y(life) / facet(country, wrap = 4)data(gm_top) + line + group(col.country) + x(col.year) + y(col.life) / facet(col.country, wrap = 4)data(gm_top) + line + group(:country) + x(:year) + y(:life) /
facet(:country, wrap = 4)plot(data(gm_top), line, group(col.country), x(col.year), y(col.life),
down(col.country, { wrap: 4 }))The same number, the same ten countries, read down the columns instead of across the rows. One number is enough because the direction is already in the sentence, which is why there is no second setting for it. ggplot2 spells the same idea as nrow and ncol.
Two details in the wrapped plots come from the fold itself. The first is the bottom right, where the last row stops after two panels: those two places are what is left over when ten panels are folded into a twelve-cell rectangle, so nothing is drawn in them. They are not the empty panels of the previous section. An empty panel in a crossing is a combination the frame says is possible and the data has no example of, which is information; here there is no combination at all, only leftover space.
The second is the axis under Mexico and Nigeria. Tick labels belong to the panels that touch the edge where the axis is drawn, which in a full rectangle is the bottom row. A folded line can leave the last row short, and then that rule no longer describes the plot. So gog states the rule in a wider form: a panel draws the x axis when no panel sits below it. Mexico and Nigeria sit above the gap, so they keep their year labels.
Wrapping a crossed grid is refused. The crossing already makes a rectangle from two columns, so a panel count beside it would answer the same question a second time:
data(gm_years) + point + x(gdp) + y(life) |
facet(continent, wrap = 2) / facet(period)(data(gm_years) + point + x(col.gdp) + y(col.life) |
facet(col.continent, wrap = 2) / facet(col.period))data(gm_years) + point + x(:gdp) + y(:life) |
facet(:continent, wrap = 2) / facet(:period)plot(data(gm_years), point, x(col.gdp), y(col.life),
across(col.continent, { wrap: 2 }), down(col.period))Error:
! gog: `wrap` folds one line of panels into a rectangle, but `continent` and `period` already cross into one. Drop `wrap`, or facet by one column and let `wrap` shape it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
28.5 Giving a panel its own scale
Everything so far has used one scale across every panel, which is what makes the panels comparable. It is also what makes some of them unreadable. Here is the world’s population by continent, one panel each:
| country | continent | year | life | population | gdp |
|---|---|---|---|---|---|
| Afghanistan | Asia | 1952 | 28.801 | 8425333 | 779.4453 |
| Afghanistan | Asia | 1957 | 30.332 | 9240934 | 820.8530 |
| Afghanistan | Asia | 1962 | 31.997 | 10267083 | 853.1007 |
| Afghanistan | Asia | 1967 | 34.020 | 11537966 | 836.1971 |
| Afghanistan | Asia | 1972 | 36.088 | 13079460 | 739.9811 |
data(gm_all) + line * sum + x(year) + y(population) | facet(continent, wrap = 3)data(gm_all) + line * sum + x(col.year) + y(col.population) | facet(col.continent, wrap = 3)data(gm_all) + line * sum + x(:year) + y(:population) |
facet(:continent, wrap = 3)plot(data(gm_all), layer(line, sum), x(col.year), y(col.population),
across(col.continent, { wrap: 3 }))“Given all the gapminder years: a line derived by sum, x is year, y is population, split into panel columns by continent, wrapped at 3.”
Asia is nearly four billion people and Oceania about twenty-five million, so the axis is sized for Asia and Oceania is a flat line along the bottom. Oceania’s line is drawn correctly, but the axis is too tall for its shape to show. The plot is not wrong. It is reporting, correctly, that these five quantities are not the same size. But if the question was “how did each continent grow”, it has answered a different one.
free = TRUE on the position fits that axis from each panel’s own rows:
data(gm_all) + line * sum + x(year) +
y(population, free = TRUE) | facet(continent, wrap = 3)(data(gm_all) + line * sum + x(col.year) +
y(col.population, free = True) | facet(col.continent, wrap = 3))data(gm_all) + line * sum + x(:year) + y(:population, free = true) |
facet(:continent, wrap = 3)plot(data(gm_all), layer(line, sum), x(col.year),
y(col.population, { free: true }), across(col.continent, { wrap: 3 }))“Given all the gapminder years: a line derived by sum, x is year, y is population, freed, split into panel columns by continent, wrapped at 3.”
Now every panel has a shape, and Oceania turns out to have more than doubled. Notice the y axis: each panel carries its own numbers, because one set of labels along the edge is enough only when the scale is shared.
Notice also what the second plot loses, because that loss is why a free scale is asked for and never assumed. All five panels now look alike: five lines rising left to right at about the same angle. Read side by side, nothing shows that one climb is measured in billions and another in tens of millions. A reader who looks at the shapes and not at the axes will draw the wrong conclusion. The first plot cannot mislead that way and the second can, so gog frees a scale only when you ask for it.
Which axis is freed is decided by where you write it. You write free inside x() or inside y(), so the sentence has already named the axis and free does not name it again. There is no free_x or free_y, and freeing both is writing it twice:
data(gm_all) + point + x(gdp, free = TRUE) + y(life, free = TRUE) |
facet(continent, wrap = 3)(data(gm_all) + point + x(col.gdp, free = True) + y(col.life, free = True) |
facet(col.continent, wrap = 3))data(gm_all) + point + x(:gdp, free = true) + y(:life, free = true) |
facet(:continent, wrap = 3)plot(data(gm_all), point, x(col.gdp, { free: true }),
y(col.life, { free: true }), across(col.continent, { wrap: 3 }))There is a third position, and it takes free the same way. A cube sits in a panel like any other plot (Space):
data(gapminder_2007) + point + x(gdp) + y(life) +
z(population, free = TRUE) + color(continent) | facet(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
z(col.population, free = True) + color(col.continent) | facet(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) +
z(:population, free = true) + color(:continent) | facet(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
z(col.population, { free: true }), color(col.continent),
across(col.continent))“Given gapminder 2007: points, x is gdp, y is life, z is population, freed, color by continent, split into panel columns by continent.”
If the height axis were shared, it would have to reach China, and Oceania’s two countries would sit at the very bottom with nothing to read. Freed, Oceania is measured in tens of millions and has a shape again. The loss is the one this section began with: the five cubes can no longer be compared. That plot needs no new word in the grammar. free is written on a position and z is a position, so the third one accepts it like the other two.
A free scale brings two consequences and one limit with it, and none of them is a setting you can ask for. The first is the bin cut. The histogram earlier in this chapter shares one set of bin edges across every panel, because a cut describes the axis and the axis was shared. Free the axis and the edges follow it, for exactly the same reason:
data(gm_all) + bar * bin + x(gdp, free = TRUE) | facet(continent, wrap = 3)data(gm_all) + bar * bin + x(col.gdp, free = True) | facet(col.continent, wrap = 3)data(gm_all) + bar * bin + x(:gdp, free = true) |
facet(:continent, wrap = 3)plot(data(gm_all), layer(bar, bin), x(col.gdp, { free: true }),
across(col.continent, { wrap: 3 }))Each panel is cut from its own rows, so Africa gets bins the size of Africa’s spread rather than bins sized for the richest countries in the data. You cannot ask for a free scale over a shared cut. That picture would count each panel’s own rows into edges cut from every panel at once.
The second is the panel’s guides: its axis numbers and gridlines. A scale decides where the marks go and where the axis prints its numbers. Freeing the scale has to move both, or the numbers stop describing the picture. Look again at the freed population plot above. Its gridlines sit at different heights in every panel, and each panel’s labels sit beside its own. Shared, those same lines are drawn at one set of heights across all five. That is the whole of what a freed guide means: the lines a reader measures against belong to the scale that was freed.
The third is that a free scale belongs to a panel, never to an animation frame. play (Animation) splits rows the same way a facet does, so you can write free on a played plot. It is refused:
data(gm_all) + point + x(gdp) + y(life, free = TRUE) + play(year)data(gm_all) + point + x(col.gdp) + y(col.life, free = True) + play(col.year)data(gm_all) + point + x(:gdp) + y(:life, free = true) + play(:year)plot(data(gm_all), point, x(col.gdp), y(col.life, { free: true }),
play(col.year))Error:
! gog: `y(life, free = TRUE)` fits one scale per *panel*, and this plot has frames rather than panels. A frame replaces the one before it, so an axis refitted per frame would move under the data and the motion would be the scale's rather than the data's. Facet the plot to free a scale across panels, or leave the sequence on one scale.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The reason is in the message. Panels sit beside each other and a reader compares them. A frame replaces the one before it, so an axis refitted every frame would move under the data. The motion on screen would then be the scale’s, not the data’s. Across panels the freedom is safe. Across frames it would misreport the data.
Free scales are refused elsewhere for the same kind of reason. On a channel that is not a position there is no axis to free, only a legend, and that one legend decodes the whole plot. And with no facet there are no panels:
data(gapminder_2007) + point + x(gdp) + y(life, free = TRUE)data(gapminder_2007) + point + x(col.gdp) + y(col.life, free = True)data(gapminder_2007) + point + x(:gdp) + y(:life, free = true)plot(data(gapminder_2007), point, x(col.gdp), y(col.life, { free: true }))Error:
! gog: `y(life, free = TRUE)` fits one scale per panel, and this plot has one panel. Facet it — `plot | facet(<column>)` — or drop `free`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
28.6 A layer without the facet column
Five panels are easier to judge against one common reference, such as the world’s median life expectancy. A layer whose table does not have the facet column is drawn in full in every panel. That is how one shared reference line is drawn behind small multiples:
| gdp | life |
|---|---|
| 277.5519 | 71.9355 |
| 49357.1902 | 71.9355 |
data(gapminder_2007) + point + x(gdp) + y(life) +
data(world_median) + line + style(color = "gray", size = 1) |
facet(continent)(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
data(world_median) + line + style(color = "gray", size = 1) |
facet(col.continent))data(gapminder_2007) + point + x(:gdp) + y(:life) + data(world_median) +
line + style(color = "gray", size = 1) | facet(:continent)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
data(world_median), line, style({ color: "gray", size: 1 }),
across(col.continent))gog: table `world_median` has no column `continent`, so its layer is drawn in every panel. If it should be split too, add `continent` to that table.
“Given gapminder 2007: points, x is gdp, y is life, and also a line from the world median, split into panel columns by continent.”
The gray line, the world’s median life expectancy, repeats in every panel, so each continent is read against the same line. The engine reports this as an assumption rather than doing it in silence: the table world_median has no continent column, so its layer is drawn everywhere.
28.7 The facet column’s type
You may want one panel for small countries and one for large ones, which is a split by population. A facet column names the panels, so it must be a categorical column: a number is a position along an axis, not a name for a frame. Faceting by a continuous column refuses with direction:
data(gapminder_2007) + point + x(gdp) + y(life) | facet(population)data(gapminder_2007) + point + x(col.gdp) + y(col.life) | facet(col.population)data(gapminder_2007) + point + x(:gdp) + y(:life) | facet(:population)plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
across(col.population))Error:
! gog: `facet(population)` splits on a number column, but a facet variable names the panels, so it must be a category column. Make `population` text — in R, `factor(population)` — or cut it into named groups first.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
shape and group ask the same question of a column, and a facet asks it of the whole plot’s panels. In R, factor(), or any explicit cut into named groups, is the way to say which panels you mean.
Trellis named that cut a shingle, and lattice still builds one with shingle() or equal.count(). A shingle’s intervals are allowed to overlap, so one row can appear in two panels. That makes the change from panel to panel smoother, and it counts some rows twice. Cutting the column yourself with cut() gives up the smoothing and puts the boundaries somewhere a reader can check them, in the strip labels.
Faceting joins with the operators, never with +; a facet is a property of the whole plot’s frame, not one more encoding in the chain. Writing + facet(continent) refuses and points at | and /.