Section 1 of 11

1 Before you start

Before you begin, take a few minutes to check what you already know and how confident you feel. You will see the same questions again at the end of the module — this helps both you and us measure what you have learned. Click an option for every question and confidence rating, then click Next to continue.

Pre-test

Which three parts make up the simplest ggplot?

Pre-test

In ggplot2, what does an aesthetic mapping written inside aes() do?

Pre-test

You want every point on a scatter plot the same fixed blue. Where should colour = "blue" go?

Pre-test

You draw geom_histogram(binwidth = 2) of CRP and see three sharp peaks. Before trusting that shape, what should you do?

Pre-test

In a geom_boxplot() of CRP by arm, what does the box itself span?

Pre-test

You overlay two arms' CRP as geom_density() curves. Why should you always quote a sample size (n) alongside the plot?

Pre-test

You have already computed each treatment arm's mean HbA1c into a small tibble and want bars of those means. Which geom do you use?

Pre-test

You add facet_wrap(~ sex) to a scatter of CRP against age. What does it produce?

Pre-confidence

I can build a labelled scatter plot from scratch - choosing the data, an aes() mapping, and geom_point() - and fix a plot that wrongly puts a constant colour inside aes().

Not at all confident
Fully confident
Pre-confidence

I can draw a histogram, density curve, and boxplot to judge a variable's shape, and explain what a boxplot summarises and what it hides.

Not at all confident
Fully confident
Pre-confidence

I can choose geom_col() versus geom_bar() correctly, split a figure into subgroup panels with facet_wrap(), and finish it with readable labs().

Not at all confident
Fully confident
Section 2 of 11

2 Introduction

In Part I you described variables in numbers — a mean here, a median and an IQR there. Numbers alone hide shape. A mean BMI of 28 says nothing about whether the values cluster tightly or trail off in a long tail. In this part you SEE the data: you learn how a ggplot is assembled from named parts, then you draw the standard figures a clinician reaches for first.

This part of the module builds a ladder from a blank plot to a labelled, faceted figure:

  • The grammar of graphics — every ggplot is data, an aesthetic mapping, and a geom, stacked with the + operator; and why a constant belongs outside aes(), not inside it.
  • Distribution plots — geom_histogram(), geom_density(), and geom_boxplot() to see the shape of one variable and spot skew and outliers.
  • Relationships, categories, and small multiples — geom_point() for scatter, geom_col() versus geom_bar(), facet_wrap() for subgroups, and labs() to label every figure.

By the end of this part you will be able to build a scatter plot from scratch — choosing the data, an aes() mapping, and the right geom; draw a histogram, density curve, and boxplot to judge a variable's shape; use geom_col() versus geom_bar() correctly; split a figure into per-subgroup panels with facet_wrap(); and finish any figure with readable labels.

Try every snippet in the R Scratchpad on the right. Each block begins by building the same simulated 200-patient cohort with set.seed() and tibble(), so this part needs no data file — you can run any block on its own and watch the figure appear.

Section 3 of 11

3 The grammar of graphics: data, aes, geom

ggplot2 does not have one function per chart type. Instead it has a grammar of graphics: you describe a plot by naming its parts, and ggplot2 draws it. Three parts are enough to start.

  • data — the tibble you are plotting, passed to ggplot().
  • aes — the aesthetic mapping: which column goes on the x-axis, which on the y, which colours the points. You write it inside aes().
  • geom — the geometric shape that draws the data: points, bars, boxes, a smooth density curve.
A ggplot is composed from three named parts, data supplies the table, aes maps columns to axes, and geom chooses the shape, which combine with + to draw the plot.
A ggplot is composed from three named parts, data supplies the table, aes maps columns to axes, and geom chooses the shape, which combine with + to draw the plot.

An aesthetic mapping is the link from a column in your data to something you can see on the plot — a position, a colour, a size. aes(x = age, y = bmi) says put age along the bottom and bmi up the side.

You assemble a plot by adding layers with +. Start with ggplot(), name the data and the mapping, then add a geom. The + always goes at the end of a line, never the start of the next one — R reads a line that looks finished as finished, so a leading + throws an error.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
ggplot(cohort, aes(x = age, y = bmi)) +
  geom_point()
An aesthetic mapping is the explicit link, declared inside aes(), that ties a column of your data to a visual channel on the plot, so here age governs horizontal position and bmi governs vertical position.
An aesthetic mapping is the explicit link, declared inside aes(), that ties a column of your data to a visual channel on the plot, so here age governs horizontal position and bmi governs vertical position.

Notice what each piece did. ggplot(cohort, ...) set the data. aes(x = age, y = bmi) mapped two columns to the two axes. geom_point() drew one point per patient. The figure appears because the whole expression is printed at the top level — if you save a plot into an object and never print it, nothing shows.

Putting a constant inside aes() is the classic beginner trap. aes() is for mappings from data. To colour every point the same fixed blue, set colour outside aes(), as a plain argument to the geom.

Right way: a column inside aes() maps to the legend (aes(colour = arm) gives one colour per treatment arm). Wrong way: aes(colour = "blue") does not paint things blue — it invents a one-level variable literally called "blue" and lets ggplot pick a colour for it.

Let’s build your first plot one layer at a time.

Worked example · Your first ggplot

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: Build a tiny data frame of age and BMI for four patients, then draw a scatter of BMI against age.

Stage 1 · Study the solved example
Fully solved solution
library(ggplot2)
d <- data.frame(age = c(45, 52, 61, 58), bmi = c(24, 27, 31, 29))
ggplot(d, aes(x = age, y = bmi)) +
  geom_point()
Walk-through
  1. data.frame() holds the two columns
  2. aes() maps age to x and bmi to y
  3. geom_point() draws one point per patient
A ggplot is built by adding layers with +, where the data frame supplies the values, aes() maps columns to the x and y axes, and a geom such as geom_point() draws the marks.
A ggplot is built by adding layers with +, where the data frame supplies the values, aes() maps columns to the x and y axes, and a geom such as geom_point() draws the marks.
Section 4 of 11

4 Distribution plots: histogram, density, boxplot

A scatter shows two variables at once. To see the shape of just one variable — is it symmetric, skewed, full of outliers? — you reach for a distribution plot. These are the first figures you make when you meet a new dataset.

Section 4.1 of 11

4.1 Histograms and density curves

A histogram slices a numeric variable into equal-width bins and draws a bar for how many values fall in each. geom_histogram() needs only an x mapping. The binwidth is the width of each slice — and it is your choice, not the data's.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
ggplot(cohort, aes(x = crp)) +
  geom_histogram(binwidth = 2)
A histogram's bin width is a setting you choose, and changing it re-slices the same data into a different shape rather than revealing a fixed property of the data.
A histogram's bin width is a setting you choose, and changing it re-slices the same data into a different shape rather than revealing a fixed property of the data.

Here is the trap that catches everyone. A histogram's shape depends on the binwidth you pick. Wide bins smooth real structure away; narrow bins turn random noise into spiky fake peaks. Always try two or three binwidths before you trust a shape. Run the block again with binwidth = 0.5, then binwidth = 10, and watch the story change.

A density plot is the smoothed cousin of the histogram: geom_density() draws one continuous curve instead of bars. It is gentler on the eye for comparing the shapes of two groups overlaid, but it still hides the raw counts, so quote it alongside a sample size, never alone.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
ggplot(cohort, aes(x = crp, fill = arm)) +
  geom_density(alpha = 0.4)
A density plot summarises a distribution as a single smooth curve derived from the same data as a histogram, letting you compare the shapes of groups at a glance while concealing the raw counts, so it should always be quoted alongside a sample size.
A density plot summarises a distribution as a single smooth curve derived from the same data as a histogram, letting you compare the shapes of groups at a glance while concealing the raw counts, so it should always be quoted alongside a sample size.
Section 4.2 of 11

4.2 Boxplots and what they hide

A boxplot summarises a distribution with five numbers: the box spans the 25th to 75th percentile (the IQR), the line inside is the median, the whiskers reach most of the range, and dots beyond them are flagged as outliers. It is the standard way to compare a continuous variable across groups.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
ggplot(cohort, aes(x = arm, y = crp)) +
  geom_boxplot()
A boxplot condenses an entire distribution into a five-number summary (Q1, median, and Q3 forming the box/IQR, whiskers reaching the furthest points within 1.5 × IQR, and anything past them flagged as outliers), which is what makes it the standard tool for comparing a continuous variable across groups.
A boxplot condenses an entire distribution into a five-number summary (Q1, median, and Q3 forming the box/IQR, whiskers reaching the furthest points within 1.5 × IQR, and anything past them flagged as outliers), which is what makes it the standard tool for comparing a continuous variable across groups.

But a boxplot is a summary, and a boxplot hides the shape inside each box. Two groups with identical boxes can hide a smooth hump in one and two separate clumps in the other — the box cannot tell them apart. When the sample is small or you suspect clustering, overlay the raw points with geom_jitter() so the reader sees what the box smoothed over.

Section 5 of 11

5 Relationships, categories, and small multiples

Distribution plots show one variable. Now you connect variables and split a figure into subgroups — and you label everything, because an unlabelled figure is unreadable to anyone but you.

Section 5.1 of 11

5.1 geom_col versus geom_bar — the trap

Both draw bars, and confusing them is the most common ggplot bar mistake. geom_bar counts rows for you: give it one categorical x and it tallies how many patients fall in each category. geom_col plots a value you already computed: give it x and y, and each bar's height is your y.

Right way to remember it: use geom_bar() when you want a count of raw rows; use geom_col() when you have already summarised the numbers yourself. Feeding a pre-computed mean to geom_bar() double-counts and gives nonsense heights.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
library(dplyr)
# geom_bar counts rows per arm for you:
ggplot(cohort, aes(x = arm)) +
  geom_bar()
geom_bar() computes each bar's height by counting the rows in a category, whereas geom_col() uses a height you have already calculated and passed in as y.
geom_bar() computes each bar's height by counting the rows in a category, whereas geom_col() uses a height you have already calculated and passed in as y.
Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
library(dplyr)
# geom_col plots a value you computed first:
arm_means <- cohort |>
  group_by(arm) |>
  summarise(mean_hba1c = mean(hba1c))
ggplot(arm_means, aes(x = arm, y = mean_hba1c)) +
  geom_col()
geom_col() does not aggregate; it draws one bar per row of your data using the value you computed beforehand as the bar height.
geom_col() does not aggregate; it draws one bar per row of your data using the value you computed beforehand as the bar height.
Section 5.2 of 11

5.2 Faceting and labels

A facet splits one plot into a grid of small panels, one per subgroup, all sharing the same axes. facet_wrap(~ sex) draws the same chart once for females and once for males, so you compare shapes side by side instead of overlaying them.

Always finish a figure with labs. labs() sets the axis titles, the legend title, and an overall plot title. A raw column name like crp on an axis is fine while you explore, but a figure you show anyone needs words a human can read.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(dplyr)
set.seed(2024)
n <- 200
cohort <- tibble(
  arm = factor(sample(c("Standard", "Intensive"), n, replace = TRUE)),
  sex = factor(sample(c("Female", "Male"), n, replace = TRUE)),
  age = round(rnorm(n, mean = 62, sd = 9)),
  bmi = round(rnorm(n, mean = 28, sd = 4), 1),
  crp = round(rexp(n, rate = 1/5), 1),
  hba1c = round(rnorm(n, mean = 7.8, sd = 1.1), 1),
  responder = factor(sample(c("Yes", "No"), n, replace = TRUE))
)
library(ggplot2)
ggplot(cohort, aes(x = age, y = crp, colour = arm)) +
  geom_point() +
  facet_wrap(~ sex) +
  labs(
    title = "CRP versus age, by sex and treatment arm",
    x = "Age (years)",
    y = "CRP (mg/L)",
    colour = "Arm"
  )
Faceting splits one plot into a grid of small panels, one per subgroup, that share the same axes so you compare each group's shape side by side instead of overlaying them.
Faceting splits one plot into a grid of small panels, one per subgroup, that share the same axes so you compare each group's shape side by side instead of overlaying them.
Section 6 of 11

6 Check your understanding

You have reached the end of the module. Try the same questions again — your answers here, paired with your pre-test answers, are how we measure what the module taught you. Answer every question and confidence rating, then click Submit and see results to view your score.

Post-test

Which three parts make up the simplest ggplot?

Post-test

In ggplot2, what does an aesthetic mapping written inside aes() do?

Post-test

You want every point on a scatter plot the same fixed blue. Where should colour = "blue" go?

Post-test

You draw geom_histogram(binwidth = 2) of CRP and see three sharp peaks. Before trusting that shape, what should you do?

Post-test

In a geom_boxplot() of CRP by arm, what does the box itself span?

Post-test

You overlay two arms' CRP as geom_density() curves. Why should you always quote a sample size (n) alongside the plot?

Post-test

You have already computed each treatment arm's mean HbA1c into a small tibble and want bars of those means. Which geom do you use?

Post-test

You add facet_wrap(~ sex) to a scatter of CRP against age. What does it produce?

Post-confidence

I can build a labelled scatter plot from scratch - choosing the data, an aes() mapping, and geom_point() - and fix a plot that wrongly puts a constant colour inside aes().

Not at all confident
Fully confident
Post-confidence

I can draw a histogram, density curve, and boxplot to judge a variable's shape, and explain what a boxplot summarises and what it hides.

Not at all confident
Fully confident
Post-confidence

I can choose geom_col() versus geom_bar() correctly, split a figure into subgroup panels with facet_wrap(), and finish it with readable labs().

Not at all confident
Fully confident
Section 7 of 11

7 Your results

Here is how your post-test answers compare with your pre-test answers. The pre/post pairing is the most reliable way to see what this module actually taught you.

Your score

Submit the post-test to see your results.

Muddiest point

What is the one thing from this module that is still unclear to you?

Rate this module

Overall, how would you rate this module?

How likely are you to recommend this module to a peer? (0 = not at all, 10 = extremely likely)