25  Operators

How do you put two things in one picture? gog has exactly four operators. They are the assembly rules, not atoms themselves, but the rules that say how atoms combine.

Think of them the way Hangeul (한글) works: consonants and vowels are the atoms; the rule “initial + vowel + optional final = syllable block” is the operator. The rule is fixed; the atoms are infinite.

+   compose   (add a layer, a channel, or an annotation to a spec)
*   derive    (combine a mark with a transform into a compound mark)
|   facet ↔   (small multiples, arranged in columns)
/   facet ↕   (small multiples, arranged in rows)

25.1 +: compose

+ is the general assembly operator. Every atom enters a specification via +:

data(df) + point + x(col_a) + y(col_b) + color(group) + title("My plot")

Adding a mark (point, line, bar, …) opens a new layer. Adding a channel (x(), color(), …) or annotation (title()) attaches to the appropriate scope (plot-level or the nearest preceding mark, see Encoding scope).

25.1.1 Layering with +

If you have edited video, you already know this idea. A video is a stack of tracks: the picture, the sound, the captions, the logo, the effects. Each one is separate, they play at the same time, and you watch one thing. A plot layers the same way. It is not a new idea to learn.

Multiple marks in the same expression produce multiple layers, rendered in order:

# Scatter plot: points, and a horizontal reference line at each band
data(gapminder_2007) + x(gdp) + y(life) +
  point + color(continent) +
  data(life_bands) + rule + style(color = "darkgrey", pattern = "dashed") +
  title("GDP vs Life Expectancy, 2007")
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point + color(col.continent) +
  data(life_bands) + rule + style(color = "darkgrey", pattern = "dashed") +
  title("GDP vs Life Expectancy, 2007"))
data(gapminder_2007) + x(:gdp) + y(:life) + point + color(:continent) +
  data(life_bands) + rule +
  style(color = "darkgrey", pattern = "dashed") +
  title("GDP vs Life Expectancy, 2007")
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  color(col.continent), data(life_bands), rule,
  style({ color: "darkgrey", pattern: "dashed" }),
  title("GDP vs Life Expectancy, 2007"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 GDP vs Life Expectancy, 2007 Life Gdp Continent Asia Europe Africa Americas Oceania

“Given gapminder 2007: x is gdp, y is life, points colored by continent, and also rules from the life bands.”

Layers draw in the order they are written: the first mark sits at the bottom. Add a line after point to draw the line on top of the points.

25.1.2 The order of +: data, mark, positions, refinements

Inside a single layer the channels commute, and the mark commutes among them: write the three in any order and the plot is the same. Three things can be arranged six ways, so here are all six.

((data(iris_flowers) + bar * bin + x(petal_length) + color(species)) |
   (data(iris_flowers) + bar * bin + color(species) + x(petal_length))) /
((data(iris_flowers) + x(petal_length) + bar * bin + color(species)) |
   (data(iris_flowers) + x(petal_length) + color(species) + bar * bin)) /
((data(iris_flowers) + color(species) + bar * bin + x(petal_length)) |
   (data(iris_flowers) + color(species) + x(petal_length) + bar * bin))
(((data(iris_flowers) + bar * bin + x(col.petal_length) + color(col.species)) |
   (data(iris_flowers) + bar * bin + color(col.species) + x(col.petal_length))) /
((data(iris_flowers) + x(col.petal_length) + bar * bin + color(col.species)) |
   (data(iris_flowers) + x(col.petal_length) + color(col.species) + bar * bin)) /
((data(iris_flowers) + color(col.species) + bar * bin + x(col.petal_length)) |
   (data(iris_flowers) + color(col.species) + x(col.petal_length) + bar * bin)))
((data(iris_flowers) + bar * bin + x(:petal_length) + color(:species)) |
  (data(iris_flowers) + bar * bin + color(:species) + x(:petal_length))) /
  ((data(iris_flowers) + x(:petal_length) + bar * bin + color(:species)) |
  (data(iris_flowers) + x(:petal_length) + color(:species) + bar * bin)) /
  ((data(iris_flowers) + color(:species) + bar * bin + x(:petal_length)) |
  (data(iris_flowers) + color(:species) + x(:petal_length) + bar * bin))
below(below(beside(plot(data(iris_flowers), layer(bar, bin),
  x(col.petal_length), color(col.species)),
  plot(data(iris_flowers), layer(bar, bin), color(col.species),
  x(col.petal_length))),
  beside(plot(data(iris_flowers), x(col.petal_length), layer(bar, bin),
  color(col.species)),
  plot(data(iris_flowers), x(col.petal_length), color(col.species),
  layer(bar, bin)))),
  beside(plot(data(iris_flowers), color(col.species), layer(bar, bin),
  x(col.petal_length)),
  plot(data(iris_flowers), color(col.species), x(col.petal_length),
  layer(bar, bin))))
2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica 2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica 2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica 2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica 2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica 2 4 6 0 10 20 30 40 Count Petal Length Species setosa versicolor virginica

Six sentences, one histogram, six times over. Not six plots that look alike: the engine emits the same bytes for every one of them.

So if the order does not change the picture, why does every plot in this book follow the same one: data, mark, positions, refinements? Because a fixed order lets a plot read like a sentence, and the sentence has a natural, subject-first shape:

  • data(...) first, the table the columns come from. A bare name like petal_length means nothing until a table is bound (Bind-Once), so the binding leads. In R the sentence must start here.
  • the mark and its *-chain second, the subject. A visual is a mark before it is anything else (Minimum Syllable): bar * mean * dodge says what you are drawing and what was done to it, as one unbroken word. It is the initial consonant of the block; everything after is said about it.
  • the positions third, the frame. x and y say where the subject stands, and a mark cannot render without them (the vowel to the mark’s consonant), so they follow immediately.
  • the refinements last, color, size, order, labels, title: everything optional. Delete every refinement and a legal plot remains; delete the mark or a position and nothing renders. Writing the removable parts last is also how you build a plot: the smallest legal sentence first, then refine (First plot).

The order is a reading convention, not a grammar rule: the engine accepts any permutation that scopes the same way. Where position genuinely does matter (a channel written before or after a mark across two or more layers) is the subject of Encoding scope. The payoff of keeping one order everywhere is that once your eye learns it, every plot in the book, and every plot you write, parses the same way.

Which parts of that order the engine actually enforces, and which are yours to arrange, is gathered for all four operators in Which orders matter below.

25.1.2.1 A refinement far from what it refines

One part of the convention is easy to misread. A grouped bar chart pairs dodge with color, yet they land at opposite ends of the sentence, with the positions in between:

data(gm_eras) + bar * mean * dodge + x(continent) + y(life) + color(era) +
  title("Mean life expectancy by continent, 1957 vs 2007")
(data(gm_eras) + bar * mean * dodge + x(col.continent) + y(col.life) + color(col.era) +
  title("Mean life expectancy by continent, 1957 vs 2007"))
data(gm_eras) + bar * mean * dodge + x(:continent) + y(:life) +
  color(:era) + title("Mean life expectancy by continent, 1957 vs 2007")
plot(data(gm_eras), layer(bar, mean, dodge), x(col.continent),
  y(col.life), color(col.era),
  title("Mean life expectancy by continent, 1957 vs 2007"))
Asia Europe Africa Americas Oceania 0 20 40 60 80 Mean life expectancy by continent, 1957 vs 2007 Life Continent Era 1957 2007

dodge and color(era) are plainly related: dodge has nothing to set side by side until a color (or group) names the groups. So why not write them together? Because they live on opposite sides of the grammar by design. dodge rides *: it modifies the mark, and it is the same modifier that works on box * dodge and interval * range * dodge, neither of which involves color. color rides +: it binds a column to a channel. color * dodge is not a thing: * wants a mark on its left. Welding the two together would cost dodge its independence from which channel does the splitting, and that independence is the whole point of a modifier.

And the order is not a compromise; it is exactly right, once you see what dodge does. dodge subdivides the x slot: each continent’s space is cut into one sub-bar per era. So it needs both neighbors: x(continent) says which slot to divide, color(era) says into how many pieces. The sentence lists them in the order dodge consumes them: the axis it splits along, then the groups it splits into. The positions are not standing between dodge and color; one of them is dodge’s other operand.

You also never have to scan the line to check the split is there. bar * mean * dodge with no color or group does not draw a lone bar; it is refused, pointing you at the missing channel. Legality holds the pair together, so writing color far from dodge costs nothing: the two are bound whether or not they sit side by side on the page.


25.2 *: derive

* modifies a mark in place, producing a compound form.

bar * bin     # histogram
line * smooth # smoothed trend line
line * density # kernel density curve

Two rules govern *, and only two. The mark goes on the left, and the transforms go on the right. Then, among the transforms themselves, the order you write them in does not matter: any order draws the same plot. The rest of this section is why each rule holds.

25.2.1 Why the order is fixed

In Hangeul, a syllable is always initial consonant + vowel, never the reverse. gog’s * works the same way: the mark is the subject; the transform is what is done to it.

bin * bar is a type error, just as ㅏ (a) + ㄱ (g) is not a valid Hangeul syllable.

bar * bin   # ✅  "a bar mark, derived by binning"
bin * bar   # ❌  Error: a transform cannot derive a mark

25.2.2 What * is rigid about

The rigidity that does the work is the one above: the mark is on the left, always, and no transform may stand there. That is what makes bar * bin a compound mark rather than a function call, and it is why the atoms can be nouns.

Write the transforms in the order they happen, and read them the same way. bar * bin * mean cuts the axis into bands, then averages inside each one. That is the order it runs in, and there is no other order it could run in.

The reason is what mean needs. A statistic reduces a group to one number, and a continuous column has no groups: every value stands alone, so its mean is itself. bin is what makes the groups, by turning many distinct numbers into one band. So the cut comes first because otherwise the mean has nothing to average.

That is also why the pairs that can stand together are the ones that do different jobs: saying where the cells are, what is in them, or what scale the answer is read on. A pair that answers the same question twice is refused rather than sequenced, so bin and count (both of them tallies with cells of their own) cannot both be in one layer:

data(gapminder_2007) + bar * bin(12) * count + x(life)
Error:
! gog: `bar * bin * count` measures each cell twice — `bin` and `count` each invent their own measurement from the rows, and neither was handed a column to give way to, so there is no reading that keeps both. Keep whichever you meant: `bar * bin` or `bar * count`. To cut an axis into cells and measure something else inside them, the second transform has to be one you hand a column: `bar * bin * mean + x(<number>) + y(<column>)`. To read either as shares of the whole rather than as counts, `proportion` rescales whichever you keep: `bar * bin * proportion`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The engine works the running order out from the transforms themselves rather than from where you typed them, so it cannot be got wrong. Write it the way you mean it and it will read the way it runs. Chaining transforms takes the pairs one at a time.

25.2.3 Live examples

# Histogram: life expectancy across all countries in 2007
data(gapminder_2007) + bar * bin + x(life) + y(count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Distribution of Life Expectancy, 2007")
(data(gapminder_2007) + bar * bin + x(col.life) + y(col.count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Distribution of Life Expectancy, 2007"))
data(gapminder_2007) + bar * bin + x(:life) + y(:count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Distribution of Life Expectancy, 2007")
plot(data(gapminder_2007), layer(bar, bin), x(col.life), y(col.count),
  x_label("Life expectancy (years)"), y_label("Count"),
  title("Distribution of Life Expectancy, 2007"))
50 60 70 80 0 10 20 30 Distribution of Life Expectancy, 2007 Count Life expectancy (years)
# Scatter + LOESS smooth layer on top
data(gapminder_2007) + x(gdp) + y(life) +
  point + color(continent) +
  line * smooth +
  title("GDP vs Life Expectancy with LOESS Smooth")
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  point + color(col.continent) +
  line * smooth +
  title("GDP vs Life Expectancy with LOESS Smooth"))
data(gapminder_2007) + x(:gdp) + y(:life) + point + color(:continent) +
  line * smooth + title("GDP vs Life Expectancy with LOESS Smooth")
plot(data(gapminder_2007), x(col.gdp), y(col.life), point,
  color(col.continent), layer(line, smooth),
  title("GDP vs Life Expectancy with LOESS Smooth"))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 GDP vs Life Expectancy with LOESS Smooth Life Gdp Continent Asia Europe Africa Americas Oceania
# Kernel density estimate
data(gapminder_2007) + line * density + x(life) + y(density) +
  x_label("Life expectancy (years)") + y_label("Density") +
  title("Kernel Density: Life Expectancy 2007")
(data(gapminder_2007) + line * density + x(col.life) + y(col.density) +
  x_label("Life expectancy (years)") + y_label("Density") +
  title("Kernel Density: Life Expectancy 2007"))
data(gapminder_2007) + line * density + x(:life) + y(:density) +
  x_label("Life expectancy (years)") + y_label("Density") +
  title("Kernel Density: Life Expectancy 2007")
plot(data(gapminder_2007), layer(line, density), x(col.life),
  y(col.density), x_label("Life expectancy (years)"), y_label("Density"),
  title("Kernel Density: Life Expectancy 2007"))
40 60 80 0.00 0.01 0.02 0.03 0.04 Kernel Density: Life Expectancy 2007 Density Life expectancy (years)

25.3 | and /: facet, or compose

| puts things side by side and / puts one above the other. Which things is decided by what is written on the right: a facet() splits one plot into panels, another plot arranges the two on a page.

With facet(), | produces small multiples arranged in columns, one panel per category of the column it names.

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))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 0K 10K 20K 30K 40K 50K 0K 10K 20K 30K 40K 50K 0K 10K 20K 30K 40K 50K 0K 10K 20K 30K 40K 50K Asia Europe Africa Americas Oceania Life Gdp

/ produces small multiples stacked in rows:

gm_two <- gapminder_2007[gapminder_2007$continent %in% c("Africa", "Europe"), ]
data(gm_two) + point + x(gdp) + y(life) / facet(continent)
40 50 60 70 80 0K 10K 20K 30K 40K 50K 40 50 60 70 80 Europe Africa Life Gdp

Write both to cross them into a grid: each operator applies to the facet written after it, read left to right. See Faceting for the grid, what the panels share, and why the facet column must be a category.

With a plot on the right, the same two operators arrange separate plots on one page, each keeping its own coordinate space, sharing an axis only where the two name the same column on it:

(data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + bar * count + x(continent))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + bar * count + x(col.continent)))
(data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + bar * count + x(:continent))
beside(plot(data(gapminder_2007), point, x(col.gdp), y(col.life)),
  plot(data(gapminder_2007), layer(bar, count), x(col.continent)))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp Asia Europe Africa Americas Oceania 0 20 40 Count Continent

One pair of operators, two meanings, told apart by the operand rather than by a second pair of words. See Composition.


25.4 Which orders matter

This book writes every sentence the same way: data, mark, positions, refinements. That is a preference rather than a rule, and it is not all the engine enforces. The difference is worth knowing, because it tells you where to be careful. There are three kinds of ordering, and only one of them needs your attention.

Kind What it means Under + Under *
Free Either way draws the same picture, to the byte. The mark and its positions, in any arrangement. x against y. Refinements among themselves. Two transforms after one mark. Write them in the order they happen; the engine works that order out either way.
Refused The engine stops and names what to move. A sentence that does not begin with data(). A style() written before any mark. A transform on the left: bin * bar.
Meaningful No error, and a different picture. A channel before the marks, or after one of them, once a plot has two. Two marks swapped: the second draws on top. Nothing. No order under * changes the picture.

Read the last cell first. * is rigid about one thing only, which is that the mark stands on its left. Once the mark is there, no arrangement of what follows changes what you get.

Free is most of it. Every one of these draws the identical plot, and the book writes the first only because a fixed order is easier to read:

(data(gapminder_2007) + point + x(gdp) + y(life)) |
  (data(gapminder_2007) + x(gdp) + point + y(life))
((data(gapminder_2007) + point + x(col.gdp) + y(col.life)) |
  (data(gapminder_2007) + x(col.gdp) + point + y(col.life)))
(data(gapminder_2007) + point + x(:gdp) + y(:life)) |
  (data(gapminder_2007) + x(:gdp) + point + y(:life))
beside(plot(data(gapminder_2007), point, x(col.gdp), y(col.life)),
  plot(data(gapminder_2007), x(col.gdp), point, y(col.life)))
0K 20K 40K 40 50 60 70 80 Life Gdp 0K 20K 40K Gdp

Both name life on y, so the two panels share one axis and it is drawn once. That is composition doing what the section above said it does, and it is why the right panel carries no numbers of its own.

Refused is the engine telling you the sentence has no subject yet. A setting needs a mark to settle on, and in R a plot needs its table first, because + has to have a plot on its left before it can add anything:

data(gapminder_2007) + style(size = 3) + point + x(gdp) + y(life)
Error:
! gog: `style()` has no mark to style. Put it after a mark, e.g. `point + style(color = "tomato")`.

Meaningful is the only one to watch, because it is the only one that stays silent. With two marks in a sentence, a channel written before them belongs to the plot and reaches both, and a channel written after one belongs to that mark. Both are legal, neither warns, and they draw different pictures. That is the subject of Encoding scope, and it is the reason this book keeps one order: when every sentence is arranged the same way, the one that is arranged differently is saying so on purpose.

The other silent one is the marks themselves. Two marks swapped draw in the order you wrote them, so the second sits on top of the first.

25.4.1 What groups first

The three kinds above are about where you may put a word. Precedence is a different question: how the words you did write get grouped. It is what lets a sentence like this one carry no parentheses at all.

data(gapminder_2007) + bar * bin + x(life) | facet(continent)
data(gapminder_2007) + bar * bin + x(col.life) | facet(col.continent)
data(gapminder_2007) + bar * bin + x(:life) | facet(:continent)
plot(data(gapminder_2007), layer(bar, bin), x(col.life),
  across(col.continent))
50 60 70 80 0 5 10 15 50 60 70 80 50 60 70 80 50 60 70 80 50 60 70 80 Asia Europe Africa Americas Oceania Count Life

* binds tightest, then +, then |. So the transform joins its mark first, the atoms join the plot next, and the facet splits the finished plot last. / binds as tightly as *, which is why a | b / c reads as a | (b / c) (Composition).

Those are R’s own precedences, and Python’s and Julia’s, so one sentence groups the same way in three of the four languages. JavaScript can give no operator a meaning of its own, so it spells the four as words, which makes the grouping something you see instead of something you remember (JavaScript).

25.4.2 What parentheses may group

Parentheses change which operator runs first, and that is the whole of what they do. They group plots, which is why a composed page uses them: / binds tighter than |, so a | b / c reads as a | (b / c) unless you say otherwise (Composition).

They do not group marks. A second data() applies to the mark written directly after it (Data), and putting parentheses around a run of marks does not widen that reach:

data(actuals) + x(year) + y(sales) +
  line +
  (data(forecast) + point + area)
Error:
! gog: parentheses do not group marks, so everything inside these would be dropped. Write the marks in sequence instead, and repeat `data()` before each one that reads that table: `+ data(forecast) + point + data(forecast) + area`. Parentheses compose whole plots, with `|` and `/`.

gog refuses that instead of drawing it. The refusal is worth having because the alternative is worse than an error: there is no way to keep the marks inside those parentheses, so the only other option is to drop them and draw a plot that is missing two layers without saying so. To put two marks on the second table, write data() before each of them.

The same refusal covers a position or a title inside the parentheses, for the same reason. In JavaScript, where the operators are words, the shape of the mistake is a plot() handed to another plot(), and it is refused there too.


25.5 Why +, and not a pipe

Readers arriving from the tidyverse ask this, and it deserves a real answer, because ggplot2’s own author has said publicly that he would spell it differently today (Wickham, 2018). The regret is on the record: ggplot, ggplot2’s predecessor, was written as function composition; the pipe did not exist yet; + was what he reached for instead. Had %>% been available, he says, the grammar would have piped, and there would have been no need for ggplot2 at all. Three reasons are given for preferring the pipe. Two of them do not reach gog, and the third is the one that settles the question the other way.

The first is about one language. A pipe is a single idea that works everywhere in R, so a grammar spelling composition differently is one more thing to learn, and switching between the two spellings is a frequent source of errors (his own included). That is true, and it is an argument about R’s ecosystem. gog is one syntax spoken by four languages, and the arithmetic there comes out differently. + can be given a meaning in R, Python and Julia. The pipe cannot: Julia has |> in its base library and R has two of them, but Python has no pipe operator, and no way to add one, since it lets you give new meanings only to the operators it already has. Piping would have meant piping in two languages and spelling it some other way in the third, which is the fork this project exists to avoid. JavaScript decides nothing here, because it cannot overload any operator at all, which is why it spells all four as words (JavaScript).

The second is about arithmetic, and it is the sharp one: you expect x + y to equal y + x, and ggplot2’s + does not always oblige. That objection is answered a few sections up. Here the channels commute, and so does the mark among them: move color ahead of x, or write the positions before the mark, and the engine emits the same bytes. One thing does depend on order, two marks swapped, and there the order is the drawing order you asked for: the second draws on top.

The other half of that objection is grouping, whether x + (y + z) means what (x + y) + z means. The question cannot arise here, because there is no way to write the left-hand side. + always takes a plot on its left, so two atoms with no data beneath them are not a smaller expression waiting to be grouped. They are refused:

point + x(gdp)
Error:
! gog: these atoms have no plot to join — the sentence starts with the data: `data(df) + point + x(gdp) + ...`.

No pile of atoms accumulates, so there is no grouping to get wrong.

The third reason is the one that decides it, and it is less an argument against the pipe than a bill the pipe would present. A pipe applies functions: x |> f means f(x), so every atom would have to become a function taking a plot and returning one. Make bar a function, then ask what bar * bin could mean. Nothing at all: you cannot multiply two functions and get a histogram. The derive operator exists only because marks and transforms are things rather than actions, values that combine with each other before either one has met a plot.

That is the same decision this book opens with, arriving a second time. ㄱ is a letter, not an instruction. You put it in a slot next to other letters, and the block is assembled from its pieces rather than performed in sequence. Hangeul’s atoms are nouns, which is exactly why they compose, and gog’s are nouns for the same reason. A grammar built on the pipe is a grammar of verbs: it can layer, because layering really is a sequence of additions, but it cannot derive. Wickham’s own pre-ggplot2 experiment was function composition, and it had no * either.

So + here is a choice rather than something inherited. It is the one operator available in every language this grammar speaks, it commutes wherever nothing should turn on order, and it leaves the atoms as nouns so that * can exist.


25.6 Summary

Operator Reads as Example
+ “…and also…” point + color(continent)
* “…derived by…” bar * bin
| “…one panel per…” (columns) plot | facet(continent)
/ “…one panel per…” (rows) plot / facet(continent)
| “…beside…” plot_a | plot_b
/ “…below…” plot_a / plot_b

A finite set of rules. An infinite space of plots.