R Intermediate Course 2026

Session 3 :: Data Visualization with ggplot2

Dr. Debasish Mukherjee, Dr. Ulrike Goebel, Dr. Ali Abdallah

Bioinformatics Core Facility CECAD

2026-09-24

Session 3 :: Data Visualization with ggplot2

Learning objectives

By the end of this session you should be able to:

  • Say why ggplot2 is worth learning on top of base R graphics.
  • Read a ggplot2 call as three parts: data, aesthetic mapping, and geoms.
  • Reproduce the everyday plots you already make in base R — scatter, bar, histogram, boxplot — in ggplot2, and see the two side by side.
  • Add a grouping variable, split into panels, and fit a line per group with a small change to the code.
  • Save a figure at a resolution suitable for a manuscript.

Why ggplot2?

The motivation

The same scatter plot, two ways

Base R

plot(Sepal.Width ~ Sepal.Length, data = iris)

ggplot2

ggplot(iris, aes(Sepal.Length, Sepal.Width)) +
  geom_point()

For a single, simple plot the two are close. The difference shows up as soon as the plot needs to grow.

Quick check — fixed colour vs mapped colour

Before we go further, compare these two base R calls:

cols <- c("#E69F00", "#0072B2", "#009E73")

# A
plot(iris$Sepal.Length, iris$Sepal.Width, col = "blue")
# B
plot(iris$Sepal.Length, iris$Sepal.Width, col = cols[iris$Species])

One of these sets a fixed colour; the other looks up a colour per observation from Species. Which is which, and what would you need to add to B to also get a legend?

A is fixed — every point is the same blue, no matter what. B is mapped — the colour depends on the data, so it varies row by row, and B is also the one that needs you to build a legend by hand in base R. ggplot2 makes exactly this distinction explicit: mappings go inside aes(), fixed values go outside it. We will put this to work right away.

Legends, panels, and styling

Three common tasks when a plot grows:

  1. Legends: base R uses legend(); ggplot2 generates guides from mappings.
  2. Panels: base R uses layouts and separate plotting calls; ggplot2 uses facets.
  3. Styling: base R shares settings through par() or helpers; ggplot2 uses themes.

We will compare these workflows using the same data.

The idea: a grammar of graphics

A ggplot2 plot is built from a few named pieces:

Piece Question it answers Example
data Which data frame? iris
aesthetic mapping (aes) Which column drives which visual property? x = Sepal.Length, colour = Species
geom How should the data be displayed? geom_point(), geom_bar()
facet Split into panels by which variable? facet_wrap(~ Species)
scale How do data values map to a visual range — position, colour, size? scale_colour_*(), scale_x_log10()
theme How does the plot look — fonts, gridlines, background — independent of the data? theme_minimal(), theme_classic()

Some geoms display individual observations; others display summaries. Layers inherit data and mappings from ggplot() unless overridden.

Anatomy of a ggplot() call

ggplot(data = iris,                                  # 1. the data
       mapping = aes(x = Sepal.Length,               # 2. map columns
                     y = Sepal.Width,                #    to visual
                     colour = Species)) +            #    properties
  geom_point() +                                     # 3. draw points
  labs(x = "Sepal length (cm)",                      # 4. labels
       y = "Sepal width (cm)")
  • data and mapping set once in ggplot() are inherited by every layer, unless a layer overrides them.
  • Use + to add layers or settings. Later drawing layers appear on top.
  • aes() is for mappings from data. A fixed value (all points blue) goes outside aes().

Feature by feature

base R on the left, ggplot2 on the right

Colour and legend from a variable

Base R — formula interface, own legend

cyl_f <- factor(mtcars$cyl)
cols  <- c("#E69F00", "#0072B2", "#009E73")
plot(mpg ~ wt, data = mtcars,
     col = cols[cyl_f], pch = 16)
legend("topright", legend = levels(cyl_f),
       col = cols, pch = 16,
       title = "cyl", bty = "n")

ggplot2 — one mapping, legend appears

ggplot(mtcars, aes(wt, mpg,
                   colour = factor(cyl))) +
  geom_point() +
  labs(colour = "cyl")

Both plots use column names for axis labels. In ggplot2, mapping colour = factor(cyl) assigns colours and creates the legend.

Bar chart of counts

Base R — tabulate first, then plot

tab <- table(mtcars$cyl)
barplot(tab,
        xlab = "Cylinders",
        ylab = "Count")

ggplot2 — counting is built in

ggplot(mtcars, aes(factor(cyl))) +
  geom_bar() +
  labs(x = "Cylinders", y = "Count")

geom_bar() counts the rows in each category for you. If you already have the numbers, use geom_col() instead.

Histogram

Define the bin boundaries once for both plots:

brks <- seq(10, 35, by = 2.5)

Base R

hist(mtcars$mpg, breaks = brks,
     xlab = "MPG", main = "")

ggplot2

ggplot(mtcars, aes(mpg)) +
  geom_histogram(breaks = brks)

Both plots use the same explicit breakpoints, so the bins are comparable.

Boxplot by group

Base R — formula interface

boxplot(mpg ~ cyl, data = mtcars,
        xlab = "Cylinders", ylab = "MPG")

ggplot2 — x and y as a mapping

ggplot(mtcars, aes(factor(cyl), mpg)) +
  geom_boxplot() +
  labs(x = "Cylinders", y = "MPG")

The base R formula mpg ~ cyl becomes the mapping aes(factor(cyl), mpg).

Small multiples: base R code

Set up the panels, use shared limits, and plot each species.

op <- par(mfrow = c(1, 3), mar = c(4, 4, 2, 1))
xr <- range(iris$Sepal.Length)
yr <- range(iris$Sepal.Width)
for (sp in levels(iris$Species)) {
  plot(Sepal.Width ~ Sepal.Length, data = iris,
       subset = Species == sp,
       xlim = xr, ylim = yr, main = sp, pch = 16)
}
par(op)

Small multiples: ggplot2 code

ggplot(iris, aes(Sepal.Length, Sepal.Width)) +
  geom_point() +
  facet_wrap(~ Species)

facet_wrap() handles the panel layout, labels, and shared scales.

Small multiples: compare the output

Base R

ggplot2

Both show one panel per species with common limits across panels.

A fitted line per group

Base R — fit and draw each line

cols  <- c("#E69F00", "#0072B2", "#009E73")
sp    <- iris$Species
plot(Sepal.Width ~ Sepal.Length, data = iris,
     col = cols[sp], pch = 16)
for (i in levels(sp)) {
  d <- iris[sp == i, ]
  abline(lm(Sepal.Width ~ Sepal.Length, d),
         col = cols[which(levels(sp) == i)])
}

ggplot2 — add a smooth layer

ggplot(iris, aes(Sepal.Length, Sepal.Width,
                 colour = Species)) +
  geom_point() +
  geom_smooth(method = "lm", se = FALSE)

geom_smooth(method = "lm") fits one line per colour group, because the colour = Species mapping already split the data — the same fit the base R loop builds by hand.

Styling and themes

Base R — share settings with par()

op <- par(cex.lab = 1.2, cex.axis = 1.1, las = 1)
plot(mpg ~ wt, data = mtcars,
     pch = 16, col = "#0072B2")

par(op)

ggplot2 — a named, reusable theme

ggplot(mtcars, aes(wt, mpg)) +
  geom_point(colour = "#0072B2") +
  theme_classic(base_size = 14)

Both systems support reusable styling. ggplot2 packages plot styling in named theme objects, such as theme_classic().

The point colour is fixed outside aes(); it is a layer setting.

A plot you can keep, not just draw

plot() writes straight to the graphics device — nothing is left afterward to reuse. A ggplot2 call instead returns an object.

p  <- ggplot(mtcars, aes(wt, mpg)) + geom_point()
p2 <- p + facet_wrap(~ cyl)   # p2 is p, extended
p                              # p itself is unchanged
  • + returns a new object rather than changing p in place — p2 gains the facets, p does not.
  • Storing a plot in a variable lets you reuse it: display it, save several versions, or pass it into a function — build once, use for several purposes.
  • A bare p auto-prints at the console or top level of a chunk. Inside a function or loop, that does not happen automatically — call print(p) explicitly.

Save a plot object

Base R — open a device, plot, close it

png("figure.png", width = 8, height = 5,
    units = "in", res = 300)
plot(mpg ~ wt, data = mtcars)
dev.off()

ggplot2 — save the plot object p

p <- ggplot(mtcars, aes(wt, mpg)) +
  geom_point()

ggsave("figure.png", p,
       width = 8, height = 5,
       units = "in", dpi = 300)

Choose dimensions, file format, and raster resolution to match the publication’s requirements. ggsave() infers the format from the extension.

When base R is still a good choice

ggplot2 is not a replacement for every situation:

  • A quick look at a vector while you work — plot() or hist() is fast and needs no packages.
  • Base R objects with dedicated plot methods, e.g. plot(hclust_result) for a dendrogram or image() for a matrix.
  • Very custom, one-off diagrams where you want to place each element by hand.

The clearest advantages shown here are automatic legends, faceting, and reusing mappings across layers. Both systems can produce publication figures.

Summary

Three things to take away

  1. A ggplot2 plot is data + an aesthetic mapping + geoms, combined with +.
  2. Mappings coordinate colours, groups, and legends; facets split the data into panels.
  3. aes() is for values that come from the data; fixed values go outside aes().

Extend the same structure with new geoms, scales, and themes.

Session info

R version 4.4.1 (2024-06-14)
Platform: x86_64-conda-linux-gnu
Running under: Ubuntu 20.04.6 LTS

Matrix products: default
BLAS/LAPACK: /home/aabdallah/anaconda3/envs/R44/lib/libopenblasp-r0.3.30.so;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=de_DE.UTF-8        LC_COLLATE=en_US.UTF-8    
 [5] LC_MONETARY=de_DE.UTF-8    LC_MESSAGES=en_US.UTF-8   
 [7] LC_PAPER=de_DE.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=de_DE.UTF-8 LC_IDENTIFICATION=C       

time zone: Europe/Berlin
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] ggplot2_4.0.0

loaded via a namespace (and not attached):
 [1] vctrs_0.6.5        nlme_3.1-168       cli_3.6.5          knitr_1.50        
 [5] rlang_1.1.6        xfun_0.52          generics_0.1.4     S7_0.2.0          
 [9] jsonlite_2.0.0     labeling_0.4.3     glue_1.8.0         htmltools_0.5.8.1 
[13] scales_1.4.0       rmarkdown_2.29     grid_4.4.1         evaluate_1.0.4    
[17] tibble_3.3.0       fastmap_1.2.0      yaml_2.3.10        lifecycle_1.0.4   
[21] compiler_4.4.1     dplyr_1.1.4        RColorBrewer_1.1-3 pkgconfig_2.0.3   
[25] mgcv_1.9-3         lattice_0.22-7     farver_2.1.2       digest_0.6.37     
[29] R6_2.6.1           tidyselect_1.2.1   splines_4.4.1      pillar_1.10.2     
[33] magrittr_2.0.3     Matrix_1.7-3       withr_3.0.2        tools_4.4.1       
[37] gtable_0.3.6