ggplot2 4.0.0
ggplot2 4.0.0
Photo by
Jonas Weckschmied
2025/09/11
ggplot2
Teun van den Brand
We’re tickled pink to announce the release of
ggplot2
4.0.0. ggplot2 is a system for declaratively creating graphics, based on The Grammar of Graphics. You provide the data, tell ggplot2 how to map variables to aesthetics, what graphical primitives to use, and it takes care of the details.
The new version can be installed from CRAN using:
install.packages
"ggplot2"
This is a substantial release meriting a new major version, and contains a series of changes from a rewrite of the object oriented system from S3 to S7, large new features to smaller quality of life improvements and bugfixes. It is also the 18th anniversary of ggplot2 which is cause for celebration! In this blog post, we will highlight the most salient new features that come with this release. You can see a full list of changes in the
release notes
library
ggplot2
library
patchwork
Adopting S7
In ggplot2, we use major version increments to indicate that something at the core of the package has changed. In this release, we have replaced many of ggplot2’s S3 objects with S7 objects. Like S3 and S4, S7 is also an object oriented system that uses classes, generics and methods. S7 is a newer system that aims to strike a good balance between the flexibility of S3 and formality of S4.
Mostly, this change shouldn’t be very noticeable when you’re just using ggplot2 for building regular plots. At best, you may notice that we’re more strictly enforcing types for certain arguments. For example, most ludicrous input is now rejected right away. This is due to how properties in S7 work, which get validated when a new object is instantiated.
element_text
hjust
"foo"
#> Error: object properties are invalid:
#> - @hjust must be , , or , not
However, it may require some adaptation on your end if you use ggplot2’s innards in unusual ways. For extension builders, a major benefit of using S7 is that one can now use double dispatch. This is most important for the
update_ggplot()
function (the successor of
ggplot_add()
), which determines what happens when you
an object to a plot. Now with S7, you can control what happens not only for right-hand side objects (which is how it used to work in S3), but also for the left-hand side objects.
We have put various pieces of backwards compatibility in to not break many packages that assumed the S3 structures of ggplot2. For example, we still return the data property with
ggplot()$data
, whereas the S7 way of accessing this should be
ggplot()@data
. Expect these to be phased out over time in favour of S7. We are preparing another blog post to help migrating from S3 to S7 for ggplot2 related packages.
Theme improvements
Themes in ggplot2 have long served the role of capturing any non-data aspects of styling plots. We have come to realise that the default look of layers, from what the default shape of points is to what the default colour palette is, are also not truly data-driven choices. The idea to put these defaults into themes has been around for a while and Dana Page Seidel did pioneering work implementing this as early as 2018. Now, years of waiting have come to fruition and we’re proud to announce this new functionality.
Ink and paper
The way layer defaults are now implemented differs slightly from typical aesthetics you know and love. Whereas layers aesthetics distinguish
colour
and
fill
, the theme defaults distinguish
ink
(foreground) and
paper
(background). A boxplot is unreadable without
colour
, but is perfectly interpretable without
fill
. In the boxplot case, the
ink
is thus clearly the
colour
whereas
paper
is the
fill
. In bar charts or histograms, the
proportional ink
principle prescribes that the
fill
aesthetic is considered foreground, and thus count as
ink
. To accommodate special cases, like lines in
geom_smooth()
or
geom_contour()
, we also added a third
accent
option. In short, the theme defaults have role-oriented settings that differ from the property-oriented settings in layers.
We’ve added these three options to all built-in complete themes. Not only propagate these automatically to the layer defaults, they are also used to style additional theme components. You may notice that the panel background colour is a blend between
paper
and
ink
, which is now how many elements are parametrised in complete themes.
ggplot
mpg
aes
displ
hwy
geom_point
geom_smooth
method
"lm"
, formula
theme_gray
paper
"cornsilk"
, ink
"navy"
, accent
"tomato"
If you’re customising a theme, you can use the
theme(geom)
argument to set a collection of defaults. The new function
element_geom()
can be used to set these properties. Additionally, if you want a layer to read the property from this theme element, you can use the
from_theme()
function in the mapping to access these variables
ggplot
mpg
aes
class
displ
geom_boxplot
aes
colour
from_theme
accent
theme
geom
element_geom
accent
"tomato"
, paper
"cornsilk"
A second conceptual difference in
element_geom()
pertains the the use of lines. In one role, like in a line graph, the line represents the data directly. In a second role, a line serves as separation between two units. For example, you can display countries as polygons and the line connecting the vertices separate out places that are inside a country versus places that are outside that country. These two roles are captured in a
linewidth
and
linetype
pair and a
borderwidth
and
bordertype
pair.
ggplot
faithful
aes
waiting
geom_histogram
bins
30
, colour
"black"
geom_freqpoly
bins
30
theme
geom
element_geom
bordertype
"dashed"
borderwidth
0.2
linewidth
linetype
"solid"
Scales and palettes
In addition to the defaults for layers, default palettes are now also encapsulated in the theme. The relevant theme settings have the pattern
palette.{aesthetic}.{type}
, where
type
can be either discrete or continuous. This allows you to coordinate your colour palettes with the rest of the theme.
ggplot
mpg
aes
displ
hwy
, shape
drv
, colour
cty
geom_point
theme
palette.colour.continuous
"chartreuse"
"forestgreen"
palette.shape.discrete
"triangle"
"triangle open"
"triangle down open"
The way this works is that all defaults scales now have
palette = NULL
as their default. During plot building, any
NULL
palettes are replaced by those declared in the theme.
Shortcuts
We like to introduce a new family of short cuts. Looking at code in the wild, we’ve come to realise that theme declarations are very often chaotic. The
theme()
functions has lots of arguments, long argument names (hello there,
axis.minor.ticks.length.x.bottom
!) and very little structure. To make themes a little bit more digestible, we’ve created the following helper functions:
theme_sub_axis()
theme_sub_axis_x()
theme_sub_axis_bottom()
theme_sub_axis_top()
theme_sub_axis_y()
theme_sub_axis_left()
theme_sub_axis_right()
theme_sub_legend()
theme_sub_panel()
theme_sub_plot()
theme_sub_strip()
These helper functions pass on their arguments to
theme()
after they’ve prepended a relevant prefix. For example, using
theme_sub_legend(justification)
will translate to
theme(legend.justification)
. When you have >1 theme element to change in a cluster of settings, it quickly becomes less typing to enlist the relevant shortcut. As a bonus, your theme code will tend to self-organise and become somewhat more readable.
# Tired, verbose, chaotic
theme
panel.widths
unit
"cm"
axis.ticks.x
element_line
colour
"red"
axis.ticks.length.x
unit
"mm"
panel.background
element_rect
fill
NA
panel.spacing.x
unit
"mm"
# Wired, terse, orderly
theme_sub_axis_x
ticks
element_line
colour
"red"
ticks.length
unit
"mm"
theme_sub_panel
widths
unit
"cm"
spacing.x
unit
"mm"
background
element_rect
fill
NA
In addition to shortcuts for clusters of theme elements, we’ve also added a few variants to declare margins.
margin_auto()
sets the margins in a CSS-like fashion similar to the
margin
and
padding
property.
margin_auto(1)
sets all four sides at once. It expands to
margin(t = 1, r = 1, b = 1, l = 1)
margin_auto(1, 2)
sets horizontal and vertical sides. It expands to
margin(t = 1, r = 2, b = 1, l = 2)
margin_auto(1, 2, 3)
expands to
margin(t = 1, r = 2, b = 3, l = 2)
margin_part()
has
NA
units as default, which will get replaced when the theme gets resolved. It roughly equates to ‘set some of the sides, keep others as they are’.
merge_element
margin_part
20
# child
margin_auto
10
# parent
#> [1] 10points 20points 10points 10points
New settings
To coordinate (non-text) margins and spacings in a theme, we’ve introduced
spacing
and
margins
as new root elements in the theme. Other spacings and margins at the leaf elements inherit from (scale with) these root elements. To facilitate the different spacings in ggplot2, unit elements can now use
rel()
to modify the inherited value. For example the default
axis.ticks.length
is now
rel(0.5)
, making the y-axis ticks 0.5 cm in the plot below. If we set the
axis.ticks.length.x
to
rel(2)
, it will double the value coming from
axis.ticks.length
, not double the value of
spacing
<-
ggplot
penguins
aes
bill_dep
bill_len
, colour
species
geom_point
na.rm
TRUE
theme
spacing
unit
"cm"
margins
margin_auto
, unit
"cm"
axis.ticks.length.x
rel
We also made it easier to set plot sizes. Using the
panel.widths
and
panel.heights
arguments, you can control the sizes of the panels. This mechanism is distinct from using
ggsave(width, height)
, where the whole plot, including annotations such as axes and titles is included. There are two ways to use these arguments:
Give a vector of units: each one will be applied to a panel separately and the vector will be recycled to fit the number of panels.
Give a single unit: which sets the total panel area (including panel spacings and inner axes) to that size.
Naturally, if you only have a single panel, these approaches are identical. If you have multiple panels and you want to set individual panels all to the same size (as opposed to the total size), you can take advantage of the recycling and use a length 2 unit vector.
In the plots below, you can notice that the panels span a different width despite the units adding up to the same amount (9 cm). This is because the ‘single unit’ approach also includes the panel spacings, but not the ‘separate units’ approach.
p1
<-
facet_grid
island
labs
title
"Separate units (per panel)"
# Using the new shortcut for panels
theme_sub_panel
widths
unit
"cm"
heights
unit
"cm"
p2
<-
facet_grid
island
labs
title
"Single unit (all panels)"
theme_sub_panel
widths
unit
"cm"
heights
unit
"cm"
p1
p2
Labels
We have added new ways that a plot retrieves labels for your variables. It is an informal convention in several packages including gt, Hmisc, labelled and others to use the ‘label’ attribute to store human readable labels for vectors. Now ggplot2 joins this convention and uses the ‘label’ attribute as the default label for a variable if present.
# The penguins dataset was incorporated into base R 4.5
df
<-
penguins
# Manually set label attributes.
# Other packages may offer better tooling than this.
attr
df
species
"label"
<-
"Penguin Species"
attr
df
bill_dep
"label"
<-
"Bill depth (mm)"
attr
df
bill_len
"label"
<-
"Bill length (mm)"
attr
df
body_mass
"label"
<-
"Body mass (g)"
ggplot
df
aes
bill_dep
bill_len
, colour
sqrt
body_mass
geom_point
na.rm
TRUE
It has also been entrenched in some workflows to use a ‘data dictionary’ or codebook. For labelling purposes these dictionaries often contain column metadata that include labels or descriptions for variables (columns) in the dataset. To make it easier to work with column labels, we added the
labs(dictionary)
argument. It takes a named vector of labels, that can easily be generated from a data dictionary by
setNames()
or
dplyr::pull()
dict
<-
tibble
::
tribble
var
label
"species"
"Penguin Species"
"bill_dep"
"Bill depth (mm)"
"bill_len"
"Bill length (mm)"
"body_mass"
"Body mass (g)"
ggplot
penguins
aes
bill_dep
bill_len
, colour
body_mass
geom_point
na.rm
TRUE
# Or:
# labs(dictionary = dplyr::pull(dict, label, name = var))
labs
dictionary
setNames
dict
label
dict
var
One benefit to the label attributes or data dictionary approaches is that it is linked to your variables, not aesthetics. This means you can easily rearrange your aesthetics for a different plot, without having to painstakingly reorient the labels towards the correct aesthetics.
last_plot
aes
body_mass
bill_len
, colour
species
There are a few caveats to these label attributes and data dictionary approaches though:
If the aesthetic is not a pure variable name the label is not used. You can see this in the
sqrt(body_mass)
in the first example, which does not use the ‘Body mass (g)’ label. We assume when a variable is adjusted in this way, this would need to be reflected in the label itself. It would therefore be inappropriate to use the label of the unadjusted variable. Use of the
.data
-pronoun
counts as a pure variable name for labelling purposes.
Some attributes are more stable than others, and it is not ggplot2’s responsibility to babysit attributes. For example using
head()
will typically drop attributes from atomic columns, whereas
head()
will not.
In addition, we’re also allowing to use functions in all the places you can declare labels. The
labs()
function, scale names and guide titles now accept functions that take in the labels generated by the lower hierarchies and return amended labels. It should be spelled out that the hierarchy from lowest priority to highest priority is the following:
The expression given in
aes()
The entry in
labs(dictionary)
The label attribute of the column.
The entry in
labs( =

\code{geom_dummy()} understands the following aesthetics. Required aesthetics are displayed in bold and defaults are displayed for optional aesthetics:
\tabular{rll}{
• \tab \code{foo} \tab → \code{"bar"} \cr
• \tab \code{\link[ggplot2:aes_group_order]{group}} \tab → inferred \cr

Learn more about setting these aesthetics in \code{vignette("ggplot2-specs")}.
Themes
To replicate how themes are handled internally, you can now use
complete_theme()
. It fills in all missing elements and performs typical checks.
my_theme
<-
theme
plot.background
element_rect
fill
NA
length
my_theme
#> [1] 1
completed
<-
complete_theme
my_theme
length
completed
#> [1] 144
# You should give rect elements to text settings
completed
<-
theme
legend.text
element_rect
|>
complete_theme
#>
Error
in `plot_theme()`:
#>
Can't merge the `legend.text` theme element.
#>
Caused by error in `method(merge_element, list(ggplot2::element, class_any))`:
#>
Only elements of the same class can be merged.
# Unknown elements
completed
<-
theme
foobar
12
|>
complete_theme
#> Warning in plot_theme(list(theme = theme), default = default): The `foobar` theme element is not defined in the element hierarchy.
We’re also introducing point and polygon theme elements. These aren’t used in any of the base ggplot2 theme settings, but you can use them in extensions. The example below demonstrates registering new theme settings and that points and polygons follow inheritance and can be rendered.
# Let's say your package 'my_pkg' registers custom point/polygon elements
register_theme_elements
my_pkg_point
element_point
colour
"red"
my_pkg_polygon
element_polygon
fill
NA
element_tree
list
my_pkg_point
el_def
element_point
, inherit
"point"
my_pkg_polygon
el_def
element_polygon
, inherit
"polygon"
# Which should inherit from the root point/polygon theme elements
my_theme
<-
theme
point
element_point
shape
17
polygon
element_polygon
linetype
"dotted"
|>
complete_theme
# Rendering your elements
pts
<-
calc_element
"my_pkg_point"
my_theme
|>
element_grob
0.2
0.5
0.8
0.8
0.2
0.5
poly
<-
calc_element
"my_pkg_polygon"
my_theme
|>
element_grob
0.1
0.5
0.9
0.9
0.1
0.5
# Drawing the elements
grid
::
grid.newpage
grid
::
grid.draw
pts
grid
::
grid.draw
poly
Acknowledgements
Thank you to all the people who contributed their issues, code and comments to this release:
@83221n4ndr34
@Abiologist
@acebulsk
@adisarid
@agila5
@agmurray
@agneeshbarua
@aijordan
@amarjitsinghchandhial
@amkilpatrick
@amongoodtx
@Andtise
@andybeet
@antoine4ucsd
@aphalo
@aravind-j
@arcresu
@arnaudgallou
@assaron
@baderstine
@BajczA475
@bakaburg1
@BegoniaCampos
@benjaminhlina
@billdenney
@binkleym
@bkohrn
@bnprks
@botanize
@Breeze-Hu
@brianmsm
@brunomioto
@btupper
@bwu62
@carljpearson
@catalamarti
@cbrnr
@ccani007
@ccsarapas
@cgoo4
@clauswilke
@Close-your-eyes
@collinberke
@const-ae
@dafxy
@DanChaltiel
@danli349
@dansmith01
@daorui
@david-romano
@davidhodge931
@dinosquash
@dominicroye
@dsconnell
@EA-Ammar
@EBukin
@elgabbas
@eliocamp
@elipousson
@erinnacland
@etiennebacher
@EvaMaeRey
@evanmascitti
@eyayaw
@fabian-s
@fkohrt
@FloLecorvaisier
@fmarotta
@Fugwaaaa
@fwunschel
@g-pacheco
@gaborcsardi
@gregorp
@guqicun
@hadley
@heinonmatti
@heor-robyoung
@herry23xet
@HMU-WH
@HRodenhizer
@hsiaoyi0504
@Hy4m
@IndrajeetPatil
@jack-davison
@JacobBumgarner
@JakubKomarek
@jansim
@japhir
@jbengler
@jdonland
@jeraldnoble
@Jigyasu4indp
@jiw181
@jmbuhr
@JMeyer31
@jmgirard
@jnolis
@joaopedrosusselbertogna
@johow
@jonocarroll
@jpquast
@JStorey42
@JThomasWatson
@julianbarg
@julou
@junjunlab
@JWiley
@kauedesousa
@kdarras
@kevinushey
@kevinwolz
@kieran-mace
@kkellysci
@kobetst
@koheiw
@krlmlr
@KryeKuzhinieri
@kylebutts
@laurabrianna
@lbenz730
@lcpmgh
@lgaborini
@lgibson7
@LGraz
@llrs
@louis-heraut
@ltierney
@Lucielclr
@luhann
@m-muecke
@marcelglueck
@margaret-colville
@markus-schaffer
@Maschette
@MathiasAmbuehl
@MathieuYeche
@mattansb
@MauricioCely
@MaxAtoms
@mcol
@mfoos
@MichaelChirico
@MichelineCampbell
@mikmart
@misea
@mjskay
@mkoohafkan
@mlaparie
@MLopez-Ibanez
@mluerig
@mohammad-numan
@MoREpro
@mtrsl
@muschellij2
@mzavattaro
@nicholasdavies
@njspix
@nmercadeb
@noejn2
@npearlmu
@Olivia-Box-Power
@olivroy
@oracle5th
@oskard95
@palderman
@PanfengZhang
@paulfajour
@PCEBrunaLab
@petrbouchal
@pgmj
@phispu
@PietrH
@pn317
@ppoyk
@pradosj
@psoldath
@py9mrg
@qli84
@randyzwitch
@raphaludwig
@RaynorJim
@rdboyes
@reechawong
@rempsyc
@rfgoldberg
@rikivillalba
@rishabh-mp3
@RodDalBen
@rogerssam
@rsh52
@rwilson8
@salim-b
@sambtalcott
@samuel-marsh
@schloerke
@schmittrjp
@sierrajohnson
@smouksassi
@stitam
@stragu
@strengejacke
@sunta3iouxos
@szkabel
@taozhou2020
@tdhock
@telenskyt
@teunbrand
@the-Hull
@thgsponer
@thomasp85
@ThomasSoeiro
@Tiggax
@tikkss
@TimTaylor
@tombishop1
@tommmmi
@totajuliusd
@trafficfan
@tungttnguyen
@tvatter
@twest820
@ujtwr
@venpopov
@vgregoire1
@victorcat4
@victorfeagins
@vivekJax
@wbvguo
@willgearty
@williamlai2
@withr
@wvictor14
@XdahaX
@yjunechoe
@yoshidk6
@YUCHENG-ZHAO
@Yunuuuu
@yutannihilation
@yzz32
@zhengxiaoUVic
, and
@zjwinn
Normally,
aes()
is strictly used to map data instead of setting a fixed property. We diverge from this API for pragmatic reasons, not theoretical ones.
↩︎
Aesthetics of the position adjustment are not be confused with position aesthetics. Position aesthetics like
and
are transformed by a scale, whereas aesthetics of the position adjustment like
nudge_x
and
nudge_y
are not (akin to
width
and
height
).
↩︎
Contents
The tidyverse is proudly supported by