---
title: "Your first ggplot: data, aes and geom"
description: "Build a plot from three pieces: a data frame, an aes mapping and a geom. Learn the one beginner error, colour inside aes versus outside, that trips everyone."
date: "2026-07-18 12:00"
categories: [R, ggplot2, data visualisation, ecology tutorial]
image: thumbnail.png
image-alt: "A small bar chart of five species counts beside the three lines of code that made it, with the data, the aes mapping and the geom labelled."
---
Every ggplot is three answers to three questions: what data, which columns map to what you can see, and what marks to draw. Once those three are separated in your head, the rest of `ggplot2` is variations on them, and the error messages start to make sense. This post builds the smallest real plot, names each of the three pieces, and then spends most of its length on the single mistake that costs beginners the most time: putting a colour in the wrong place.
## The three pieces
Here is a small species table, the kind that comes out of the reshaping in [Wide and long: reshaping species data](../wide-and-long-species-data/). `ggplot2` wants the long shape, one row per observation, so that is what we give it.
```{r data}
library(ggplot2)
counts <- data.frame(
species = c("Festuca", "Trifolium", "Plantago", "Achillea", "Bromus"),
individuals = c(42, 7, 3, 5, 11)
)
counts
```
The plot is three lines:
```{r fig-first}
#| fig-cap: "The smallest complete plot. The data frame supplies the numbers, the aes mapping says species goes on the x axis and individuals on the y, and geom_col draws a bar for each row. Remove any one of the three and there is nothing to draw."
#| fig-alt: "A bar chart with five dark grey bars, one per species. Festuca is by far the tallest at forty-two individuals, then Bromus at eleven, with Trifolium, Achillea and Plantago much shorter."
ggplot(counts, aes(x = species, y = individuals)) +
geom_col()
```
Read it as a sentence. `ggplot(counts, ...)` names the data frame. `aes(x = species, y = individuals)` is the mapping: it connects the `species` column to the x position and the `individuals` column to the y position. `geom_col()` is the mark: one bar per row, its height read from the y mapping. The pieces are joined with `+`, not the pipe, and that is the first thing to get used to, because `+` is particular to `ggplot2` and predates the pipe.
Each piece answers one question:
```{r pieces}
#| eval: false
ggplot(data, aes(mapping)) + geom_something()
# ^data ^which columns ^what marks
```
Miss the data and there is nothing to plot. Miss the mapping and ggplot does not know which column is the height. Miss the geom and you get an empty panel with correct axes and no bars, which is a genuinely useful thing to see once, because it tells you the first two pieces worked.
```{r fig-nogeom}
#| fig-cap: "The same call with no geom. The axes are built from the data and the mapping, so you can see ggplot understood both, but nothing is drawn because no mark was asked for. An empty panel with correct axes almost always means a missing geom."
#| fig-alt: "An empty plot panel. The horizontal axis lists the five species names and the vertical axis runs from zero to forty, but the plotting area is blank with no bars or points."
ggplot(counts, aes(x = species, y = individuals))
```
## Mapping versus setting: the one error
You want the bars a specific colour. There are two ways to write it, they look almost identical, and only one does what you meant. This is the beginner error, and seeing both side by side is the fastest way to never make it again.
The wrong way puts the colour inside `aes`:
```{r fig-wrong}
#| fig-cap: "Colour written inside aes. ggplot treats blue as data: a column with the single value blue, mapped to the fill channel. It assigns that category a colour from its own palette, which is not blue, and adds a legend for a variable that does not exist. The tell is the salmon bars and the pointless legend."
#| fig-alt: "A bar chart where all five bars are the same salmon red, not blue, with a legend on the right labelled blue containing one red swatch. The colour requested was ignored and a spurious legend appeared."
ggplot(counts, aes(x = species, y = individuals, fill = "blue")) +
geom_col()
```
The bars are not blue, and there is a legend nobody asked for. Inside `aes`, everything is a mapping, so `fill = "blue"` tells ggplot to treat the literal word blue as a variable: a column whose only value is blue, mapped to the fill channel. ggplot dutifully assigns that one category a fill from its default palette, which is a salmon red, and builds a legend to explain the mapping. Nothing here is a bug; ggplot did exactly what mapping means. It is just not what you wanted.
The right way sets the colour outside `aes`, as an argument to the geom:
```{r fig-right}
#| fig-cap: "Colour set outside aes, as a plain argument to geom_col. Now blue is an instruction to the mark, not a variable to be mapped, so every bar is that colour and there is no legend. The rule: a constant that should apply to every mark goes outside aes; a column that should vary the marks goes inside."
#| fig-alt: "The same five species bars, now all coloured a solid forest blue, with no legend. The requested colour was applied directly to every bar."
ggplot(counts, aes(x = species, y = individuals)) +
geom_col(fill = "#2c5f8a")
```
Every bar is blue, no legend. The distinction is the whole of the lesson. Inside `aes` means map this column to this channel, and it is what you want when the colour should carry information: one colour per species, per treatment, per site. Outside `aes`, in the geom, means set this channel to this fixed value for every mark, and it is what you want when the colour is just a colour.
The same split governs a genuine mapping. To colour by a real column, `fill` goes back inside `aes`, pointed at a variable rather than a constant:
```{r fig-mapped}
#| fig-cap: "Fill mapped to the species column, which is a real variable, so here the mapping is correct and the legend earns its place. This is the same fill = inside aes as the error above, except it points at a column instead of a constant word. The position of the argument, inside or outside aes, is the decision; the argument name is the same either way."
#| fig-alt: "The five species bars, each a different colour, with a legend on the right listing the five species names against their colours. The fill now encodes which species each bar represents."
ggplot(counts, aes(x = species, y = individuals, fill = species)) +
geom_col() +
scale_fill_brewer(palette = "Greens")
```
## Swapping the geom
Because the data and the mapping are separate from the mark, you can change the mark without touching either. The same `aes` that drew bars will draw points:
```{r fig-swap}
#| fig-cap: "geom_col replaced with geom_point, and nothing else changed. The data and the aes mapping are identical to the first plot; only the mark is different. This separation is why ggplot has one geom per kind of mark rather than one function per kind of chart."
#| fig-alt: "The same five species on the x axis and their counts on the y axis, now shown as five dots instead of bars. Festuca sits highest at forty-two, the others cluster low."
ggplot(counts, aes(x = species, y = individuals)) +
geom_point(size = 3)
```
`geom_col` to `geom_point` is a one word edit. The data did not move, the mapping did not move, and the axes are the same, because those were never the geom's job. When you find yourself searching for a bar chart function and then a scatter plot function, stop: it is the same function, and only the geom changes.
## What to carry forward
A ggplot is `ggplot(data, aes(...)) + geom_...()`, joined with `+`. The mapping in `aes` connects columns to things you can see; the geom draws the marks. The one rule worth memorising past everything else: a value that varies with the data goes inside `aes` as a mapping, and a constant that applies to every mark goes outside `aes` as an argument to the geom. Almost every unexpected legend and every ignored colour in a beginner's plot is that line crossed.
Everything after this is more of the same shape. `facet_wrap` splits the marks into panels, `scale_*` controls how a mapping turns into colours or positions, `theme` changes the surround, and each is added with another `+`. The three pieces do not change.
## Where to go next
The plots here use a hand made data frame; real ones come from [Reading field data into R](../reading-field-data-into-r/) and usually need the long shape from [Wide and long: reshaping species data](../wide-and-long-species-data/). For the counts themselves, [Diversity indices in R](../diversity-indices-in-r/) turns a species table into the numbers worth plotting.
## Related tutorials
- [Wide and long: reshaping species data](../wide-and-long-species-data/)
- [Reading field data into R](../reading-field-data-into-r/)
- [Cleaning species names before you count](../cleaning-species-names/)
- [Diversity indices in R](../diversity-indices-in-r/)
## References
- Wickham H, Navarro D, Pedersen TL 2016 ggplot2: Elegant Graphics for Data Analysis, Springer (ISBN 978-3-319-24277-4)
- Wilkinson L 2005 The Grammar of Graphics, 2nd edn, Springer (ISBN 978-0-387-24544-7)