Your first ggplot: data, aes and geom

R
ggplot2
data visualisation
ecology tutorial
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.
Author

Tidy Ecology

Published

2026-07-18

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. ggplot2 wants the long shape, one row per observation, so that is what we give it.

library(ggplot2)

counts <- data.frame(
  species = c("Festuca", "Trifolium", "Plantago", "Achillea", "Bromus"),
  individuals = c(42, 7, 3, 5, 11)
)
counts
    species individuals
1   Festuca          42
2 Trifolium           7
3  Plantago           3
4  Achillea           5
5    Bromus          11

The plot is three lines:

ggplot(counts, aes(x = species, y = individuals)) +
  geom_col()
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.
Figure 1: 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.

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:

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.

ggplot(counts, aes(x = species, y = individuals))
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.
Figure 2: 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.

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:

ggplot(counts, aes(x = species, y = individuals, fill = "blue")) +
  geom_col()
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.
Figure 3: 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.

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:

ggplot(counts, aes(x = species, y = individuals)) +
  geom_col(fill = "#2c5f8a")
The same five species bars, now all coloured a solid forest blue, with no legend. The requested colour was applied directly to every bar.
Figure 4: 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.

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:

ggplot(counts, aes(x = species, y = individuals, fill = species)) +
  geom_col() +
  scale_fill_brewer(palette = "Greens")
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.
Figure 5: 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.

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:

ggplot(counts, aes(x = species, y = individuals)) +
  geom_point(size = 3)
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.
Figure 6: 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.

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 and usually need the long shape from Wide and long: reshaping species data. For the counts themselves, Diversity indices in R turns a species table into the numbers worth plotting.

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)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.