How to create within-subject scatter plots in R with ggplot2

statistics tutorial R visualization ggplot2

Scatterplots can be a very effective form of visualization for data from within-subjects experiments. You’ll often see within-subject data visualized as bar graphs (condition means, and maybe mean difference if you’re lucky.) But alternatives exist, and today we’ll take a look at within-subjects scatterplots.

Matti Vuorre (University of Oxford)

Today, we’ll take a look at creating a specific type of visualization for data from a within-subjects experiment (also known as repeated measures, but that can sometimes be a misleading label). You’ll often see within-subject data visualized as bar graphs (condition means, and maybe mean difference if you’re lucky.) But alternatives exist, and today we’ll take a look at within-subjects scatterplots.

For example, Ganis and Kievit (2015) asked 54 people to observe, on each trial, two 3-D shapes with various rotations and judge whether the two shapes were the same or not.

There were 4 angles (0, 50, 100, and 150 degree rotations), but for simplicity, today we’ll only look at items that were not rotated with respect to each other, and items rotated 50 degrees. The data are freely available (thanks!) in Excel format, and the below snippet loads the data and cleans into a useable format:

if (!file.exists("")) {
  download.file("", "")
files <- list.files(
  pattern = "sub[0-9]+.xlsx", full.names = T
dat <- map(
  ~ read_xlsx(.x, range = "A4:G100", col_types = rep("text", 7))
) %>%
  bind_rows(.id = "id")
dat <- dat %>%
  filter(angle %in% c("0", "50")) %>%
    id = factor(id),
    angle = factor(angle),
    rt = as.numeric(Time),
    accuracy = as.numeric(`correct/incorrect`)
Table 1: Example data.
id angle rt accuracy
1 0 1355 1
1 50 1685 1
1 50 1237 1
1 0 1275 1
1 50 2238 1
1 0 1524 1

We’ll focus on comparing the reaction times between the 0 degree and 50 degree rotation trials.

Subject means

We’ll be graphing subjects’ means and standard errors, so we compute both first

dat_sum <- group_by(dat, id, angle) %>%
    m = mean(rt, na.rm = T),
    se = sd(rt, na.rm = TRUE) / sqrt(n())
Table 2: Summary data
id angle m se
1 0 1512.12 146.50
1 50 2039.42 133.74
10 0 2784.39 301.94
10 50 3766.58 337.51
11 0 3546.30 388.03
11 50 4639.84 281.78
dat_sum %>%
  ggplot(aes(x = angle, y = m)) +
  stat_summary( = mean_cl_normal, size = 1
  ) +
  geom_quasirandom(width = .1, shape = 1) +
  scale_y_continuous("Mean RT")

This figure shows quite clearly that the mean reaction time in the 50 degree angle condition was higher than in the 0 degree angle condition, and the spread across individuals in each condition. However, we often are specifically interested in the within-subject effect of condition, which would be difficult to visually display in this image. We could draw lines to connect each point, and the effect would then be visible as a “spaghetti plot,” but while useful, these plots may sometimes be a little overwhelming especially if there’s too many people (spaghetti is great but nobody likes too much of it!)

Within-subject scatterplots

To draw within-subjects scatterplots, we’ll need a slight reorganization of the data, such that it is in wide format with respect to the conditions.

dat_sum_wide <- dat_sum %>% 
  pivot_wider(names_from = angle, values_from = c(m, se))
Table 3: Summary data in wide format.
id m_0 m_50 se_0 se_50
1 1512.12 2039.42 146.50 133.74
10 2784.39 3766.58 301.94 337.51
11 3546.30 4639.84 388.03 281.78
12 1251.04 1767.54 125.10 211.44
13 1372.54 2037.67 86.25 167.52
14 1231.92 1666.25 84.09 126.10

Then we can simply map the per-subject angle-means and standard errors to the X and Y axes. I think it’s important for these graphs to usually have a 1:1 aspect ratio, an identity line, and identical axes, which we add below.

ggplot(dat_sum_wide, aes(x = m_0, y = m_50)) +
  # Equalize axes
  scale_x_continuous("RT (0 degrees)", limits = c(500, 5000)) +
  scale_y_continuous("RT (50 degrees)", limits = c(500, 5000)) +
  # Identity line
  geom_abline(size = .25) +
  # 1:1 aspect ratio
  theme(aspect.ratio = 1) +
  # Points and errorbars
  geom_point() +
  geom_linerange(aes(ymin = m_50-se_50, ymax = m_50+se_50), size = .25) +
  geom_linerange(aes(xmin = m_0-se_0, xmax = m_0+se_0), size = .25)

This plot shows each person (mean) as a point and their SEs as thin lines. The difference between conditions can be directly seen by how far from the diagonal line the points are. Were we to use CIs, we could also see subject-specific significant differences. Points above the diagonal indicate that the person’s (mean) RT was greater in the 50 degrees condition. All of the points lie below the identity line, indicating that the effect was as we predicted, and robust across individuals.

This is a very useful diagnostic plot that simultaneously shows the population- (or group-) level trend (are the points, on average, below or above the identity line?) and the expectation (mean) for every person (roughly, how far apart the points are from each other?). The points are naturally connected by their location, unlike in a bar graph where they would be connected by lines. Maybe you think it’s an informative graph; it’s certainly very easy to do in R with ggplot2. Also, I think it is visually very convincing, and doesn’t necessarily lead one to focus unjustly just on the group means: I am both convinced and informed by the graph.


Within-subject scatter plots are pretty common in some fields (psychophysics), but underutilized in many fields where they might have a positive impact on statistical inference. Why not try them out on your own data, especially when they’re this easy to do with R and ggplot2?

Recall that for real applications, it’s better to transform or model reaction times with a skewed distribution. Here we used normal distributions just for convenience.

Finally, this post was made possible by the Ganis and Kievit (2015) who generously have shared their data online.

Support this work

Software used

The following software packages were used: R [Version 4.0.3; R Core Team (2020)] and the R-packages dplyr [Version 1.0.5; Wickham et al. (2021)], forcats [Version 0.5.1; Wickham (2021a)], ggbeeswarm [Version 0.6.0; Clarke and Sherrill-Mix (2017)], ggplot2 [Version 3.3.3; Wickham (2016)], ggstance [Version 0.3.5; Henry, Wickham, and Chang (2020)], kableExtra [Version 1.3.4; Zhu (2021)], knitr [Version 1.31; Xie (2015)], purrr [Version 0.3.4; Henry and Wickham (2020)], readr [Version 1.4.0; Wickham and Hester (2020)], readxl [Version 1.3.1; Wickham and Bryan (2019)], scales [Version 1.1.1; Wickham and Seidel (2020)], stringr [Version 1.4.0; Wickham (2019)], tibble [Version 3.1.0; Müller and Wickham (2021)], tidyr [Version 1.1.3; Wickham (2021b)], and tidyverse [Version 1.3.0; Wickham et al. (2019)].

Clarke, Erik, and Scott Sherrill-Mix. 2017. Ggbeeswarm: Categorical Scatter (Violin Point) Plots.
Ganis, Giorgio, and Rogier Kievit. 2015. “A New Set of Three-Dimensional Shapes for Investigating Mental Rotation Processes: Validation Data and Stimulus Set.” Journal of Open Psychology Data 3 (1).
Henry, Lionel, and Hadley Wickham. 2020. Purrr: Functional Programming Tools.
Henry, Lionel, Hadley Wickham, and Winston Chang. 2020. Ggstance: Horizontal ’Ggplot2’ Components.
Müller, Kirill, and Hadley Wickham. 2021. Tibble: Simple Data Frames.
R Core Team. 2020. R: A Language and Environment for Statistical Computing. Vienna, Austria: R Foundation for Statistical Computing.
Wickham, Hadley. 2016. Ggplot2: Elegant Graphics for Data Analysis. Springer-Verlag New York.
———. 2019. Stringr: Simple, Consistent Wrappers for Common String Operations.
———. 2021a. Forcats: Tools for Working with Categorical Variables (Factors).
———. 2021b. Tidyr: Tidy Messy Data.
Wickham, Hadley, Mara Averick, Jennifer Bryan, Winston Chang, Lucy D’Agostino McGowan, Romain François, Garrett Grolemund, et al. 2019. “Welcome to the tidyverse.” Journal of Open Source Software 4 (43): 1686.
Wickham, Hadley, and Jennifer Bryan. 2019. Readxl: Read Excel Files.
Wickham, Hadley, Romain François, Lionel Henry, and Kirill Müller. 2021. Dplyr: A Grammar of Data Manipulation.
Wickham, Hadley, and Jim Hester. 2020. Readr: Read Rectangular Text Data.
Wickham, Hadley, and Dana Seidel. 2020. Scales: Scale Functions for Visualization.
Xie, Yihui. 2015. Dynamic Documents with R and Knitr. 2nd ed. Boca Raton, Florida: Chapman; Hall/CRC.
Zhu, Hao. 2021. kableExtra: Construct Complex Table with ’Kable’ and Pipe Syntax.



If you see mistakes or want to suggest changes, please create an issue on the source repository.


Text and figures are licensed under Creative Commons Attribution CC BY 4.0. Source code is available at, unless otherwise noted. The figures that have been reused from other sources don't fall under this license and can be recognized by a note in their caption: "Figure from ...".


For attribution, please cite this work as

Vuorre (2017, Jan. 4). Sometimes I R: How to create within-subject scatter plots in R with ggplot2. Retrieved from

BibTeX citation

  author = {Vuorre, Matti},
  title = {Sometimes I R: How to create within-subject scatter plots in R with ggplot2},
  url = {},
  year = {2017}