ggplot2 and the Grammar of Graphics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. This page uses the add-on package ggplot2 version 4.0.3 (the CRAN release of 2026-04-22), one of the core packages of the tidyverse, and a few packages that come with it: scales 1.4.0 (one label function and one out-of-bounds function are called by name), S7 0.2.2 (to ask what a plot object is), svglite 2.2.2 (the SVG writer that ggsave() uses) and dplyr 1.2.1 (a single pipeline, called as dplyr:: functions, prepares one summary table). gtable 0.3.6 supplies the class of the rendered plot and is not called by name, and rlang 1.3.0 supplies the quosures that aes() stores and the embrace operator behind {{ }}; it is called by name once, as rlang::quo_get_expr(), to show what a mapping holds. Behind the scenes ggplot2 also uses cli 3.6.6 (the formatting of its messages), lifecycle 1.0.5 (the deprecation warnings), viridisLite 0.4.3 and RColorBrewer 1.1.3 (the viridis and brewer palette scales) and, when it is installed, ragg 1.5.2 (the PNG files that ggsave() writes). Every other preparation step is base R (merge(), aggregate(), subset(), table()), and the drawing engine underneath is the grid package that ships with R. The packages patchwork, cowplot and gridExtra are named in one sentence and not installed here, so they are never run. The lintr 3.4.0 default linters were used only to check the scripts.

ggplot2 is a package for describing a graphic rather than drawing it: the data, the mapping from variables to visual properties, the shapes, the statistical summaries, the scales, the coordinate system, the panels and the theme are separate parts that are added together with the plus operator into one object, and drawing happens later, in a separate step. This page explains that model and where each part lives in the object, shows what every part does to the data by printing the data behind the plot (the plots themselves are not reproduced on this page), and rebuilds the four charts of the shelf scenario in ggplot2. It also collects the messages and warnings that ggplot2 prints, with their real text, and the places where tutorials written before 2022 no longer match ggplot2 4.0.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes. The names books, loans and loan_days come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). The genre column of books holds nine spellings of six genres, so scripts that group by genre first run books$genre <- tolower(trimws(books$genre)); cleaning is the subject of Strings and Regular Expressions and Data Frames. No script prints a plot at the top level or opens a window. Each one either writes the picture to a file with a name from tempfile(), which is never printed because it contains a session-specific directory, or only prints what the plot contains. Where a ggplot2 message has a second line that starts with a small i in a circle or a cross, those are the bullet symbols that the cli package prints in a UTF-8 locale.

This page continues Base Graphics and Devices, which draws the same shelf charts with the graphics package, and Grid Graphics and Lattice, which explains the grid model that ggplot2 draws with. The mapping step relies on the data masks of Evaluation and Non-Standard Evaluation, and the plot object on the system described in The S7 Object System.

The grammar of graphics

A chart type such as "scatter plot" or "bar chart" is a name for one combination of choices, not a primitive. The statistician Leland Wilkinson made that observation the basis of his book The Grammar of Graphics (Springer, 1999; second edition 2005): a graphic is a sentence built from a small number of independent parts (the data, how variables are turned into visual properties, the geometric shapes, the statistical transformations, the scales, the coordinate system, the panels), and different sentences give the familiar chart types and many unfamiliar ones. Hadley Wickham adapted that idea to R in the paper "A Layered Grammar of Graphics" (Journal of Computational and Graphical Statistics, 2010), and ggplot2 is its implementation. Both are community and book material, not part of the official R documentation, and this page does not reproduce them; what it describes is ggplot2 4.0.3 as its own documentation describes it. The package’s introduction names seven composable parts, and says that a chart needs at least three of them (data, a mapping and a layer) because the scales, facets, coordinates and themes have defaults (ggplot2 package documentation: Introduction to ggplot2).

Part What it decides Where it is written

Data

The data frame, one row per observation. Each layer can have its own.

ggplot(data) or geom_*(data = )

Mapping

Which variable drives which visual property (aesthetic): position, colour, fill, shape, size, alpha, line type, grouping.

aes(), in ggplot() or in a layer

Layers

What is drawn: a geom (the shape), a stat (a computation applied to the data first) and a position adjustment (where overlapping shapes go).

geom_*(), stat_*(), position_*()

Scales

How data values become visual values (a number to a position, a factor level to a colour) and how the axes and legends are labelled.

scale_*(), lims(), labs()

Coordinates

How positions are projected onto the page: Cartesian, flipped, fixed aspect, polar, transformed.

coord_*()

Facets

How the data is split into small multiples.

facet_*()

Theme

Everything that is not data: fonts, grid lines, backgrounds, legend placement.

theme_*(), theme(), element_*()

The pipeline below follows the data through those parts in the order ggplot2 applies them. First ggplot() and the plus operator only record what was asked for in an object. Drawing it takes three stages. ggplot_build() runs the data through the layers and the scales and returns a list of data frames, one per layer, ready to draw; the reference page for it calls this the step that performs everything necessary to produce an object that can be rendered. ggplot_gtable() turns that into a table of grid graphical objects (grobs), applying the theme and building the legends. And a graphics device draws the table. print() on a plot, the automatic printing of a plot at the console, and ggsave() all run the three stages. The diagram orders the facets last, as the page would explain them, but the assignment of rows to panels is decided at the very start of the build (the plot’s layout is set up before the aesthetics are evaluated), and the panels themselves are laid out and labelled in the gtable stage.

flowchart TD subgraph make["Building the object: nothing is drawn"] start["ggplot(data, aes(...)): the data and the default mapping"] -->|"+"| layer["+ layer: geom_*() or stat_*() with its own data, mapping, stat, geom and position"] layer -->|"+"| parts["+ scale_*(), coord_*(), facet_*(), theme_*(), labs(), guides()"] parts --> obj["plot object of class ggplot (S7): data, layers, scales, coordinates, facet, theme, labels"] end obj -->|"print(p) or ggsave(): all three stages"| d1 subgraph build["ggplot_build(): data in, data out"] d1["data: the plot data or the layer's own data"] --> d2["aes() mapping: quoted expressions evaluated in the data"] d2 --> d3["stat_*(): computed variables, mapped back with after_stat()"] d3 --> d4["geom_*(): default aesthetics and setup"] d4 --> d5["position adjustment: stack, dodge, fill, jitter"] d5 --> d6["scale_*(): trained on every layer, map data to visual values, then after_scale()"] d6 --> d7["coord_*(): the coordinate system and the panel ranges"] d7 --> d8["facet_*(): the panels, one per combination of the faceting variables"] end d8 --> built["ggplot_built object: one data frame per layer, the layout and the plot"] built -->|"ggplot_gtable() or ggplotGrob()"| t1 subgraph draw["ggplot_gtable(): data to grobs"] t1["theme_*() and theme(): the non-data ink"] --> t2["guides: legends and colour bars built from the scales"] t2 --> gt["gtable of grobs"] end gt -->|"grid.draw() on a device"| out["drawn by grid: window, file or ggsave()"]

Every box in the diagram corresponds to a function family with a regular name: geom_*() and stat_*() build layers, position_*() the adjustments, scale_<aesthetic>_<type>() the scales, and coord_*(), facet_*() and theme_*() the rest. A name that follows the pattern can be guessed and found with tab completion, and the regularity is the reason the same few ideas cover a very large number of charts. The rest of this page takes the stages one by one, always by looking at the data or the object that the stage produced.

A plot is an object

ggplot() does not draw anything. It returns an object, and everything after it with a plus sign returns a new object with one more component. The reference page for ggplot() says that it initializes the object, declares the default data frame and the aesthetic mappings that all later layers share unless they override them, and is almost always followed by a plus sign (ggplot2 package documentation: ggplot). The script below creates a plot with no layers, adds one, and then takes the three stages of the pipeline apart: the object, the built data, and the gtable.

library(ggplot2)
source("scenario.R")

# Two books have no page count and one has no year; leave them out for now.
known <- subset(books, !is.na(pages) & !is.na(year))

# ggplot() builds an object; nothing is drawn until it is printed or saved.
p <- ggplot(known, aes(x = pages, y = year))
class(p)
#> [1] "ggplot2::ggplot" "ggplot"          "ggplot2::gg"     "S7_object"
#> [5] "gg"
length(p$layers)
#> [1] 0

# The plus operator returns a new plot with the layer added.
p2 <- p + geom_point()
length(p$layers)
#> [1] 0
length(p2$layers)
#> [1] 1
p2$layers
#> $geom_point
#> geom_point: na.rm = FALSE
#> stat_identity: na.rm = FALSE
#> position_identity
#>
p2$mapping
#> Aesthetic mapping:
#> * `x` -> `pages`
#> * `y` -> `year`
summary(p2)
#> data: title, author, isbn, year, genre, pages [13x6]
#> mapping:  x = ~pages, y = ~year
#> faceting:  <empty>
#> -----------------------------------
#> geom_point: na.rm = FALSE
#> stat_identity: na.rm = FALSE
#> position_identity
#>

# Stage 1, ggplot_build(): data to data (stats, positions, scales applied).
built <- ggplot_build(p2)
class(built)
#> [1] "ggplot2::ggplot_built" "ggplot_built"          "ggplot2::gg"
#> [4] "S7_object"
head(built$data[[1]], 3)
#>     x    y PANEL group shape colour fill size alpha stroke
#> 1 387 1974     1    -1    19  black   NA  1.5    NA    0.5
#> 2 432 1813     1    -1    19  black   NA  1.5    NA    0.5
#> 3 214 2019     1    -1    19  black   NA  1.5    NA    0.5

# Stage 2, ggplot_gtable(): the built plot to a gtable of grobs. Measuring
# text needs a graphics device, so open one that writes to a temporary file.
svg(tempfile(fileext = ".svg"))
gt <- ggplot_gtable(built)
class(gt)
#> [1] "gtable" "gTree"  "grob"   "gDesc"
invisible(dev.off())

# Stage 3: draw the gtable on a device. ggsave() opens one for the file.
ggsave(tempfile(fileext = ".svg"), p2, width = 6, height = 4)

Five things to take from the output. The class vector of a plot starts with "ggplot2::ggplot": since ggplot2 4.0 the plot is an S7 object (see ggplot2 4.0 on S7). Adding a layer returns a new plot and leaves the old one with no layers: the plot is a value, and p can be kept and extended into several plots. summary() of a plot prints the data frame’s columns and size, the mapping, the facets and the layers, which is the quickest way to check what a long expression built. ggplot_build() returns an object of class "ggplot2::ggplot_built" whose data (built$data, one data frame per layer) already has the aesthetics evaluated and the scales applied: the first rows show the numbers x and y that will be drawn, the default shape, colour, size and stroke that the geom filled in, the panel the row belongs to and the group. ggplot_gtable() takes that built object and returns a gtable, a grid graphical object, and measuring the text for it needs a graphics device, which is why the script opens svg() to a temporary file around the call. The reference page for ggplot_build() documents the helpers that read the middle stage, layer_data() (the data of layer i) and layer_scales() (the scales of a panel), and in 4.0 their new names get_layer_data() and get_panel_scales(); the page says they are useful for tests, and this page uses layer_data() as a window on what a layer will draw. In ggplot2 4.0 the reference page also describes the internal function build_ggplot() as the builder and ggplot_build() as vestigial; ggplot_build() is the one that is exported by the package and the one to call.

Printing, ggsave() and the default device

A plot object is drawn when it is printed. At the console the value of an expression is printed automatically, so typing the name of a plot draws it. Inside a script that is executed with source(), inside a function or inside a loop, nothing is printed automatically and a plot that is never passed to print() is never drawn; this is a frequent surprise, and it has its own entry in the R FAQ, which names ggplot2 graphics among the objects that are created but not displayed until their print() method runs (R FAQ 7.22: Why do lattice/trellis graphics not work?). The documentation of print.ggplot() makes the same point: the default top-level print method does it for you, and an explicit print() is needed to draw a plot inside a function or a for loop (ggplot2 package documentation: print.ggplot).

The other half of the surprise happens under Rscript. No device is open when the script starts. The first operation that needs one, whether drawing a plot or just measuring text for ggplotGrob(), opens the default device, which for a non-interactive session is the pdf device, and the pdf device writes a file called Rplots.pdf in the working directory. The script below shows it, and cleans up after itself; code that is meant to run unattended should open its own device, or use ggsave(), so that no stray file appears.

library(ggplot2)
source("scenario.R")
known <- subset(books, !is.na(pages) & !is.na(year))
p <- ggplot(known, aes(pages, year)) + geom_point()

# Under Rscript no device is open until something needs one.
dev.cur()
#> null device
#>           1
g <- ggplotGrob(p) # measuring text opens the default device
dev.cur()
#> pdf
#>   2
file.exists("Rplots.pdf")
#> [1] TRUE

# Clean up: close the device and delete the file it wrote.
invisible(dev.off())
unlink("Rplots.pdf")
file.exists("Rplots.pdf")
#> [1] FALSE

The plus operator

Everything that can be added to a plot is added with the plus operator, which is a method of the plot class and returns a new plot. The script below adds a layer, a second layer, a scale, a coordinate system, a facet specification, a complete theme and a title, then shows where each landed. It also shows that a list of components is added element by element and that adding NULL does nothing, which is what makes it possible to keep a house style in one list and add it to every plot. The plus operator is a binary operator, so it must end the line when an expression continues on the next one: a line that starts with a plus is a new statement, and ggplot2 detects that case and says so.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))
base <- ggplot(known, aes(pages, year))

# The plus operator adds layers, scales, coordinates, facets, themes, labels,
# guides and lists of them; each lands in its own property of the plot.
p <- base +
  geom_point() +
  geom_rug() +
  scale_x_log10() +
  coord_cartesian(ylim = c(1900, 2030)) +
  facet_wrap(~genre) +
  theme_bw() +
  labs(title = "Pages and year")
length(p$layers)
#> [1] 2
vapply(p$layers, \(l) class(l$geom)[[1L]], character(1L))
#>  geom_point    geom_rug
#> "GeomPoint"   "GeomRug"
class(p$coordinates)[[1L]]
#> [1] "CoordCartesian"
class(p$facet)[[1L]]
#> [1] "FacetWrap"
p$theme@complete
#> [1] TRUE
get_labs(p)$title
#> [1] "Pages and year"

# A list of components is added element by element, and NULL adds nothing.
common <- list(theme_minimal(), labs(x = NULL), NULL)
q <- base + geom_point() + common
q$theme@complete
#> [1] TRUE
length(q$layers)
#> [1] 1

# Adding returns a new plot: p is unchanged. The building blocks can be kept
# in variables and reused.
r <- base + geom_point()
s <- r + geom_rug()
c(r = length(r$layers), s = length(s$layers))
#> r s
#> 1 2

# Starting a line with + is a syntax trap: R has already finished the statement.
tryCatch(+geom_point(), error = \(e) conditionMessage(e))
#> [1] "Cannot use `+` with a single argument.\nℹ Did you accidentally put `+` on a new line?"
tryCatch(base + "points", error = \(e) conditionMessage(e))
#> [1] "Can't add `\"points\"` to a <ggplot> object."

The two messages at the end are the real text of the 4.0.3 errors. The first, Cannot use + with a single argument, is what a leading plus produces: R evaluated +geom_point(), the unary plus of a layer, as a statement of its own. The second names the object that cannot be added, with its value quoted. The package’s own documentation calls the underlying generic update_ggplot() (the older ggplot_add() is described there as vestigial), which is the function an extension package implements a method for in order to make its own objects addable.

Data and aesthetics

The first argument of ggplot() is the data, a data frame (the reference page says that anything else is converted by fortify(); the shelf books and loans of Data Frames are data frames already). The second is the mapping, built by aes(). A mapping is a set of pairs: the name of an aesthetic on the left, an expression on the right that is evaluated in the data to give that aesthetic’s values. The common aesthetics are x and y, colour (also spelt color), fill, shape, size, alpha, linewidth, linetype, label and group; each geom has a list of those it understands, printed by GeomPoint$aesthetics() in the script below, and the article on aesthetic specifications says which values each one accepts (ggplot2 package documentation: Aesthetic specifications).

aes() quotes its arguments. Like quote() in Language Objects and Quoting, it does not evaluate genre; it records the expression together with the environment where it was written (a quosure), and ggplot2 evaluates it later, in the data. That is the data mask of Evaluation and Non-Standard Evaluation, and it is the reason a column name can be written without quotes or books$.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))

# aes() quotes its arguments: nothing is looked up when it is called.
m <- aes(x = pages, y = year, colour = genre)
class(m)
#> [1] "ggplot2::mapping" "uneval"           "gg"               "S7_object"
m$colour
#> <quosure>
#> expr: ^genre
#> env:  global
rlang::quo_get_expr(m$colour)
#> genre

# Mapping: colour is a variable; a scale turns it into colours and a legend
# appears.
mapped <- ggplot(books, aes(pages, year, colour = genre)) + geom_point()
head(layer_data(mapped)[c("x", "y", "colour")], 4)
#>     x    y  colour
#> 1 387 1974 #F564E3
#> 2 432 1813 #F8766D
#> 3 214 2019 #B79F00
#> 4 198 2021 #B79F00

# Setting: colour is one constant for the whole layer, no scale, no legend.
fixed <- ggplot(books, aes(pages, year)) + geom_point(colour = "red")
head(layer_data(fixed)[c("x", "y", "colour")], 4)
#>     x    y colour
#> 1 387 1974    red
#> 2 432 1813    red
#> 3 214 2019    red
#> 4 198 2021    red
names(fixed$layers[[1]]$aes_params)
#> [1] "colour"

# The classic mistake: quoting a constant inside aes() maps it.
oops <- ggplot(books, aes(pages, year)) + geom_point(aes(colour = "red"))
unique(layer_data(oops)$colour)
#> [1] "#F8766D"

# Aesthetics a geom understands.
GeomPoint$aesthetics()
#> [1] "x"      "y"      "shape"  "colour" "fill"   "size"   "alpha"  "stroke"
#> [9] "group"

The distinction between mapping and setting causes more confusion than any other. An aesthetic inside aes() is a mapping: it is evaluated against the data, goes through a scale, and gets a legend. The same name outside aes(), as an argument of the geom, is a setting: a constant for the layer, stored in aes_params, with no scale and no legend. The script shows both: the mapped colour column holds the hexadecimal colours the discrete colour scale picked for each genre, and the set colour holds the constant. The classic mistake, aes(colour = "red"), maps the constant string: it is a one-value variable, so it gets the first colour of the default palette (a salmon pink, #F8766D) and a legend with one entry called "red". Write colour = "red" outside aes().

Groups

Several geoms draw one shape per group, for example one line for each group. The group aesthetic says which rows belong together, and ggplot2 builds it by default from the interaction of all the discrete variables in the mapping (the group column of the built data in the first script was -1, meaning no discrete variable). When a line plot comes out as a single zigzag through all the groups, or as a set of single points, the cause is the group aesthetic: the cure is aes(group = ...), and the message in the section on messages, "Each group consists of only one observation", is ggplot2 pointing at it.

Functions that take column names: {{ }} and .data

A function that builds a plot from a column the caller names has the problem described in the page on non-standard evaluation: aes(var) quotes the symbol var, not the argument that var stands for. Tidy evaluation, from rlang, solves it, and aes() supports it: embracing the argument with {{ var }} passes on what the caller wrote, and the .data pronoun indexes the data with a string held in a variable. The .data pronoun is re-exported by ggplot2 and the embrace operator is syntax that aes() understands, so no other package needs to be attached.

library(ggplot2)
source("scenario.R")
known <- subset(books, !is.na(pages))

# aes() quotes its arguments. {{ }} ("embrace") passes on the caller's argument.
shelf_plot <- function(df, var) {
  ggplot(df, aes({{ var }})) + geom_histogram(binwidth = 100)
}
p <- shelf_plot(known, pages)
p$mapping
#> Aesthetic mapping:
#> * `x` -> `pages`

# Without the embrace, aes() looks for a column called "field".
bad_plot <- function(df, field) {
  ggplot(df, aes(field)) + geom_histogram(binwidth = 100)
}
res <- tryCatch(
  layer_data(bad_plot(known, pages)),
  error = \(e) conditionMessage(e)
)
cat(res, sep = "\n")
#> Problem while computing aesthetics.
#> ℹ Error occurred in the 1st layer.
#> Caused by error:
#> ! object 'pages' not found

# A column name held in a string: the .data pronoun.
by_column <- function(df, col) {
  ggplot(df, aes(.data[[col]])) + geom_histogram(binwidth = 100)
}
q <- by_column(known, "pages")
q$mapping
#> Aesthetic mapping:
#> * `x` -> `.data[["pages"]]`
head(layer_data(q)[c("x", "count")], 3)
#>     x count
#> 1 100     2
#> 2 200     4
#> 3 300     4

# Two embraces in one aes(): the arguments are bare column names.
shelf_scatter <- function(df, x, y) {
  ggplot(df, aes({{ x }}, {{ y }})) + geom_point()
}
shelf_scatter(known, pages, year)$mapping
#> Aesthetic mapping:
#> * `x` -> `pages`
#> * `y` -> `year`

The first function, shelf_plot(), is the pattern to remember: aes({{ var }}), called as shelf_plot(known, pages). The second shows the error that the version without the embrace raises: aes(field) captured the symbol field, evaluating it forced the argument that the caller wrote, pages, in the caller’s frame, where there is no variable of that name. The third function takes the column name as a string, which is the usual case when the name comes from a configuration, a menu or a loop, and writes it .data[[col]]; the mapping that results shows .data[["pages"]]. The fourth takes two bare names. The embrace operator and the .data pronoun are documented by rlang (rlang package documentation: Embrace operator). The functions aes_string() and aes_() that older tutorials use for the same job are deprecated; see Where older tutorials differ. In a package, referring to columns of the data without .data makes R CMD check report "no visible binding for global variable", which the ggplot2 article on using it in packages treats (ggplot2 package documentation: Using ggplot2 in packages).

Layers: geoms, stats and positions

The heart of a plot is its layers. The introduction article says that every layer has three important parts: the geometry that decides how the data is displayed, the statistical transformation that may compute new variables from the data and affect which of it is shown, and the position adjustment that mostly decides where each piece of data is displayed. A layer is built either with a geom_*() or with a stat_*() function, and which one is used only decides which of the three parts the function sets as its default; the other two stay arguments (ggplot2 package documentation: Introduction to ggplot2). Underneath both is layer(), which takes the geom, the stat and the position explicitly and is what an extension package calls (ggplot2 package documentation: layer). The script prints the triple for a representative set of constructors.

library(ggplot2)

# Every layer is a geom + a stat + a position adjustment. The geom_*()
# and stat_*() constructors differ only in which half they default.
parts <- \(l) {
  c(
    geom = class(l$geom)[[1L]],
    stat = class(l$stat)[[1L]],
    position = class(l$position)[[1L]]
  )
}
layers <- list(
  geom_point = geom_point(),
  geom_line = geom_line(),
  geom_col = geom_col(),
  geom_bar = geom_bar(),
  geom_histogram = geom_histogram(),
  geom_boxplot = geom_boxplot(),
  geom_density = geom_density(),
  geom_smooth = geom_smooth(method = "lm"),
  geom_text = geom_text(),
  geom_errorbar = geom_errorbar(),
  geom_tile = geom_tile(),
  geom_jitter = geom_jitter(),
  geom_violin = geom_violin(),
  stat_summary = stat_summary()
)
t(vapply(layers, parts, character(3L)))
#>                geom             stat           position
#> geom_point     "GeomPoint"      "StatIdentity" "PositionIdentity"
#> geom_line      "GeomLine"       "StatIdentity" "PositionIdentity"
#> geom_col       "GeomCol"        "StatIdentity" "PositionStack"
#> geom_bar       "GeomBar"        "StatCount"    "PositionStack"
#> geom_histogram "GeomBar"        "StatBin"      "PositionStack"
#> geom_boxplot   "GeomBoxplot"    "StatBoxplot"  "PositionDodge2"
#> geom_density   "GeomDensity"    "StatDensity"  "PositionIdentity"
#> geom_smooth    "GeomSmooth"     "StatSmooth"   "PositionIdentity"
#> geom_text      "GeomText"       "StatIdentity" "PositionNudge"
#> geom_errorbar  "GeomErrorbar"   "StatIdentity" "PositionIdentity"
#> geom_tile      "GeomTile"       "StatIdentity" "PositionIdentity"
#> geom_jitter    "GeomPoint"      "StatIdentity" "PositionJitter"
#> geom_violin    "GeomViolin"     "StatYdensity" "PositionDodge"
#> stat_summary   "GeomPointrange" "StatSummary"  "PositionIdentity"

# Two constructors, one layer: the arguments are the same ones.
identical(class(geom_bar()$stat), class(stat_count()$stat))
#> [1] TRUE
class(stat_summary(geom = "point")$geom)[[1L]]
#> [1] "GeomPoint"

Read the matrix row by row. geom_point(), geom_line(), geom_tile() and geom_errorbar() draw the data as it is (StatIdentity, PositionIdentity). geom_bar() counts the rows first (StatCount) and stacks the bars that share a position, while geom_col() draws the heights it is given (StatIdentity) and stacks them. geom_histogram() is a bar geom fed by StatBin. geom_boxplot() computes the five numbers with StatBoxplot and dodges the boxes (PositionDodge2). geom_smooth() fits a model (StatSmooth), geom_density() estimates a density (StatDensity), geom_violin() a density per group (StatYdensity). geom_jitter() is geom_point() with PositionJitter. In 4.0 geom_text() has PositionNudge, and the 4.0 NEWS says that position_nudge() gained nudge_x and nudge_y aesthetics. The last line of the script shows that the two spellings of one layer are the same layer: geom_bar() and stat_count() have the same stat, and stat_summary(geom = "point") swaps the geom of a stat-first constructor.

Bars: geom_bar() and geom_col(), histograms, boxes and smooths

geom_bar() and geom_col() are the pair that older tutorials mix up. geom_bar() takes only x (or only y) and counts the rows at each value; giving it a y as well is an error. geom_col() takes a y and draws a bar of that height; omitting it is the other error. The tutorials that write geom_bar(stat = "identity") to draw given heights were writing geom_col() by hand: the reference page says that geom_col() uses stat_identity() and leaves the data as it is. geom_histogram() bins a continuous variable by binwidth or by bins (30 by default, with a message that says so) and counts the rows in each bin; the script below uses binwidth = 7 with boundary = 0 so that the bins are weeks starting from day 0. geom_boxplot() gets its five numbers and its outliers from the stat. geom_smooth(method = "lm") fits the model and draws the line and its band; it prints a message when the formula was not given.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# geom_bar() counts rows (stat = "count"); geom_col() draws a given height.
bar_plot <- ggplot(books, aes(genre)) + geom_bar()
head(layer_data(bar_plot)[c("x", "y", "count", "prop")], 3)
#>   x y count prop
#> 1 1 2     2    1
#> 2 2 4     4    1
#> 3 3 2     2    1
counts <- as.data.frame(table(genre = books$genre), responseName = "n")
col_plot <- ggplot(counts, aes(genre, n)) + geom_col()
head(layer_data(col_plot)[c("x", "y")], 3)
#>   x y
#> 1 1 2
#> 2 2 4
#> 3 3 2
class(geom_bar(stat = "identity")$stat)[[1L]]
#> [1] "StatIdentity"

# geom_histogram(): bins are chosen by binwidth (or bins), then counted.
hist_plot <- ggplot(shelf, aes(duration)) +
  geom_histogram(binwidth = 7, boundary = 0)
head(layer_data(hist_plot)[c("x", "xmin", "xmax", "count", "density")], 3)
#>      x xmin xmax count    density
#> 1  3.5    0    7     4 0.01142857
#> 2 10.5    7   14     9 0.02571429
#> 3 17.5   14   21    13 0.03714286

# geom_boxplot(): the stat computes the five numbers and the outliers.
box_plot <- ggplot(shelf, aes(duration, genre)) + geom_boxplot()
bd <- layer_data(box_plot)
bd[order(bd$y), c("y", "xmin", "xlower", "xmiddle", "xupper", "xmax")]
#>   y xmin xlower xmiddle xupper xmax
#> 1 1   21     21    23.0  24.00   28
#> 2 2    1     11    20.0  27.25   37
#> 3 3   10     10    18.5  29.25   36
#> 4 4   16     17    22.0  24.00   27
#> 5 5   21     21    24.0  25.00   26
#> 6 6    8     16    20.0  27.00   36

# geom_smooth(method = "lm") prints a message, because formula was defaulted.
with_pages <- subset(shelf, !is.na(pages))
smooth <- ggplot(with_pages, aes(pages, duration)) +
  geom_point() +
  geom_smooth(method = "lm")
fit <- layer_data(smooth, 2L)
#> `geom_smooth()` using formula = 'y ~ x'
nrow(fit)
#> [1] 80
head(fit[c("x", "y", "ymin", "ymax", "se")], 3)
#>          x        y     ymin     ymax       se
#> 1 88.00000 18.46648 13.02827 23.90469 2.690752
#> 2 92.35443 18.49603 13.15581 23.83626 2.642270
#> 3 96.70886 18.52559 13.28271 23.76846 2.594100

layer_data() is the tool for reading what a layer will draw. For the bars, x is the position of the genre on the discrete axis (the first genre is 1), y and count are the number of books, and prop is the proportion within the group, which is 1 here because each bar is its own group. For the histogram, xmin and xmax are the edges of the seven-day bins and count and density the computed variables. For a horizontal box plot the stat’s output uses the horizontal names: xmin, xlower, xmiddle, xupper, xmax are the whisker ends, the box edges and the median, because the plot was written with the continuous variable on x, see coordinates. For the smooth, the layer holds 80 rows (the fit evaluated on a grid of 80 x values) with y, the band ymin and ymax and the standard error se; the message above the numbers is what geom_smooth() prints when no formula was given, to say which formula it chose (the reference page says that method = NULL implies y ~ x for fewer than 1000 observations, and a smooth s(x, bs = "cs") otherwise). The shelf version of this chart passes formula = y ~ x and is silent.

The other geoms

The geoms that are left do not need a stat to explain them. The script below builds seven more plots from the shelf data and reports how many rows each layer holds after the build. geom_density() estimates a smooth curve (512 points by default). geom_jitter() is points with a little random noise on a discrete axis, so that equal values do not overprint. geom_violin() draws the density per group (so it has many rows per group). geom_errorbar() draws vertical intervals from the ymin and ymax aesthetics, here a mean plus or minus one standard error. geom_text() and geom_label() draw the label aesthetic at x and y, the second inside a box. geom_tile() draws rectangles at the x and y positions and colours them by fill.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# Mean duration and standard error per genre, for the error bars.
by_genre <- aggregate(duration ~ genre, data = shelf, FUN = mean)
by_genre$se <- aggregate(duration ~ genre, data = shelf,
                         FUN = \(x) sd(x) / sqrt(length(x)))$duration
by_genre$n <- as.vector(table(shelf$genre))

plots <- list(
  density = ggplot(shelf, aes(duration)) + geom_density(),
  jitter = ggplot(shelf, aes(genre, duration)) + geom_jitter(width = 0.2),
  violin = ggplot(shelf, aes(genre, duration)) + geom_violin(),
  errorbar = ggplot(by_genre, aes(genre, duration)) +
    geom_point() +
    geom_errorbar(aes(ymin = duration - se, ymax = duration + se), width = 0.3),
  text = ggplot(by_genre, aes(n, duration, label = genre)) + geom_text(),
  label = ggplot(by_genre, aes(n, duration, label = genre)) + geom_label(),
  tile = ggplot(shelf, aes(factor(format(loan_date, "%m")), genre)) +
    geom_tile(aes(fill = duration))
)

# How many rows does each layer end up with, and does it draw?
rows <- vapply(plots, \(p) nrow(layer_data(p)), integer(1L))
rows
#>  density   jitter   violin errorbar     text    label     tile
#>      512       50     3087        6        6        6       50
for (name in names(plots)) {
  ggsave(tempfile(fileext = ".svg"), plots[[name]], width = 6, height = 4)
}
by_genre
#>             genre duration       se  n
#> 1         classic 21.40000 2.839014  5
#> 2         fiction 19.33333 2.390067 18
#> 3         mystery 20.75000 6.472699  4
#> 4     non-fiction 21.20000 2.083267  5
#> 5          poetry 20.00000 4.086563  5
#> 6 science fiction 20.53846 2.252108 13

The row counts are the lesson: the density layer has 512 rows however many books there are, the six error bars, labels and boxes correspond to the six genres, the tile layer has one row per loan, and the violin layer has about 500 rows per genre because it holds the outline of each density. Each of the seven plots was also written to a temporary SVG file by the loop, which proves they draw without warnings with this data; the standard error in by_genre is widest for mystery (four loans, 6.5 days), which is what a small sample deserves.

Stats and computed variables

A stat is a function from the layer’s data to new data. geom_bar() replaces the rows by counts; geom_smooth() replaces them by a fitted curve; stat_summary() replaces the rows of each group by a summary. The output has new columns, the computed variables, and the geom reads some of them by default: the histogram’s bars read count as their height because StatBin$default_aes maps y to after_stat(count), as the script prints. To use a different computed variable, map it with after_stat(), inside aes(): aes(y = after_stat(density)) turns a histogram of counts into a density scale, and an expression such as after_stat(count / sum(count)) can compute from the columns the stat produced. The reference page for the evaluation functions lists the three stages at which an aesthetic can be mapped: from the data, after the stat (after_stat()), and after the scale has mapped it (after_scale()); after_stat() replaces the older stat() and the dot-dot notation (ggplot2 package documentation: Control aesthetic evaluation). after_scale() works on the visual value that the scale produced, which is how the box outline in the script is made a darker version of its fill without a second scale.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# A stat turns the layer's data into new data. stat_summary() with fun = mean.
means <- ggplot(shelf, aes(genre, duration)) +
  stat_summary(fun = mean, geom = "point")
layer_data(means)[c("x", "y")]
#>   x        y
#> 1 1 21.40000
#> 2 2 19.33333
#> 3 3 20.75000
#> 4 4 21.20000
#> 5 5 20.00000
#> 6 6 20.53846

# fun.data returns y, ymin and ymax; mean_se() is the default.
ranges <- ggplot(shelf, aes(genre, duration)) +
  stat_summary(fun.data = mean_se)
head(layer_data(ranges)[c("x", "y", "ymin", "ymax")], 3)
#>   x        y     ymin     ymax
#> 1 1 21.40000 18.56099 24.23901
#> 2 2 19.33333 16.94327 21.72340
#> 3 3 20.75000 14.27730 27.22270

# stat_bin() computes count, density and more; the geom reads count by default.
StatBin$default_aes
#> Aesthetic mapping:
#> * `x`      -> `after_stat(count)`
#> * `y`      -> `after_stat(count)`
#> * `weight` -> 1
hist_plot <- ggplot(shelf, aes(duration)) +
  geom_histogram(binwidth = 7, boundary = 0)
names(layer_data(hist_plot))
#>  [1] "count"       "x"           "xmin"        "xmax"        "density"
#>  [6] "ncount"      "ndensity"    "flipped_aes" "PANEL"       "group"
#> [11] "y"           "ymin"        "ymax"        "colour"      "fill"
#> [16] "linewidth"   "linetype"    "alpha"       "width"

# after_stat() maps a computed variable to an aesthetic.
density_plot <- ggplot(shelf, aes(duration)) +
  geom_histogram(aes(y = after_stat(density)), binwidth = 7, boundary = 0)
head(layer_data(density_plot)[c("x", "y", "count", "density")], 3)
#>      x          y count    density
#> 1  3.5 0.01142857     4 0.01142857
#> 2 10.5 0.02571429     9 0.02571429
#> 3 17.5 0.03714286    13 0.03714286
share_plot <- ggplot(shelf, aes(duration)) +
  geom_histogram(aes(y = after_stat(count / sum(count))),
                 binwidth = 7, boundary = 0)
round(sum(layer_data(share_plot)$y), 6)
#> [1] 1

# after_scale() works on the value after the scale has mapped it.
tinted <- ggplot(shelf, aes(genre, duration, fill = genre)) +
  geom_boxplot(aes(colour = after_scale(adjustcolor(fill, red.f = 0.5,
                                                    green.f = 0.5,
                                                    blue.f = 0.5))))
unique(layer_data(tinted)[c("fill", "colour")])[1:2, ]
#>      fill    colour
#> 1 #F8766D #7C3B37FF
#> 2 #B79F00 #5C5000FF

# A stat can be named in the layer: stat_count() is geom_bar()'s stat.
count_plot <- ggplot(shelf, aes(genre)) + stat_count(geom = "point")
head(layer_data(count_plot)[c("x", "y")], 3)
#>   x  y
#> 1 1  5
#> 2 2 18
#> 3 3  4

stat_summary() is the stat to use for a mean and its spread: fun = mean computes one value per group, and fun.data takes a function that returns the three columns y, ymin and ymax at once; mean_se() (mean plus or minus one standard error) is the default, and ggplot2 also provides mean_cl_normal(), mean_sdl() and median_hilow() (the reference page says that these come from the Hmisc package, which is not installed here, so they are not run). The argument names are fun, fun.min, fun.max and fun.data; the older fun.y is deprecated, see Where older tutorials differ. The reference page is ggplot2 package documentation: stat_summary.

The output of the last block shows that stat_count(geom = "point") draws the same counts as points: the stat is what computes the numbers, the geom is only how they are shown. The pairing can be changed in either direction, which is the grammar at work: a histogram is stat_bin(geom = "bar"), and geom_freqpoly() is the same StatBin drawn with a path geom.

Position adjustments

When shapes land on the same position, the position adjustment decides what happens. It is the third part of a layer, and it works on the data after the stat, as a function from the data to data. position_stack() (the default of bars) puts the shapes on top of each other; position_dodge() puts them side by side, each taking part of the slot; position_fill() stacks and rescales every stack to the interval from 0 to 1, so that the bars show shares; position_jitter() adds random noise. The argument can be the name (position = "dodge") or the object, which is the form to use when the adjustment has its own arguments (position_dodge(preserve = "single"), position_jitter(width = 0.2, height = 0, seed = 282)). The reference pages are ggplot2 package documentation: position_dodge, ggplot2 package documentation: position_stack and ggplot2 package documentation: position_jitter.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$year <- format(shelf$loan_date, "%Y")
shelf <- subset(shelf, !is.na(year))
counts <- as.data.frame(table(genre = shelf$genre, year = shelf$year),
                        responseName = "n")
counts <- counts[counts$genre %in% c("fiction", "science fiction"), ]
counts
#>              genre year  n
#> 2          fiction 2025 16
#> 6  science fiction 2025  8
#> 8          fiction 2026  7
#> 12 science fiction 2026  7

bars <- \(position) {
  ggplot(counts, aes(year, n, fill = genre)) + # nolint: object_usage_linter.
    geom_col(position = position)
}
rows <- \(p) layer_data(p)[c("x", "xmin", "xmax", "ymin", "ymax")]

# Stack (the default for bars): the bars of one x are piled up.
rows(bars("stack"))
#>   x xmin xmax ymin ymax
#> 1 1 0.55 1.45    8   24
#> 2 1 0.55 1.45    0    8
#> 3 2 1.55 2.45    7   14
#> 4 2 1.55 2.45    0    7
# Dodge: side by side; each bar gets half of the slot.
rows(bars(position_dodge(preserve = "single")))
#>       x xmin xmax ymin ymax
#> 1 0.775 0.55 1.00    0   16
#> 2 1.225 1.00 1.45    0    8
#> 3 1.775 1.55 2.00    0    7
#> 4 2.225 2.00 2.45    0    7
# Fill: stacked and rescaled to 0..1, so the heights are shares.
rows(bars("fill"))
#>   x xmin xmax      ymin      ymax
#> 1 1 0.55 1.45 0.3333333 1.0000000
#> 2 1 0.55 1.45 0.0000000 0.3333333
#> 3 2 1.55 2.45 0.5000000 1.0000000
#> 4 2 1.55 2.45 0.0000000 0.5000000

# Jitter moves points randomly: seed it so that the plot can be reproduced.
jittered <- position_jitter(width = 0.2, height = 0, seed = 282)
pts <- ggplot(shelf, aes(genre, pages)) + geom_point(position = jittered)
round(as.numeric(head(layer_data(pts)$x, 4)), 3)
#> [1] 1.949 2.181 1.931 2.189

The numbers show the three adjustments on the same bars. With stacking, the two bars at the first x share the slot from 0.55 to 1.45 and the second sits on top of the first (ymin 8, ymax 24 for the bar of 16 fiction loans, since the bar of 8 science fiction loans is below it). With dodging, each bar gets half of the slot (the xmin and xmax pairs 0.55 to 1.00 and 1.00 to 1.45) and both start at zero. With fill, the heights are shares: one third and two thirds in the first year, one half and one half in the second. The jitter is random, so the positions change from run to run unless a seed is given, which is why the script passes one; x in the built data of a jittered discrete axis is a number near the integer position of the genre, of class "mapped_discrete", so the script converts it before rounding.

Scales

A scale is the dictionary between data values and visual values for one aesthetic. The introduction article describes scales as the translation from what is shown on the graph back to an understanding of the data: they update the limits, set the breaks, format the labels and may apply a transformation, and in the plot they are represented by guides, the axes and legends. The functions are named scale_<aesthetic>_<type>(), where the aesthetic is one from the mapping and the type says what kind of scale it is: scale_x_continuous(), scale_x_date(), scale_colour_manual(), scale_fill_brewer(), scale_colour_viridis_d(). The type matches the data: continuous data gets a continuous scale, a factor a discrete one, a Date a date scale (Dates and Times covers the class). If no scale is added for a mapped aesthetic, ggplot2 adds the default one for the type of the data, and it is that default scale which the scripts below read.

A scale has two jobs, in this order, on every layer’s data: it is trained, which means it learns the range of the data (the limits for a continuous scale, the levels for a discrete one) across all the layers that use the aesthetic, and then it maps each data value to a visual value (a position, a colour, a size). The script below reads the trained scales from a built plot to show both steps, and then changes the limits, the breaks and the labels of a position scale, applies a logarithmic transformation, swaps the colour scale, and sets the titles with labs().

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))

base <- ggplot(known, aes(pages, year, colour = genre)) + geom_point()

# A scale is trained on the data of every layer, then maps data to values.
built <- ggplot_build(base)
x_scale <- built$layout$panel_scales_x[[1L]]
x_scale$get_limits()
#> [1]  88 432
x_scale$get_breaks()
#> [1] 100 200 300 400
colour_scale <- built$plot$scales$get_scales("colour")
class(colour_scale)[[1L]]
#> [1] "ScaleDiscrete"
colour_scale$get_limits()
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
colour_scale$map(c("poetry", "classic"))
#> [1] "#619CFF" "#F8766D"

# Position scale arguments: limits, breaks and labels.
tuned <- base +
  scale_x_continuous(
    limits = c(0, 500),
    breaks = seq(0, 500, by = 100),
    labels = scales::label_number(suffix = " p")
  )
scale_tuned <- ggplot_build(tuned)$layout$panel_scales_x[[1L]]
scale_tuned$get_breaks()
#> [1]   0 100 200 300 400 500
scale_tuned$get_labels()
#> [1] "0 p"   "100 p" "200 p" "300 p" "400 p" "500 p"

# Transformations: transform = "log10" (argument trans before 3.5.0).
logged <- base + scale_x_continuous(transform = "log10")
ggplot_build(logged)$layout$panel_scales_x[[1L]]$get_transformation()$name
#> [1] "log-10"
shortcut <- base + scale_x_log10()
ggplot_build(shortcut)$layout$panel_scales_x[[1L]]$get_transformation()$name
#> [1] "log-10"

# Colour scales: manual, viridis, brewer.
manual <- base + scale_colour_manual(
  values = c(classic = "grey40", fiction = "orange", mystery = "purple",
             `non-fiction` = "brown", poetry = "steelblue",
             `science fiction` = "darkgreen")
)
unique(layer_data(manual)$colour)
#> [1] "darkgreen" "grey40"    "orange"    "purple"    "brown"     "steelblue"
viridis <- base + scale_colour_viridis_d()
unique(layer_data(viridis)$colour)
#> [1] "#FDE725FF" "#440154FF" "#414487FF" "#2A788EFF" "#22A884FF" "#7AD151FF"
brewer <- base + scale_colour_brewer(palette = "Dark2")
unique(layer_data(brewer)$colour)
#> [1] "#E6AB02" "#1B9E77" "#D95F02" "#7570B3" "#E7298A" "#66A61E"

# Labels: labs() sets title, subtitle, caption, axis and legend titles.
labelled <- base + labs(
  title = "Pages and publication year",
  subtitle = "Shelf catalogue",
  caption = "Source: books.csv",
  x = "Pages",
  y = "Year",
  colour = "Genre"
)
get_labs(labelled)[c("title", "subtitle", "caption", "x", "y", "colour")]
#> $title
#> [1] "Pages and publication year"
#>
#> $subtitle
#> [1] "Shelf catalogue"
#>
#> $caption
#> [1] "Source: books.csv"
#>
#> $x
#> [1] "Pages"
#>
#> $y
#> [1] "Year"
#>
#> $colour
#> [1] "Genre"
#>

The continuous scale learned the range 88 to 432 pages and chose four breaks; the discrete colour scale learned the six genres and its map() method turned "poetry" and "classic" into two of the default hue colours. In the second plot the three arguments of a position scale are visible: limits fix the range (and drop the data outside it, see the next section), breaks the positions of the ticks, and labels the text, here produced by scales::label_number(suffix = " p"), a function from the scales package that formats the break values; a character vector or a plain function of the breaks also works. The names of the transformation scales are the part that changed: the argument is transform since ggplot2 3.5.0 (earlier it was trans, which the NEWS lists as deprecated), and scale_x_log10() is the shortcut for scale_x_continuous(transform = "log10"). The script confirms that both produce the transformation "log-10". The colour scales show three ways to choose colours: scale_colour_manual(values = ) takes a named vector (names matched to levels, which is the safe form), scale_colour_viridis_d() uses the viridis palette and scale_colour_brewer(palette = ) a ColorBrewer palette; the colours of the plot are the colour column of the layer data. Titles are not part of the scales but live in labs(): title, subtitle, caption, x, y and one entry per aesthetic that has a legend (colour = "Genre"), and get_labs() reads the completed set. Since 4.0 the labels are derived while the plot is built, so p$labels is empty until then; code that read it before the build should call get_labs() instead (the ggplot2 4.0.0 NEWS lists this among the breaking changes).

Palettes and the 4.0 theme palettes

Until 4.0 the colours of a default discrete or continuous colour scale came from a global option. In 4.0 the default scales (scale_colour_discrete(), scale_fill_continuous() and the others for the aesthetics colour and fill) have a palette argument that is NULL by default, which means "ask the theme", and the theme has new elements palette.colour.discrete, palette.colour.continuous and the same for fill; that replaced the old options-based system, with some backward compatibility (ggplot2 4.0.0 NEWS). The practical effect is that a palette can now be part of a theme, see Themes.

Limits, dropped rows and zooming

The most common warning in ggplot2 is not about a mistake in the code. It is about data that cannot be drawn. A missing value in a mapped aesthetic cannot be placed, and a value outside the limits of a scale has been turned into a missing value by the scale; in both cases the row is removed before drawing and ggplot2 says how many were removed and by which layer. The same mechanism explains two spellings that look equivalent and are not: xlim(100, 300) (and lims(), and limits = in a scale) set the limits of the scale, so the data outside them is converted to NA and removed, and every stat then works on what is left; coord_cartesian(xlim = c(100, 300)) only changes the window through which the plot is seen, and the stats still use all the data. When the question is "show me this part of the plot", use the coordinate system; when the question is "ignore these values", use the scale.

The script collects the conditions raised while a plot is built and drawn, to show the text of the warning in each case, and then compares what the two approaches do to a fitted line. The warning is raised while the plot is drawn, not when it is built, which is why the helper in the script saves the plot to a temporary file.

library(ggplot2)
source("scenario.R")

# Collect the messages and warnings raised while an expression runs.
conditions <- \(expr) {
  seen <- character()
  withCallingHandlers(
    expr,
    message = \(m) {
      seen <<- c(seen, conditionMessage(m))
      invokeRestart("muffleMessage")
    },
    warning = \(w) {
      seen <<- c(seen, conditionMessage(w))
      invokeRestart("muffleWarning")
    }
  )
  seen
}
draw <- \(p) ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 4)

# Three books have no pages or no year: they are dropped when drawing.
base <- ggplot(books, aes(pages, year)) + geom_point()
cat(conditions(draw(base)), sep = "\n")
#> Removed 3 rows containing missing values or values outside the scale range
#> (`geom_point()`).

# xlim() sets scale limits: data outside is converted to NA, then removed.
cut_off <- base + xlim(100, 300)
cat(conditions(draw(cut_off)), sep = "\n")
#> Removed 10 rows containing missing values or values outside the scale range
#> (`geom_point()`).
sum(is.na(layer_data(cut_off)$x))
#> [1] 10

# coord_cartesian() zooms: no data is removed, only the window changes.
zoomed <- base + coord_cartesian(xlim = c(100, 300))
cat(conditions(draw(zoomed)), sep = "\n")
#> Removed 3 rows containing missing values or values outside the scale range
#> (`geom_point()`).
sum(is.na(layer_data(zoomed)$x))
#> [1] 2

# The difference matters when a stat uses the data: the fit changes. (The
# messages and warnings of these builds are the ones shown above.)
trend <- ggplot(books, aes(pages, year)) +
  geom_smooth(method = "lm", se = FALSE)
fit_range <- \(p) {
  d <- suppressWarnings(suppressMessages(layer_data(p, 1L)))
  round(range(d$y), 1)
}
fit_range(trend + xlim(100, 300))
#> [1] 2009.4 2024.0
fit_range(trend + coord_cartesian(xlim = c(100, 300)))
#> [1] 1953.0 2051.8

# oob controls what happens to values outside the limits; oob_keep() keeps them.
kept <- base + scale_x_continuous(limits = c(100, 300), oob = scales::oob_keep)
cat(conditions(draw(kept)), sep = "\n")
#> Removed 3 rows containing missing values or values outside the scale range
#> (`geom_point()`).

# expand adds padding around the data; expand = FALSE removes it.
tight <- base + coord_cartesian(expand = FALSE)
tight$coordinates$expand
#> [1] FALSE
base$coordinates$expand
#> [1] TRUE

Three books have no page count or no year, so the first plot already reports Removed 3 rows containing missing values or values outside the scale range, with the layer in brackets on the second line. With xlim(100, 300) the count is ten: the three rows with a missing value and seven more whose page count is outside the range; layer_data() shows NA in the x column of ten rows (the two missing page counts and eight books outside the range). With coord_cartesian() the count is back to three, and only the two missing page counts show up as NA in x. The fit shows why it matters: with xlim() the line is estimated only from the books with 100 to 300 pages, and its y runs from 2009.4 to 2024.0; with coord_cartesian() the line is estimated from all books over their whole range of pages, with y from 1953.0 to 2051.8, and the window only crops it. Neither is wrong; they answer different questions, and silently mixing them up is how a trend line changes when a plot is "zoomed". The scale argument oob ("out of bounds") says what happens to a value outside the limits: the default converts it to NA, and scales::oob_keep leaves it in the data (the plot is clipped to the panel). expand adds padding around the data; coord_cartesian(expand = FALSE) removes it, and the expansion() helper builds a padding of a given multiple or amount (ggplot2 package documentation: expansion). The coordinate system is documented at ggplot2 package documentation: coord_cartesian and the out-of-bounds handlers at scales package documentation: oob.

Coordinate systems

The coordinate system turns the positions the scales produced into positions on the page. The default is coord_cartesian(), which also carries xlim and ylim (the zoom above), expand and, since 4.0, a ratio and a reverse. The others change the geometry. coord_fixed(ratio = ) makes one unit on x as long as ratio units on y, which is right for maps and for plots where both axes are in the same unit; in 4.0 it is a wrapper around coord_cartesian(ratio = ). coord_flip() swaps the axes. coord_polar() maps x to an angle and y to a radius, and its successor coord_radial() (introduced with 3.5.0) adds a better axis system. coord_transform() (named coord_trans() before 4.0.0) applies a transformation after the stats, which is different from a scale transformation that is applied before them.

coord_flip() is the one that old tutorials use for horizontal bars and horizontal box plots, and the reason is historical: before ggplot2 3.3.0 the way to put the groups on the vertical axis was to draw the vertical version and flip the whole plot. Since 3.3.0 the geoms and stats that have a direction can choose it, deduced from the mapping or given by the orientation argument. Mapping the categorical variable to y is enough: ggplot(shelf, aes(duration, genre)) + geom_boxplot() draws horizontal boxes. The ggplot2 3.5.0 NEWS marks coord_flip() as superseded and recommends swapping the aesthetics and using orientation, and the script shows that both ways draw the same boxes.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# Horizontal boxes the old way: draw vertical ones, then flip the coordinates.
flipped <- ggplot(shelf, aes(genre, duration)) +
  geom_boxplot() +
  coord_flip()
class(flipped$coordinates)[[1L]]
#> [1] "CoordFlip"
flipped$coordinates$is_linear()
#> [1] TRUE

# The current way: put the continuous variable on x and the groups on y.
horizontal <- ggplot(shelf, aes(duration, genre)) + geom_boxplot()
class(horizontal$coordinates)[[1L]]
#> [1] "CoordCartesian"
layer_data(horizontal)$flipped_aes[[1L]]
#> [1] TRUE

# Same pictures, same boxes: the data differ only in the names of the columns.
a <- layer_data(flipped)
b <- layer_data(horizontal)
a$ymin[order(a$x)]
#> [1] 21  1 10 16 21  8
b$xmin[order(b$y)]
#> [1] 21  1 10 16 21  8

# coord_fixed(): one unit on x is as long as one unit on y.
fixed <- ggplot(shelf, aes(pages, duration)) +
  geom_point() +
  coord_fixed(ratio = 5)
fixed$coordinates$ratio
#> [1] 5

# coord_polar() and its successor coord_radial() change the geometry: this is
# how a pie chart is made, and why one is not drawn here.
pie <- ggplot(shelf, aes(x = "", fill = genre)) +
  geom_bar(width = 1) +
  coord_polar(theta = "y")
class(pie$coordinates)[[1L]]
#> [1] "CoordPolar"
class(coord_radial())[[1L]]
#> [1] "CoordRadial"
class(coord_transform(y = "log10"))[[1L]]
#> [1] "CoordTransform"

The two box plots have the same five numbers per genre (the vertical ones in the ymin column of the built data, the horizontal ones in xmin), and the layer data of the second records flipped_aes as TRUE, which is how the stat and the geom agree on the direction. coord_fixed(ratio = 5) stores its ratio. The last three lines show the class of a polar pie, of coord_radial() and of the transformed coordinate system. A pie chart is a bar chart in polar coordinates, geom_bar(width = 1) with coord_polar(theta = "y"), and the script builds one and shows its class; it is not drawn here because angles are read less accurately than lengths and a bar chart (the default of geom_bar()) says the same thing better. The reference pages are ggplot2 package documentation: coord_fixed, ggplot2 package documentation: coord_polar and coord_radial and ggplot2 package documentation: coord_transform.

Facets

Facets split the data into panels that share the scales and the layout. facet_wrap() makes a ribbon of panels from the levels of one or more variables and wraps it to fit; facet_grid() makes a grid with one variable on the rows and another on the columns. Both take the variables as vars() (the current spelling, which quotes like aes()) or as a formula such as ~ genre or genre ~ period, which is the older spelling and still works. facet_wrap() has ncol and nrow, scales ("fixed", "free", "free_x" or "free_y" to let each panel have its own axis range), and labeller, a function that makes the text of the strip: label_both adds the variable name, label_wrap_gen() wraps long labels, and as_labeller() turns a named vector or a function into one.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)
shelf$period <- ifelse(shelf$loan_date < as.Date("2026-01-01"), "2025", "2026")

base <- ggplot(shelf, aes(duration)) + geom_histogram(binwidth = 7)

# facet_wrap(): a ribbon of panels, one per value of the variable.
wrapped <- base + facet_wrap(~genre, ncol = 3, scales = "free_y")
layout <- ggplot_build(wrapped)$layout$layout
layout[c("PANEL", "ROW", "COL", "genre", "SCALE_X", "SCALE_Y")]
#>   PANEL ROW COL           genre SCALE_X SCALE_Y
#> 1     1   1   1         classic       1       1
#> 2     2   1   2         fiction       1       2
#> 3     3   1   3         mystery       1       3
#> 4     4   2   1     non-fiction       1       4
#> 5     5   2   2          poetry       1       5
#> 6     6   2   3 science fiction       1       6

# facet_grid(): rows by one variable and columns by another.
grid_plot <- base + facet_grid(rows = vars(genre), cols = vars(period))
grid_layout <- ggplot_build(grid_plot)$layout$layout
head(grid_layout[c("PANEL", "ROW", "COL", "genre", "period")], 4L)
#>   PANEL ROW COL   genre period
#> 1     1   1   1 classic   2025
#> 2     2   1   2 classic   2026
#> 3     3   2   1 fiction   2025
#> 4     4   2   2 fiction   2026

# Faceting is done by the data: every panel is a subset of the layer data.
points <- ggplot(shelf, aes(pages, duration)) +
  geom_point() +
  facet_wrap(~genre)
table(layer_data(points)$PANEL)
#>
#>  1  2  3  4  5  6
#>  5 18  4  5  5 13
table(shelf$genre)
#>
#>         classic         fiction         mystery     non-fiction          poetry
#>               5              18               4               5               5
#> science fiction
#>              13

# labeller changes the strip text: label_both adds the variable name.
labelled <- base + facet_wrap(vars(genre), labeller = label_both)
ggsave(tempfile(fileext = ".svg"), labelled, width = 7, height = 5)
# as_labeller() turns a named vector (or a function) into a labeller.
pretty_names <- as_labeller(c(`science fiction` = "Sci-fi", fiction = "Fiction",
                              classic = "Classic", mystery = "Mystery",
                              `non-fiction` = "Non-fiction", poetry = "Poetry"))
renamed <- base + facet_wrap(vars(genre), labeller = pretty_names)
ggsave(tempfile(fileext = ".svg"), renamed, width = 7, height = 5)
class(renamed$facet)[[1L]]
#> [1] "FacetWrap"

The layout is a data frame with one row per panel: its position (ROW, COL), the value of each faceting variable, and which x and y scale it uses (SCALE_X, SCALE_Y). With scales = "free_y" each panel has its own SCALE_Y, from 1 to 6, and with the default "fixed" they would all be 1. facet_grid() crosses the two variables: six genres by two periods gives twelve panels, and the layout lists them in order, with the rows first. The faceting is done on the data: the number of rows of a point layer in each panel is the number of loans of that genre, which agrees with table(shelf$genre). The last chunk saves two faceted plots with strip labels from label_both and from as_labeller(). The functions are documented at ggplot2 package documentation: facet_grid, ggplot2 package documentation: labellers, ggplot2 package documentation: as_labeller and ggplot2 package documentation: vars. A faceted plot of different charts (a histogram next to a scatter plot) is not what facets are for: they repeat one chart over subsets. Different charts are combined with another package, see Combining different plots.

Themes

The theme controls everything that is not data: text, lines, backgrounds, spacing and the position of the legend. There are two layers to it. A complete theme, theme_gray() (the default, also spelt theme_grey()), theme_bw(), theme_minimal(), theme_classic(), theme_light(), theme_dark(), theme_linedraw() and theme_void(), sets every element at once and takes base_size and base_family; in 4.0 it also takes ink, paper, accent and header_family. A partial theme made by theme() sets only the elements named, and is added to the plot like any other component. The elements form a tree (axis.text.x inherits from axis.text, which inherits from text), and each is built by an element function: element_text() for text, element_line() for lines, element_rect() for rectangles, and element_blank() for "draw nothing", which is how a part of the plot is removed. The ggplot2 documentation describes this hierarchy in the reference pages for ggplot2 package documentation: theme, ggplot2 package documentation: complete themes and ggplot2 package documentation: theme elements.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))
p <- ggplot(known, aes(pages, year, colour = genre)) + geom_point()

# A complete theme sets every element; theme_gray() is the default.
themes <- list(gray = theme_gray(), minimal = theme_minimal(),
               bw = theme_bw(), classic = theme_classic())
vapply(themes, \(t) t@complete, logical(1L))
#>    gray minimal      bw classic
#>    TRUE    TRUE    TRUE    TRUE
identical(get_theme(), theme_gray())
#> [1] TRUE

# base_size scales all the text of a complete theme.
theme_gray(base_size = 18)$text@size
#> [1] 18

# theme() makes a partial theme: only the elements named are replaced.
tweaks <- theme(
  axis.text.x = element_text(angle = 45, hjust = 1),
  legend.position = "bottom",
  plot.title = element_text(face = "bold")
)
tweaks@complete
#> [1] FALSE
names(tweaks)
#> [1] "axis.text.x"     "legend.position" "plot.title"
tweaks$plot.title@face
#> [1] "bold"
tweaked <- p + labs(title = "Pages and year") + tweaks
tweaked$theme$legend.position
#> [1] "bottom"

# element_blank() removes an element; the element tree decides what it covers.
plain <- p + theme(panel.grid = element_blank())
class(plain$theme$panel.grid)[[1L]]
#> [1] "ggplot2::element_blank"

# theme_set() replaces the default for the session and returns the old one;
# theme_update() changes single elements of the current default.
old <- theme_set(theme_minimal())
get_theme()@complete
#> [1] TRUE
theme_update(legend.position = "bottom")
get_theme()$legend.position
#> [1] "bottom"
invisible(theme_set(old))
get_theme()$legend.position
#> [1] "right"

# ggplot2 4.0: the theme carries the layers' defaults, and ink and paper.
bigger <- p + theme(geom = element_geom(pointsize = 3))
unique(layer_data(bigger)$size)
#> [1] 3
red <- ggplot(known, aes(pages, year)) +
  geom_point() +
  theme_minimal(ink = "red")
unique(layer_data(red)$colour)
#> [1] "red"

# 4.0 palette theme elements give scales without palette= their palettes.
six <- c("black", "red", "blue", "green", "orange", "purple")
palette_plot <- p + theme(palette.colour.discrete = six)
sort(unique(layer_data(palette_plot)$colour))
#> [1] "black"  "blue"   "green"  "orange" "purple" "red"

The four complete themes are all complete (@complete is TRUE), and the active default is identical to theme_gray(). theme() returns an incomplete theme, so @complete is FALSE; it holds exactly the three elements the script named (axis.text.x rotated, legend.position at the bottom, plot.title in bold face). element_blank() stores an object of class "ggplot2::element_blank" in the place of the element. theme_set() replaces the active theme for the rest of the session and returns the previous one, which is what lets a script restore it, and theme_update() modifies single elements of the active theme; in 4.0 their new names are set_theme() and update_theme() and get_theme() replaces theme_get(), and the old names keep working (ggplot2 package documentation: get_theme). A global theme is convenient and has the cost that every plot in the session, including the ones drawn by other packages, now changes with it. The last two chunks of the script are 4.0 features. The first is theme(geom = element_geom(...)): the theme now carries the defaults of the layers, so pointsize = 3 made every point layer draw size 3 points, and the ink argument of a complete theme drove the default colour of the points to red. The second is the palette.colour.discrete element, which supplies the colours of the default discrete colour scale. Both are listed in the 4.0.0 NEWS as new theme() arguments (geom, palette.{aes}.discrete and palette.{aes}.continuous).

Guides

Guides are what scales draw to be read: axes for position scales, legends and colour bars for the others. The guides are built from the trained scales, and since ggplot2 3.5.0 they are ggproto objects styled through the theme (the 3.5.0 NEWS lists the rewrite of the whole guide system as its main breaking change). Two aesthetics mapped from the same variable merge into a single legend; a different variable makes a separate one; a continuous colour or fill gets a colour bar. The function guides() switches a guide off (guides(size = "none"); the old FALSE is deprecated since 3.3.4) or replaces it by a restyled one: guide_legend(ncol = 2) arranges the keys in two columns and guide_colourbar(reverse = TRUE) reverses the bar. The legend position is a theme setting, theme(legend.position = ) with "right", "left", "top", "bottom", "none" or "inside"; a numeric vector as the position was deprecated in 3.5.0 in favour of "inside" with legend.position.inside. The reference pages are ggplot2 package documentation: guides, ggplot2 package documentation: guide_legend and ggplot2 package documentation: guide_colourbar.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))

# Guides (legends and colour bars) are built from the trained scales.
# Count the guides of a built plot and name their classes.
guides_of <- \(p) {
  g <- ggplot_build(p)$plot$guides$guides
  unname(vapply(g, \(x) class(x)[[1L]], character(1L)))
}
one <- ggplot(known, aes(pages, year, colour = genre)) + geom_point()
guides_of(one)
#> [1] "GuideLegend"

# The same variable on two aesthetics: the legends merge into one.
merged <- ggplot(known, aes(pages, year, colour = genre, shape = genre)) +
  geom_point()
guides_of(merged)
#> [1] "GuideLegend"

# Two variables: two legends. A continuous colour gets a colour bar.
two <- ggplot(known, aes(pages, year, colour = genre, size = pages)) +
  geom_point()
guides_of(two)
#> [1] "GuideLegend" "GuideLegend"
bar <- ggplot(known, aes(pages, year, colour = year)) + geom_point()
guides_of(bar)
#> [1] "GuideColourbar"

# guides() switches a legend off or restyles it.
guides_of(two + guides(size = "none"))
#> [1] "GuideLegend"
columns <- two + guides(colour = guide_legend(ncol = 2))
columns$guides$guides$colour$params$ncol
#> [1] 2
reversed <- bar + guides(colour = guide_colourbar(reverse = TRUE))
class(reversed$guides$guides$colour)[[1L]]
#> [1] "GuideColourbar"

# Where the legends go is a theme setting.
bottom <- one + theme(legend.position = "bottom")
bottom$theme$legend.position
#> [1] "bottom"
inside <- one + theme(
  legend.position = "inside",
  legend.position.inside = c(0.15, 0.75)
)
inside$theme$legend.position.inside
#> [1] 0.15 0.75

The first four plots show the merging rule: one variable on one aesthetic gives one legend; the same variable on colour and shape still gives one; a second variable (size = pages) gives a second legend; a continuous colour gives GuideColourbar. guides(size = "none") removes the second guide, and guide_legend(ncol = 2) is stored with the guide’s parameters. The class names printed here are the ggproto classes of 3.5.0 and later; they are what an extension author subclasses.

Saving plots: ggsave() and devices

ggsave() is the function to save a plot to a file. Its reference page says that it defaults to saving the last plot that was displayed, uses the size of the current device unless a size is given, and guesses the type of device from the extension of the file name. The arguments that matter are filename, plot, device, path, width, height, units ("in", "cm", "mm" or "px"), dpi (default 300; a number, or a string such as "print"), scale, bg and, since 3.5.0, create.dir. Always give a plot and a size in a script: the defaults refer to the last plot displayed and the size of whichever device happens to be current, which depends on the session, and ggplot2 says so with the message Saving 7 x 7 in image when the size had to be taken from the device. The return value is the file name, invisibly, since ggplot2 3.3.4. In 4.0.0 the plot argument can also be a list of plots, saved as the pages of a PDF.

The device is chosen by the extension, or by device, which is a name or a device function. For vector output ggplot2 maps "svg" to svglite::svglite() and checks that the svglite package is installed (it is a suggested, not a required, dependency: 3.5.1 made ggsave() offer to install it, and the NEWS entry that began using it is in 2.1.0); the package is documented at svglite package documentation: svglite. The base R device svg() is the Cairo one and is still available by passing the function (device = svg), as the script shows. For PNG, JPEG and TIFF, ggplot2 uses the ragg package if it is installed and the base devices otherwise (the NEWS entry is in 3.3.4), which is why a PNG can look different on two machines. For PDF, "pdf" is the default and cairo_pdf (a device function, with device = cairo_pdf) embeds fonts through Cairo. The base R devices are documented at R documentation: svg and cairo_pdf, R documentation: png and R documentation: pdf; the page Base Graphics and Devices explains the device model. An alternative that does not use ggsave() is to open a device yourself, print the plot and close the device, as the script does with svg(); print() returns the plot invisibly, so it can sit inside a function.

library(ggplot2)
source("scenario.R")
known <- subset(books, !is.na(pages) & !is.na(year))
p <- ggplot(known, aes(pages, year)) + geom_point()

# The device is chosen from the file extension; "svg" means svglite.
svg_file <- tempfile(fileext = ".svg")
saved <- ggsave(svg_file, p, width = 6, height = 4)
identical(saved, svg_file) # the file name comes back invisibly
#> [1] TRUE
file.exists(svg_file)
#> [1] TRUE
readLines(svg_file, n = 1L, warn = FALSE)
#> [1] "<?xml version='1.0' encoding='UTF-8' ?>"
any(grepl("class='svglite'", readLines(svg_file, warn = FALSE), fixed = TRUE))
#> [1] TRUE

# A device function can be passed instead: here grDevices::svg() (cairo).
cairo_file <- tempfile(fileext = ".svg")
ggsave(cairo_file, p, width = 6, height = 4, device = svg)
any(grepl("class='svglite'", readLines(cairo_file, warn = FALSE), fixed = TRUE))
#> [1] FALSE
file.exists(cairo_file)
#> [1] TRUE

# width, height, units and dpi decide the size; a PNG records its pixels.
png_size <- \(file) {
  header <- readBin(file, "raw", 24L)
  as.integer(c(
    sum(as.integer(header[17:20]) * 256^(3:0)),
    sum(as.integer(header[21:24]) * 256^(3:0))
  ))
}
png_file <- tempfile(fileext = ".png")
ggsave(png_file, p, width = 6, height = 4, units = "in", dpi = 100)
png_size(png_file)
#> [1] 600 400
ggsave(png_file, p, width = 12.7, height = 7.62, units = "cm", dpi = 100)
png_size(png_file)
#> [1] 500 300

# PDF: the default device is pdf(); cairo_pdf embeds fonts differently.
pdf_file <- tempfile(fileext = ".pdf")
ggsave(pdf_file, p, width = 6, height = 4, device = cairo_pdf)
rawToChar(readBin(pdf_file, "raw", 5L))
#> [1] "%PDF-"

# Without a size, ggsave() takes the size of the current device and says so.
msgs <- character()
withCallingHandlers(
  ggsave(tempfile(fileext = ".svg"), p),
  message = \(m) {
    msgs <<- c(msgs, conditionMessage(m))
    invokeRestart("muffleMessage")
  }
)
msgs
#> [1] "Saving 7 x 7 in image"

# The base route: open a device yourself, print the plot, close the device.
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
shown <- print(p)
invisible(dev.off())
identical(shown, p) # print() returns the plot itself, invisibly
#> [1] TRUE

# An unknown extension is an error, not a guess.
tryCatch(
  ggsave(tempfile(fileext = ".xyz"), p, width = 6, height = 4),
  error = \(e) conditionMessage(e)
)
#> [1] "Unknown graphics device \"xyz\""

# last plot: get_last_plot() (last_plot() before 4.0.0) is the default `plot`.
identical(get_last_plot(), p)
#> [1] TRUE

The script writes SVG with svglite (the file starts with the XML declaration and contains the group marker svglite), then with the Cairo device (no marker), then two PNG files whose dimensions are read back from the file header: 6 by 4 inches at 100 dpi is 600 by 400 pixels, and 12.7 by 7.62 centimetres (5 by 3 inches) is 500 by 300. The PDF check reads the first bytes, %PDF-. The Saving 7 x 7 in image message is the one produced when no size is given. An extension that ggplot2 does not know is an error with the text Unknown graphics device "xyz", not a guess, and get_last_plot() (called last_plot() before 4.0) returns the plot that was last created or modified, which is what a ggsave() call without plot saves. FAQ 7.19 describes producing PNG graphics in batch mode (R FAQ 7.19: How do I produce PNG graphics in batch mode?), which is the situation of a script that runs under Rscript.

Under the plot: grid and gtable

A rendered ggplot is not a bitmap or a special ggplot2 drawing language: it is a gtable, a table of grid graphical objects, drawn by the grid package that ships with R. ggplotGrob(p) returns that table, and so does ggplot_gtable(ggplot_build(p)). The table has a row and column structure (the script prints 16 rows by 13 columns), a layout data frame that says which cell each grob occupies and under which name (panel, axis-l, axis-b, title, the legend boxes), and heights and widths in grid units. Each cell holds a grid grob; the panel is a gTree whose children are the pieces of the layers (here points, with grid lines and borders that are empty zeroGrob placeholders). Because it is a grob, it can be drawn on any device with grid.draw() after grid.newpage(), edited cell by cell with the gtable functions, or combined with other grobs. The model of viewports, units and grobs is the subject of Grid Graphics and Lattice; the structure and the way that the graphics engine drives it are described in the R Internals manual (R Internals: Grid graphics).

library(ggplot2)
source("scenario.R")
known <- subset(books, !is.na(pages) & !is.na(year))
p <- ggplot(known, aes(pages, year)) + geom_point() + labs(title = "Pages")

# ggplotGrob() builds the plot and returns the gtable: a grid grob that is a
# table of other grobs. A device is needed to measure the text.
svg(tempfile(fileext = ".svg"))
g <- ggplotGrob(p)
class(g)
#> [1] "gtable" "gTree"  "grob"   "gDesc"
dim(g)
#> [1] 16 13
g$layout[g$layout$name %in% c("panel", "axis-l", "axis-b", "title"),
         c("t", "l", "b", "r", "name")]
#>     t l  b r   name
#> 2   9 6  9 6 axis-l
#> 5   9 7  9 7  panel
#> 6  10 7 10 7 axis-b
#> 20  3 7  3 7  title

# Each cell holds a grid grob; the panel is a gTree of the layers' grobs.
panel <- g$grobs[[which(g$layout$name == "panel")]]
class(panel)[1:2]
#> [1] "gTree" "grob"
unname(vapply(panel$children, \(x) class(x)[[1L]], character(1L)))
#> [1] "gTree"    "zeroGrob" "points"   "zeroGrob" "zeroGrob"

# Widths and heights are grid units, so the table can be edited and drawn.
class(g$widths)
#> [1] "unit"    "unit_v2"
length(g$heights)
#> [1] 16
invisible(dev.off())

# Drawing is grid's job: grid.newpage() then grid.draw() on any device.
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
grid::grid.newpage()
grid::grid.draw(g)
invisible(dev.off())

The grid functions used are R documentation: grid.draw and R documentation: grid.newpage; the grob and gTree classes are in R documentation: grob and gTree, and the units in R documentation: unit. The gtable package itself is described at gtable package documentation.

ggplot2 4.0 on S7

The script asks a plot object what it is, in the terms of The S7 Object System.

library(ggplot2)
source("scenario.R")
known <- subset(books, !is.na(pages) & !is.na(year))
p <- ggplot(known, aes(pages, year)) + geom_point()

# Since ggplot2 4.0.0 a plot is an S7 object, not an S3 list.
class(p)
#> [1] "ggplot2::ggplot" "ggplot"          "ggplot2::gg"     "S7_object"
#> [5] "gg"
S7::S7_inherits(p, class_ggplot)
#> [1] TRUE
S7::S7_class(p)@name
#> [1] "ggplot"
S7::prop_names(p)
#>  [1] "data"        "layers"      "scales"      "guides"      "mapping"
#>  [6] "theme"       "coordinates" "facet"       "layout"      "labels"
#> [11] "meta"        "plot_env"
is.list(p)
#> [1] FALSE

# $ and [[ still read (and write) the properties; @ is the S7 spelling.
identical(p$layers, p@layers)
#> [1] TRUE
identical(p[["mapping"]], p@mapping)
#> [1] TRUE
p$data <- subset(known, year > 2000)
nrow(p@data)
#> [1] 10

# Test with is_ggplot(); is.ggplot() is soft-deprecated since ggplot2 3.5.2.
is_ggplot(p)
#> [1] TRUE
tryCatch(is.ggplot(p), warning = \(w) conditionMessage(w))
#> [1] "`is.ggplot()` was deprecated in ggplot2 3.5.2.\nℹ Please use `is_ggplot()` instead."

# What is S7 and what is still ggproto (or plain S3)?
parts <- list(
  plot = p, built = ggplot_build(p), theme = theme_gray(),
  element = element_text(), mapping = aes(x), labels = labs(title = "x"),
  layer = geom_point(), geom = GeomPoint, stat = StatBin,
  coord = coord_cartesian(), facet = facet_wrap(~genre),
  scale = scale_x_continuous(), guide = guide_legend()
)
data.frame(
  S7 = vapply(parts, S7::S7_inherits, logical(1L)),
  ggproto = vapply(parts, is_ggproto, logical(1L))
)
#>            S7 ggproto
#> plot     TRUE   FALSE
#> built    TRUE   FALSE
#> theme    TRUE   FALSE
#> element  TRUE   FALSE
#> mapping  TRUE   FALSE
#> labels   TRUE   FALSE
#> layer   FALSE    TRUE
#> geom    FALSE    TRUE
#> stat    FALSE    TRUE
#> coord   FALSE    TRUE
#> facet   FALSE    TRUE
#> scale   FALSE    TRUE
#> guide   FALSE    TRUE

# Adding to a plot goes through an S7 generic, update_ggplot().
class(update_ggplot)
#> [1] "S7_generic" "function"   "S7_object"

ggplot2 4.0.0, the first release of the 4.x series, lists among its breaking changes that "the S3 parts of ggplot2 have been replaced with S7 bits" (ggplot2 package documentation: NEWS), and the output above shows what that means for the objects you meet: a plot is an instance of the S7 class ggplot2::ggplot (S7 0.2.2 is an import of ggplot2 4.0.3), its class vector still contains the old S3 name "ggplot" so that inherits(p, "ggplot") keeps working, and its twelve properties (data, layers, scales, guides, mapping, theme, coordinates, facet, layout, labels, meta, plot_env) can be read with @ or S7::prop(), and with $ and [[, which ggplot2 still supports, so p$layers, p$mapping and p$data <- value from tutorials keep working; the plot is no longer a list (is.list(p) is FALSE), names(p) returns NULL, and S7::prop_names(p) is the replacement. The built plot (ggplot_built), the theme, every theme element, the mapping made by aes() and the labels made by labs() are S7 objects too, which is why @complete worked on a theme in the section on themes; the layer, the geoms, the stats, the positions, the coordinate systems, the facets, the scales and the guides remain ggproto objects, as the table of the script shows, so the extension mechanism for them did not change (see the next section). For code that tests what it has been given, the family is_ggplot(), is_theme(), is_layer(), is_geom() and the others was added in 3.5.2 and is.ggplot() is soft-deprecated as of that version (the script prints the warning); S7::S7_inherits(x, class_ggplot) is what is_ggplot() calls. The generic through which + adds a component is an S7 generic in 4.0, update_ggplot(), and ggplot2’s own documentation describes the older ggplot_add() as vestigial. S7 itself is described by its authors as somewhat experimental (see the S7 page), so code that depends on the property names and the documented functions is safer than code that depends on how the object prints. The release notes of every version are at ggplot2 package documentation: NEWS.

Extending ggplot2 with ggproto

Everything that draws, computes or lays out in a plot is a ggproto object: Geom, Stat, Position, Coord, Facet, Scale and Guide are the base classes, and GeomPoint, StatBin and so on are their instances, as the matrix in the layers section showed. ggproto is ggplot2’s own object system with mutable objects and inheritance that works across packages, designed for this purpose, and the article on extending ggplot2 gives its history and builds a stat, a geom, a facet and a guide with it (ggplot2 package documentation: Extending ggplot2). The reference page for ggproto() documents the constructor (ggplot2 package documentation: ggproto). The script defines the smallest useful extension: a stat that replaces each group’s rows by the group’s median point, and a constructor function that wraps layer() in the form that package code exports. The nolint block is there because ggproto objects are named in CamelCase and the layer constructors use the dotted argument names (na.rm, show.legend, inherit.aes) that every ggplot2 function uses.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))

# A stat is a ggproto object: compute_group() gets one group's data frame
# and returns a new data frame.
# nolint start: object_name_linter.
StatMedianPoint <- ggproto("StatMedianPoint", Stat,
  required_aes = c("x", "y"),
  compute_group = function(data, scales) {
    data.frame(x = median(data$x), y = median(data$y))
  }
)

# The user-facing function wraps layer(); it is what package code exports.
# The argument names are ggplot2's own conventions, hence the nolint block.
stat_median_point <- function(mapping = NULL, data = NULL, geom = "point",
                              position = "identity", ...,
                              na.rm = FALSE, show.legend = NA,
                              inherit.aes = TRUE) {
  layer(
    stat = StatMedianPoint, data = data, mapping = mapping, geom = geom,
    position = position, show.legend = show.legend,
    inherit.aes = inherit.aes, params = list(na.rm = na.rm, ...)
  )
}

# nolint end

p <- ggplot(known, aes(pages, year, colour = genre)) +
  geom_point(alpha = 0.3) +
  stat_median_point(size = 4)
class(StatMedianPoint)
#> [1] "StatMedianPoint" "Stat"            "ggproto"         "gg"
is_ggproto(StatMedianPoint)
#> [1] TRUE
medians <- layer_data(p, 2L)
medians[order(medians$group), c("x", "y", "group")]
#>       x    y group
#> 1 356.0 1906     1
#> 2 214.0 2021     2
#> 3 272.5 2017     3
#> 4 263.5 2016     4
#> 5  88.0 2005     5
#> 6 387.0 2014     6
ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 4)

The layer data of the second layer has six rows, one median point per genre, with the group number that the colour mapping created. This is the whole mechanism: a ggproto class with compute_group(), a layer constructor, and the usual +. A geom is the same, with draw_panel() or draw_group(). Only an extension that adds a theme element or manipulates the plot object itself meets the S7 classes; the ones that add geoms, stats, positions, coordinates, facets and scales work with ggproto. Packages that extend ggplot2 (patchwork and cowplot among them) are named in the section on combining different plots and are not installed here.

The shelf charts rebuilt in ggplot2

The page on base graphics draws four charts of the shelf scenario: loans per month, the distribution of loan duration, duration by genre and pages against duration. They are rebuilt here as ggplot2 plots, one script each. The data preparation is the same in every one: loans has the loan and return dates; a loan returned before it was lent (a negative duration) and a loan not yet returned (a missing return date) have no usable duration; the books are joined on the ISBN after the hyphens are stripped, as in Data Frames. The plots are saved to temporary files and the scripts print the data that each layer will draw, which is what a test of a chart would check.

Loans per month

geom_col() draws the count per month, and the x axis is a date scale. A Date is a number of days to the scale, so the bars are width = 25 days wide, centred on the first of the month. scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") puts a tick every three months with the label format of format() for dates (Dates and Times lists the codes). The loan with the impossible date 2025-02-30 became NA when loans was read and is dropped by aggregate(), so the count for February 2025 is two.

library(ggplot2)
source("scenario.R")

# Count the loans per month. The loan on 2025-02-30 has no valid date: NA.
loans$month <- as.Date(format(loans$loan_date, "%Y-%m-01"))
per_month <- aggregate(loan_id ~ month, data = loans, FUN = length)
names(per_month)[[2L]] <- "loans"
head(per_month, 3)
#>        month loans
#> 1 2025-01-01     4
#> 2 2025-02-01     2
#> 3 2025-03-01     3
class(per_month$month)
#> [1] "Date"

p <- ggplot(per_month, aes(month, loans)) +
  geom_col(width = 25) +
  scale_x_date(date_breaks = "3 months", date_labels = "%b %Y") +
  labs(title = "Loans per month", x = NULL, y = "Loans")

# A Date is a number to the scale: bars are 25 days wide around the 1st.
head(layer_data(p)[c("x", "xmin", "xmax", "y")], 3)
#>       x    xmin    xmax y
#> 1 20089 20076.5 20101.5 4
#> 2 20120 20107.5 20132.5 2
#> 3 20148 20135.5 20160.5 3
x_scale <- ggplot_build(p)$layout$panel_scales_x[[1L]]
as.Date(x_scale$get_limits(), origin = "1970-01-01")
#> [1] "2024-12-19" "2026-05-13"
breaks <- x_scale$get_breaks()
format(as.Date(breaks[2:6], origin = "1970-01-01"), "%b %Y")
#> [1] "Mar 2025" "Jun 2025" "Sep 2025" "Dec 2025" "Mar 2026"
ggsave(tempfile(fileext = ".svg"), p, width = 7, height = 3.5)

The breaks of a date scale are numbers in the built plot, days since 1970-01-01, which as.Date(origin = ) turns back into dates: the limits are 2024-12-19 and 2026-05-13 (the edges of the first and last bars), and the first full three-month break inside them falls on 2025-03-01 (the break before the first bar is outside the limits and is not drawn).

Loan duration

A histogram of the days between loan and return, in bins of one week, with a dashed vertical line at the 21-day loan period (loan_days from the scenario). The histogram is StatBin and the line is geom_vline(xintercept = loan_days), whose xintercept is not mapped from the data but set once.

library(ggplot2)
source("scenario.R")

# Days between loan and return; unreturned loans (NA) and the loan returned
# before it was lent (negative) are left out.
loans$duration <- as.integer(loans$return_date - loans$loan_date)
returned <- subset(loans, !is.na(duration) & duration >= 0)
nrow(returned)
#> [1] 51

p <- ggplot(returned, aes(duration)) +
  geom_histogram(binwidth = 7, boundary = 0, fill = "grey70",
                 colour = "white") +
  geom_vline(xintercept = loan_days, linetype = "dashed") +
  labs(title = "Loan duration", x = "Days", y = "Loans")

# The plot has two layers: the bars and the reference line.
vapply(p$layers, \(l) class(l$geom)[[1L]], character(1L))
#> geom_histogram     geom_vline
#>      "GeomBar"    "GeomVline"
head(layer_data(p, 1L)[c("x", "xmin", "xmax", "count")], 4)
#>      x xmin xmax count
#> 1  3.5    0    7     4
#> 2 10.5    7   14     9
#> 3 17.5   14   21    13
#> 4 24.5   21   28    19
layer_data(p, 2L)[c("xintercept", "linetype")]
#>   xintercept linetype
#> 1         21   dashed
sum(layer_data(p, 1L)$count[layer_data(p, 1L)$xmin >= loan_days])
#> [1] 25
sum(returned$duration > loan_days)
#> [1] 25
ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 3.5)

The two layers print as GeomBar (a histogram is a bar geom) and GeomVline. The 25 loans in the bins that start at or after day 21 are the 25 loans kept more than 21 days: the check at the end compares the bins with a direct count and the two agree, because the bins are aligned to 21 with boundary = 0 and binwidth = 7. The data of the second layer holds the single row that draws the reference line (xintercept 21, linetype dashed). The reference page for the reference lines is ggplot2 package documentation: geom_abline, geom_hline and geom_vline.

Duration by genre

A box plot per genre, horizontal because the continuous variable is mapped to x and the genre to y. The genres are ordered by median duration with reorder() (a factor re-levelled by a summary of another variable), so the plot reads as a ranking, and the same dashed line marks the loan period.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# Order the genres by median duration, so the boxes read as a ranking.
shelf$genre <- reorder(shelf$genre, shelf$duration, FUN = median)
levels(shelf$genre)
#> [1] "mystery"         "fiction"         "science fiction" "non-fiction"
#> [5] "classic"         "poetry"

p <- ggplot(shelf, aes(duration, genre)) +
  geom_boxplot() +
  geom_vline(xintercept = loan_days, linetype = "dashed") +
  labs(title = "Loan duration by genre", x = "Days", y = NULL)

box <- layer_data(p, 1L)
box <- box[order(box$y), ]
box[c("y", "xmin", "xlower", "xmiddle", "xupper", "xmax")]
#>   y xmin xlower xmiddle xupper xmax
#> 1 1   10     10    18.5  29.25   36
#> 2 2    1     11    20.0  27.25   37
#> 3 3    8     16    20.0  27.00   36
#> 4 4   16     17    22.0  24.00   27
#> 5 5   21     21    23.0  24.00   28
#> 6 6   21     21    24.0  25.00   26
lengths(box$outliers)
#> [1] 0 0 0 0 1 1
ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 3.5)

y in the box layer is the position of the genre in the new order, so the first row is "mystery", the genre with the lowest median (18.5 days), and the last is poetry (24). The last line counts the outliers per box: none for the four boxes at the top and one each for the classic and poetry boxes, which are the two with the narrowest quartile ranges. reorder() is documented at R documentation: reorder.

Pages against duration

A scatter plot of the number of pages of a book against the days it was kept, with a straight line fitted by lm. The formula is given, so no message is printed. The script then checks that the line is the same as the one from lm() itself, using the base R functions of Statistical Models.

library(ggplot2)
source("scenario.R")
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0 & !is.na(pages))
nrow(shelf)
#> [1] 42

p <- ggplot(shelf, aes(pages, duration)) +
  geom_point(alpha = 0.6) +
  geom_smooth(method = "lm", formula = y ~ x) +
  labs(title = "Pages and loan duration", x = "Pages", y = "Days")

# geom_smooth(method = "lm") draws the lm() fit and a confidence band.
fit <- lm(duration ~ pages, data = shelf)
round(coef(fit), 4)
#> (Intercept)       pages
#>     17.8693      0.0068
curve <- layer_data(p, 2L)
head(round(curve[c("x", "y", "ymin", "ymax")], 2), 3)
#>       x     y  ymin  ymax
#> 1 88.00 18.47 13.03 23.90
#> 2 92.35 18.50 13.16 23.84
#> 3 96.71 18.53 13.28 23.77
newdata <- data.frame(pages = curve$x[1:3])
round(unname(predict(fit, newdata)), 2)
#> [1] 18.47 18.50 18.53
ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 3.5)

The coefficients are an intercept of about 17.9 days and a slope of 0.0068 days per page: a 300-page book is kept about two days longer than a 10-page one, and the data are far too few to say whether that is more than chance. The first three rows of the smooth layer match the predict() values to two decimals, which shows that geom_smooth(method = "lm") is a convenience over lm() and predict(), with the band computed from the standard errors of the fit.

Assembling charts

The charts that share data and a mapping are assembled with facets. The script below does that for pages against duration, one panel per genre with scales = "free_x", and for the overdue share per genre as a bar chart. The table of shares is the one place where a dplyr pipeline is used; the base R equivalent (aggregate() with a function and an extra argument) follows it and gives the same numbers.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
shelf <- merge(loans, books[c("isbn", "genre", "pages")], by = "isbn")
shelf$duration <- as.integer(shelf$return_date - shelf$loan_date)
shelf <- subset(shelf, !is.na(duration) & duration >= 0)

# One dplyr pipeline for the data preparation: overdue rate by genre.
by_genre <- shelf |>
  dplyr::group_by(genre) |>
  dplyr::summarise(
    loans = dplyr::n(),
    mean_days = round(mean(duration), 1),
    overdue = round(mean(duration > loan_days), 2)
  ) |>
  dplyr::arrange(dplyr::desc(overdue))
as.data.frame(by_genre)
#>             genre loans mean_days overdue
#> 1         classic     5      21.4    0.60
#> 2     non-fiction     5      21.2    0.60
#> 3          poetry     5      20.0    0.60
#> 4         mystery     4      20.8    0.50
#> 5         fiction    18      19.3    0.44
#> 6 science fiction    13      20.5    0.38

# The same table in base R, for comparison.
rate <- \(d, days) round(mean(d > days), 2)
base_rate <- aggregate(duration ~ genre, shelf, rate, days = loan_days)
base_rate[order(-base_rate$duration), ]
#>             genre duration
#> 1         classic     0.60
#> 4     non-fiction     0.60
#> 5          poetry     0.60
#> 3         mystery     0.50
#> 2         fiction     0.44
#> 6 science fiction     0.38

p_rate <- ggplot(by_genre, aes(overdue, reorder(genre, overdue))) +
  geom_col() +
  labs(title = "Share of loans kept longer than 21 days", x = NULL, y = NULL)

# One chart, one panel per genre: facets assemble charts that share data.
with_pages <- subset(shelf, !is.na(pages))
p_panels <- ggplot(with_pages, aes(pages, duration)) +
  geom_point() +
  geom_hline(yintercept = loan_days, linetype = "dashed") +
  facet_wrap(~genre, scales = "free_x") +
  labs(title = "Pages and duration by genre", x = "Pages", y = "Days")

layout <- ggplot_build(p_panels)$layout$layout
nrow(layout)
#> [1] 6
table(layer_data(p_panels, 1L)$PANEL)
#>
#>  1  2  3  4  5  6
#>  5 13  4  2  5 13
ggsave(tempfile(fileext = ".svg"), p_rate, width = 6, height = 3.5)
ggsave(tempfile(fileext = ".svg"), p_panels, width = 8, height = 5)

The dplyr pipeline is called with dplyr:: prefixes so that no package is attached and no function is masked, and |> is the native pipe of Pipes and the Native Pipe. The two tables agree: the classic, non-fiction and poetry books are kept more than 21 days in 60 per cent of the returned loans, science fiction in 38 per cent. These shares count only the loans that came back, because the script keeps the loans with a known, non-negative duration; the overdue rates on The tidyverse and dplyr and data.table also count the loans still out on the reference day, and are therefore different (0.67 for poetry and 0.47 for science fiction there). The faceted plot has six panels, with the number of points per panel in the table at the end of the script (the loans of each genre that have a page count). For charts that are different from each other (a bar chart next to a scatter plot next to a histogram), facets are the wrong tool, and the usual answer is a package that arranges several plots on one page.

Combining different plots

The patchwork package arranges ggplot objects with the operators +, | and / (p1 | p2 puts two plots side by side), cowplot offers plot_grid() and publication themes, and gridExtra has grid.arrange(); all three are community packages that work on the grid layer, and none is installed in the environment used for this page, so they are not run here. The grid layer is also available directly: ggplotGrob() returns the gtable of each plot, and grid.layout() and viewports (see Grid Graphics and Lattice) place them. The base graphics equivalent, par(mfrow = ) and layout(), does not work for ggplot objects, because they are drawn with grid and not with the graphics package.

Messages, warnings and errors

ggplot2 reports problems with the cli package: the first line says what went wrong, and lines that start with a small i in a circle (information), a cross (a failed requirement) or a bullet add the detail. A warning or message that comes from a layer names the layer in brackets. Messages, and the warnings of stats, are raised while the plot is built; the warning of a geom about removed rows is raised when the plot is drawn. The script runs fourteen small plots through a helper that builds and saves each one and prints everything it said, so that the real text of the 4.0.3 messages is on this page. The table that follows lists the common ones without the backticks that the real messages put around function names, which the output above shows verbatim.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))

# Run a plot through build and draw, and report what it says.
report <- \(label, p) {
  seen <- character()
  withCallingHandlers(
    tryCatch(
      ggsave(tempfile(fileext = ".svg"), p, width = 6, height = 4),
      error = \(e) seen <<- c(seen, paste("error:", conditionMessage(e)))
    ),
    message = \(m) {
      seen <<- c(seen, paste("message:", conditionMessage(m)))
      invokeRestart("muffleMessage")
    },
    warning = \(w) {
      seen <<- c(seen, paste("warning:", conditionMessage(w)))
      invokeRestart("muffleWarning")
    }
  )
  cat("--", label, "\n", paste(seen, collapse = "\n"), "\n", sep = "")
}

report("default bins", ggplot(known, aes(pages)) + geom_histogram())
#> --default bins
#> message: `stat_bin()` using `bins = 30`. Pick better value `binwidth`.
report("missing values in a stat", ggplot(books, aes(pages)) +
         geom_histogram(binwidth = 50))
#> --missing values in a stat
#> warning: Removed 2 rows containing non-finite outside the scale range (`stat_bin()`).
report("default formula", ggplot(known, aes(pages, year)) +
         geom_smooth(method = "lm"))
#> --default formula
#> message: `geom_smooth()` using formula = 'y ~ x'
report("unknown aesthetic", ggplot(known, aes(pages, year)) +
         geom_point(aes(foo = genre)))
#> --unknown aesthetic
#> warning: Ignoring unknown aesthetics: foo
report("unknown parameter", ggplot(known, aes(pages, year)) +
         geom_point(colour2 = "red"))
#> --unknown parameter
#> warning: Ignoring unknown parameters: `colour2`
report("one observation per group", ggplot(known, aes(genre, pages)) +
         geom_line(aes(group = title)))
#> --one observation per group
#> message: `geom_line()`: Each group consists of only one observation.
#> ℹ Do you need to adjust the group aesthetic?
report("too many shapes", ggplot(known, aes(pages, year, shape = title)) +
         geom_point())
#> --too many shapes
#> warning: The shape palette can deal with a maximum of 6 discrete values because more
#> than 6 becomes difficult to discriminate
#> ℹ you have requested 13 values. Consider specifying shapes manually if you need
#>   that many of them.
#> warning: Removed 7 rows containing missing values or values outside the scale range
#> (`geom_point()`).
report("missing aesthetic", ggplot(known) + geom_point())
#> --missing aesthetic
#> error: Problem while setting up geom.
#> ℹ Error occurred in the 1st layer.
#> Caused by error in `compute_geom_1()`:
#> ! `geom_point()` requires the following missing aesthetics: x and y.
report("bins need numbers", ggplot(known, aes(genre)) + geom_histogram())
#> --bins need numbers
#> error: Problem while computing stat.
#> ℹ Error occurred in the 1st layer.
#> Caused by error in `setup_params()`:
#> ! `stat_bin()` requires a continuous x aesthetic.
#> ✖ the x aesthetic is discrete.
#> ℹ Perhaps you want `stat="count"`?
report("bar with y", ggplot(known, aes(genre, pages)) + geom_bar())
#> --bar with y
#> error: Problem while computing stat.
#> ℹ Error occurred in the 1st layer.
#> Caused by error in `setup_params()`:
#> ! `stat_count()` must only have an x or y aesthetic.
report("col without y", ggplot(known, aes(genre)) + geom_col())
#> --col without y
#> error: Problem while setting up geom.
#> ℹ Error occurred in the 1st layer.
#> Caused by error in `compute_geom_1()`:
#> ! `geom_col()` requires the following missing aesthetics: y.
report("wrong scale", ggplot(known, aes(pages, year, colour = genre)) +
         geom_point() + scale_colour_gradient())
#> --wrong scale
#> error: Discrete value supplied to a continuous scale.
#> ℹ Example values: "classic" and "science fiction".
report("unknown column", ggplot(known, aes(pages, nosuchcolumn)) +
         geom_point())
#> --unknown column
#> error: Problem while computing aesthetics.
#> ℹ Error occurred in the 1st layer.
#> Caused by error:
#> ! object 'nosuchcolumn' not found
report("facet variable", ggplot(known, aes(pages, year)) +
         geom_point() + facet_wrap(~nosuchcolumn))
#> --facet variable
#> error: At least one layer must contain all faceting variables: `nosuchcolumn`
#> ✖ Plot is missing `nosuchcolumn`
#> ✖ Layer 1 is missing `nosuchcolumn`
Output Meaning What to do

stat_bin() using bins = 30. Pick better value binwidth.

A histogram without binwidth or bins uses 30 bins.

Give binwidth (and boundary or center) or bins.

geom_smooth() using formula = 'y ~ x'

geom_smooth() was not given a formula and chose one.

Give formula = y ~ x (and method) to say so, which also silences the message.

Removed 3 rows containing missing values or values outside the scale range

Rows with a missing value in a mapped aesthetic, or outside the scale limits, are not drawn. The layer is named in the next line.

Look at the data (summary(), colSums(is.na(books))); if the limits were the cause, use coord_cartesian() to zoom.

Removed 2 rows containing non-finite outside the scale range

The same, raised by a stat (stat_bin(), stat_smooth(), stat_summary()) before the geom.

As above.

Ignoring unknown aesthetics: foo

An aesthetic in aes() that the geom does not understand.

Check the spelling against GeomPoint$aesthetics() and the specification article.

Ignoring unknown parameters: colour2

An argument of the layer that the geom, the stat and the position all do not accept.

The same; size on geom_bar() appears here since 4.0.

geom_line(): Each group consists of only one observation.

No two rows share the group, so there is no line to draw.

Set aes(group = ) to the variable that identifies a series.

The shape palette can deal with a maximum of 6 discrete values

shape mapped to a variable with more than six values.

Map a variable with fewer values, or give the shapes with scale_shape_manual().

geom_point() requires the following missing aesthetics: x and y (an error)

The layer has no x or no y: there is no mapping on the plot or on the layer.

Add aes(x, y).

stat_bin() requires a continuous x aesthetic (an error)

A histogram of a factor or character variable.

Use geom_bar() (the message suggests stat = "count").

stat_count() must only have an x or y aesthetic (an error)

geom_bar() with both x and y.

geom_col() for heights that are given.

geom_col() requires the following missing aesthetics: y (an error)

geom_col() with only x.

geom_bar() for counts.

Discrete value supplied to a continuous scale (an error)

A character or factor variable meets a continuous scale.

Match the scale to the variable, or convert the variable.

object 'nosuchcolumn' not found (an error, in Problem while computing aesthetics)

The expression in aes() found nothing in the data or in the environment.

Check the name; inside a function use {{ }} or .data.

At least one layer must contain all faceting variables (an error)

A facet variable is missing from the data.

Name an existing column.

Pitfalls

  • A plot that is never printed is never drawn. In source(), in a function and in a loop the automatic printing does not happen, so call print(p) or ggsave(). This is R FAQ 7.22, and it names ggplot2 graphics (R FAQ 7.22: Why do lattice/trellis graphics not work?). Under Rscript, drawing without opening a device writes Rplots.pdf into the working directory.

  • A plus at the start of a line is a new statement. End the line with the plus, not the next one. The error is Cannot use + with a single argument.

  • Mapping is not setting. aes(colour = "red") maps a constant and adds a legend; colour = "red" outside aes() sets it.

  • xlim() and limits drop data; coord_cartesian() zooms. They give different fits and different counts of removed rows.

  • geom_bar() counts, geom_col() draws heights. Giving geom_bar() a y and geom_col() no y are the two errors in the table of messages.

  • Order of a discrete axis is the order of the factor levels (alphabetical for a character vector). Reorder with a factor, or with reorder() as the genre chart does (Factors).

  • Randomness in geom_jitter() and position_jitter() changes the plot at each draw. Give the position a seed to make a plot reproducible.

  • Columns in aes() of package code need .data. Otherwise R CMD check reports "no visible binding for global variable" (see the evaluation page). Inside aes(), df$col is discouraged and says so: Use of known$pages is discouraged. Use pages instead.

  • A global theme_set() changes every later plot in the session. Keep the previous value that it returns and restore it, or pass the theme to each plot.

  • Lifecycle warnings are normally shown once per session. A script that runs once shows each deprecation once; a long interactive session may have seen it earlier and show nothing.

Version notes

The entries are the ones the ggplot2 NEWS file lists (ggplot2 package documentation: NEWS) except where the table says that the version comes from the text of a message.

Version Change

2.1.0

ggsave("x.svg") uses svglite to produce the SVG.

3.0.0

aes() supports quasiquotation (!!, !!!, :=), and aes_() and aes_string() are soft-deprecated; facet_wrap() and facet_grid() accept vars(); stat() is introduced for computed variables, replacing aes(y = ..count..) (the message of aes_string() says 3.0.0).

3.3.0

after_stat() and after_scale() (with stage()) control when an aesthetic is evaluated, superseding stat() and the dot-dot notation; geoms and stats with a direction choose it from the mapping or the orientation argument instead of coord_flip(); guide_axis(); expand_scale() is deprecated in favour of expansion(); the fun.y argument of stat_summary() is deprecated (the message says 3.3.0).

3.3.4

ggsave() returns the file name invisibly and uses ragg for raster output when it is installed; guides(<scale> = FALSE) is deprecated in favour of "none".

3.4.0

The linewidth aesthetic supersedes size for lines; the dot-dot notation and stat() are formally deprecated; qplot() is formally deprecated; messages are rewritten with cli; vctrs is used internally.

3.5.0

The guide system is rewritten on ggproto and styled through the theme; coord_flip() is superseded; the trans argument of scales is renamed transform; coord_radial() succeeds coord_polar(); a numeric legend.position is deprecated in favour of "inside"; ggsave() gains create.dir.

3.5.1

ggsave() offers to install svglite when it is needed.

3.5.2

The test functions is_ggplot(), is_layer(), is_geom() and the others; get_labs() and get_geom_defaults(); is.ggplot() is soft-deprecated (the message says 3.5.2).

4.0.0

The S3 parts of ggplot2 are replaced with S7; theme(geom = element_geom()), from_theme() and the palette.* theme elements; the get_*, set_* and update_* names for the theme and layer helpers (the old names remain); coord_trans() is renamed coord_transform(); geom_errorbarh() and the fortify() methods for models are deprecated; functions deprecated before 3.0.0 are errors and those soft-deprecated up to 3.4.0 are warnings; default labels are derived during the build; ggsave() can write a multi-page PDF from a list of plots; %+% on a plot is deprecated (the message says 4.0.0).

4.0.1

Fixes for regressions of 4.0.0 (scales, geom_text() labels, stat_bin(boundary)), and logical linetype values.

4.0.2

A hotfix for compatibility between rlang versions.

4.0.3

geom_boxplot() and stat_boxplot() gain quantile.type; fixes for guide_axis_theta() and for a change in rlang.

Where older tutorials differ

Tutorials written before ggplot2 3.4.0 (2022) contain idioms that now warn or fail, and some that only look dated. The script below runs the deprecated ones under a helper that prints the warning instead of raising it, so the message each one gives today is on record; the idioms in the last block are old but still fine.

library(ggplot2)
source("scenario.R")
books$genre <- tolower(trimws(books$genre))
known <- subset(books, !is.na(pages) & !is.na(year))

# Old idioms still run, mostly; this script only shows what they now say. The
# calls are wrapped so that the warning text is printed instead of raised.
old <- \(label, expr) {
  seen <- character()
  withCallingHandlers(
    expr,
    warning = \(w) {
      seen <<- c(seen, conditionMessage(w))
      invokeRestart("muffleWarning")
    }
  )
  cat("--", label, "\n", paste(seen, collapse = "\n"), "\n", sep = "")
}

old("qplot()", ggplot_build(qplot(pages, year, data = known)))
#> --qplot()
#> `qplot()` was deprecated in ggplot2 3.4.0.
old("..count..", ggplot_build(
  ggplot(known, aes(pages, ..count..)) + geom_histogram(binwidth = 50) # nolint
))
#> --..count..
#> The dot-dot notation (`..count..`) was deprecated in ggplot2 3.4.0.
#> ℹ Please use `after_stat(count)` instead.
old("stat(count)", ggplot_build(
  ggplot(known, aes(pages, stat(count))) + geom_histogram(binwidth = 50)
))
#> --stat(count)
#> `stat(count)` was deprecated in ggplot2 3.4.0.
#> ℹ Please use `after_stat(count)` instead.
old("aes_string()", ggplot_build(
  ggplot(known, aes_string("pages", "year")) + geom_point()
))
#> --aes_string()
#> `aes_string()` was deprecated in ggplot2 3.0.0.
#> ℹ Please use tidy evaluation idioms with `aes()`.
#> ℹ See also `vignette("ggplot2-in-packages")` for more information.
old("aes(known$pages)", ggplot_build(
  ggplot(known, aes(known$pages)) + geom_histogram(bins = 4) # nolint
))
#> --aes(known$pages)
#> Use of `known$pages` is discouraged.
#> ℹ Use `pages` instead.
old("stat_summary(fun.y =)", ggplot_build(
  ggplot(known, aes(genre, pages)) + stat_summary(fun.y = mean, geom = "point")
))
#> --stat_summary(fun.y =)
#> The `fun.y` argument of `stat_summary()` is deprecated as of ggplot2 3.3.0.
#> ℹ Please use the `fun` argument instead.
old("geom_line(size =)", ggplot_build(
  ggplot(known, aes(year, pages)) + geom_line(size = 1)
))
#> --geom_line(size =)
#> Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
#> ℹ Please use `linewidth` instead.
old("geom_bar(size =)", ggplot_build(
  ggplot(known, aes(genre)) + geom_bar(size = 2)
))
#> --geom_bar(size =)
#> Ignoring unknown parameters: `size`
old("guides(colour = FALSE)", ggplot_build(
  ggplot(known, aes(pages, year, colour = genre)) +
    geom_point() + guides(colour = FALSE)
))
#> --guides(colour = FALSE)
#> The `<scale>` argument of `guides()` cannot be `FALSE`. Use "none" instead as
#> of ggplot2 3.3.4.
old("geom_errorbarh()", ggplot_build(
  ggplot(known, aes(y = genre, xmin = pages - 1, xmax = pages + 1)) +
    geom_errorbarh()
))
#> --geom_errorbarh()
#> `geom_errorbarh()` was deprecated in ggplot2 4.0.0.
#> ℹ Please use the `orientation` argument of `geom_errorbar()` instead.
old("coord_trans()", coord_trans(y = "log10"))
#> --coord_trans()
#> `coord_trans()` was deprecated in ggplot2 4.0.0.
#> ℹ Please use `coord_transform()` instead.
old("expand_scale()", expand_scale(mult = 0.1))
#> --expand_scale()
#> `expand_scale()` was deprecated in ggplot2 3.3.0.
#> ℹ Please use `expansion()` instead.
old("fortify(<lm>)", fortify(lm(pages ~ year, known)))
#> --fortify(<lm>)
#> `fortify(<lm>)` was deprecated in ggplot2 4.0.0.
#> ℹ Please use `broom::augment(<lm>)` instead.
old("geom_violin(draw_quantiles =)", ggplot_build(
  ggplot(known, aes(genre, pages)) + geom_violin(draw_quantiles = 0.5)
))
#> --geom_violin(draw_quantiles =)
#> The `draw_quantiles` argument of `geom_violin()` is deprecated as of ggplot2
#> 4.0.0.
#> ℹ Please use the `quantiles.linetype` argument instead.
#> Groups with fewer than two datapoints have been dropped.
#> ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.

old("%+%", {
  p <- ggplot(known, aes(pages, year)) + geom_point()
  q <- p %+% subset(known, genre == "fiction")
  print(c(nrow(p$data), nrow(q$data)))
})
#> [1] 13  3
#> --%+%
#> <ggplot> %+% x was deprecated in ggplot2 4.0.0.
#> ℹ Please use <ggplot> + x instead.

# Things that are old but not wrong: no warning, and the same picture.
old("geom_bar(stat = 'identity')", {
  counts <- as.data.frame(table(genre = known$genre), responseName = "n")
  a <- ggplot(counts, aes(genre, n)) + geom_bar(stat = "identity")
  b <- ggplot(counts, aes(genre, n)) + geom_col()
  print(identical(layer_data(a)$ymax, layer_data(b)$ymax))
})
#> [1] TRUE
#> --geom_bar(stat = 'identity')
#>
old("scale_x_continuous(trans =)", ggplot_build(
  ggplot(known, aes(pages, year)) + geom_point() +
    scale_x_continuous(trans = "log10")
))
#> --scale_x_continuous(trans =)
#>
old("scale_colour_discrete(name =)", ggplot_build(
  ggplot(known, aes(pages, year, colour = genre)) + geom_point() +
    scale_colour_discrete(name = "Genre")
))
#> --scale_colour_discrete(name =)
#>
old("theme_get() and last_plot()", {
  invisible(theme_get())
  invisible(last_plot())
})
#> --theme_get() and last_plot()
#>
Older tutorial writes Current practice Why Since

qplot(pages, year, data = books)

ggplot(books, aes(pages, year)) + geom_point()

qplot() is deprecated and says so

3.4.0

aes(y = ..count..) or aes(y = stat(count))

aes(y = after_stat(count))

the dot-dot notation and stat() are formally deprecated

3.3.0 (new), 3.4.0 (deprecated)

aes_string("pages", "year") and aes_()

aes(.data[[col]]), or aes({{ var }}) in a function

quasiquotation in aes(); the old functions warn

3.0.0

aes(known$pages)

aes(pages)

a column of the plot data is named, not extracted; ggplot2 says "Use of known$pages is discouraged"

always

stat_summary(fun.y = mean)

stat_summary(fun = mean) (and fun.min, fun.max for fun.ymin, fun.ymax)

the argument was renamed and the old one warns

3.3.0

geom_line(size = 1)

geom_line(linewidth = 1)

size is only for points and text; for lines it is deprecated; geom_bar(size = ) is now ignored with a message

3.4.0 (4.0.0)

geom_bar(stat = "identity")

geom_col()

the same layer under a name that says what it does

always

coord_flip() for horizontal bars and boxes

put the categorical variable on y, or use orientation

geoms choose their direction; coord_flip() is superseded

3.3.0 (3.5.0)

scale_x_continuous(trans = "log10")

scale_x_continuous(transform = "log10") or scale_x_log10()

the argument was renamed and trans is deprecated (4.0.3 still accepts it without a message)

3.5.0

scale_colour_discrete(name = "Genre")

labs(colour = "Genre")

both work; labs() keeps all titles in one place

always

guides(colour = FALSE), scale_*(guide = FALSE)

guides(colour = "none")

FALSE is deprecated

3.3.4

theme(legend.position = c(0.9, 0.2))

theme(legend.position = "inside", legend.position.inside = c(0.9, 0.2))

a numeric position is deprecated; 4.0.3 printed no message in the run above

3.5.0

theme_get(), theme_set(), theme_update(), last_plot(), layer_data()

get_theme(), set_theme(), update_theme(), get_last_plot(), get_layer_data()

new names for consistency; the old ones work and do not warn

4.0.0

p %+% newdata

build the plot again with the data you want, or give the layer a data argument

%+% on a plot is deprecated

4.0.0

is.ggplot(p)

is_ggplot(p)

the new test family; the old one warns

3.5.2

coord_trans(y = "log10")

coord_transform(y = "log10")

renamed

4.0.0

geom_errorbarh()

geom_errorbar(orientation = "y")

deprecated

4.0.0

expand_scale(mult = 0.1)

expansion(mult = 0.1)

deprecated

3.3.0

fortify(lm_fit) to plot a model

broom::augment(lm_fit) and broom::tidy(lm_fit)

the fortify() methods for models are deprecated

4.0.0

geom_violin(draw_quantiles = 0.5)

the quantiles parameter of stat_ydensity() and the quantiles.linetype argument named by the message

the quantiles are now computed from the data, not inferred from the density

4.0.0

theme_update() described as replacing elements

theme_update() merges with the plus operator; theme_replace() keeps the replacing behaviour

the NEWS for 2.1.0 changed theme_update() so that unspecified values are no longer set to NULL

2.1.0

theme(axis.text = element_text(size = 12)) in theme_set()

theme_gray(base_size = 12) to scale everything

base_size scales the whole theme in proportion

always

Two books are the community references for ggplot2: ggplot2: Elegant Graphics for Data Analysis (Wickham, Navarro and Pedersen, third edition online and from Springer) and R for Data Science (Wickham, Cetinkaya-Rundel and Grolemund, second edition); both are widely used community references, and neither is reproduced here. Older editions and blog posts are the usual source of the first column of the table.

Where to go next

References