Introduction to Pivoting

Re-shaping data to make analysis easier

Published

October 19, 2026

Brief introduction w/ penguins data

library(tidyverse)
library(palmerpenguins)

In the figure below, the data is reshaped, or made longer.

  1. Notice that some columns (the first) gain repeated entries.
  2. Also, notice that some variable names become entries in a new column.
  3. Also, the old entries that are spread out in rectangular form are sent to a new column.

https://epirhandbook.com/images/pivoting/pivot_longer_new.png For more: link

Pivoting is often used to tidy data, i.e., make it look like this:

https://r4ds.hadley.nz/images/tidy-1.png

But often data is gathered in a way that is convenient for the data collector, not the data analyst.

Example: stacking several numeric variables into one

# # # # # # # # # # # # # # # # # # # # # # # #
#
# Assumption: penguins is loaded from palmerpenguins (part of tidyverse)
#
# Tip: use only the data you need
d <- penguins |>
    select(species, island, sex, bill_length_mm, flipper_length_mm, body_mass_g) |>
    filter(!is.na(sex))

# Another tip: make a new variable that keeps track of row numbers
d <- d |> mutate(penguin = row_number())

print(d)
# A tibble: 333 × 7
   species island    sex    bill_length_mm flipper_length_mm body_mass_g penguin
   <fct>   <fct>     <fct>           <dbl>             <int>       <int>   <int>
 1 Adelie  Torgersen male             39.1               181        3750       1
 2 Adelie  Torgersen female           39.5               186        3800       2
 3 Adelie  Torgersen female           40.3               195        3250       3
 4 Adelie  Torgersen female           36.7               193        3450       4
 5 Adelie  Torgersen male             39.3               190        3650       5
 6 Adelie  Torgersen female           38.9               181        3625       6
 7 Adelie  Torgersen male             39.2               195        4675       7
 8 Adelie  Torgersen female           41.1               182        3200       8
 9 Adelie  Torgersen male             38.6               191        3800       9
10 Adelie  Torgersen male             34.6               198        4400      10
# ℹ 323 more rows

bill_length_mm (bill length), flipper_length_mm (flipper length), and body_mass_g (body mass) are three separate numeric variables, each measured on its own scale, but each one could be thought of as “a value” for “a measure” tied to a penguin. Stack these three columns into one variable of measure (which column it came from) and one variable of value (what the number was).

d_pivoted <- d |> pivot_longer(
  cols = c(bill_length_mm, flipper_length_mm, body_mass_g),
  names_to = "measure",
  values_to = "value")

Group the pivoted data by sex & measure, then find the median value for each measure, for each sex.

d_grouped <- group_by(d_pivoted, species, measure) |>
  summarize(median_value = median(value, na.rm = TRUE))

Make a (side-by-side) bar chart colored by sex, whose height tracks the median value, faceted by measure so the very different scales don’t get squashed together.

d_grouped |> ggplot(
  aes(x = species, y = median_value)) +
  geom_point(aes(color = species), size = 5) +
  facet_wrap(
    ~measure,
    # let the y-axis scale vary by measure, since the three measures are on very different scales
    scales = "free_y",
    # label the facets with more human-readable names
    labeller = labeller(measure = c(
      bill_length_mm = "Bill Length (mm)",
      flipper_length_mm = "Flipper Length (mm)",
      body_mass_g = "Body Mass (g)"
    ))
  ) +
  labs(
    title = "Median Bill Length, Flipper Length, and Body Mass by Species",
    x = "Species", 
    y = "Median Value", 
    color = "Species"
  ) +
  scale_color_viridis_d() +
  scale_x_discrete(guide = guide_axis(n.dodge = 2))

Class Exercise

Change the plot & data above to compare bill length and bill depth by sex, faceted by species.

Spread a pair of columns onto a field of cells

Suppose instead we want a table that compares, for each species, the median body mass for males side-by-side with the median body mass for females — one row per species, one column per sex. Right now that comparison lives in long form (one row per species/sex combination):

mass_by_species_sex <- penguins |>
    filter(!is.na(sex)) |>
    group_by(species, sex) |>
    summarize(median_mass = median(body_mass_g, na.rm = TRUE), .groups = "drop")

mass_by_species_sex
# A tibble: 6 × 3
  species   sex    median_mass
  <fct>     <fct>        <dbl>
1 Adelie    female        3400
2 Adelie    male          4000
3 Chinstrap female        3550
4 Chinstrap male          3950
5 Gentoo    female        4700
6 Gentoo    male          5500

Use pivot_wider() to spread the sex column out into its own set of columns, one per sex, filled in with median_mass. This makes the male-vs-female comparison much easier to read at a glance.

mass_by_species_sex |>
    pivot_wider(names_from = sex, values_from = median_mass)
# A tibble: 3 × 3
  species   female  male
  <fct>      <dbl> <dbl>
1 Adelie      3400  4000
2 Chinstrap   3550  3950
3 Gentoo      4700  5500
# or for a nicer looking table 
# install.packages('gt')
library(gt)
mass_by_species_sex |>
  gt() |>
  tab_header(title = "Median Values by Species") 
Median Values by Species
species sex median_mass
Adelie female 3400
Adelie male 4000
Chinstrap female 3550
Chinstrap male 3950
Gentoo female 4700
Gentoo male 5500

If we wished to examine differences between the sexes, we could add a new column that computes the difference between the two median masses, then sort by that difference. (sex in penguins is coded as lowercase male/female, so those become the new column names.)

mass_by_species_sex |>
    pivot_wider(names_from = sex, values_from = median_mass) |>
    mutate(diff = male - female) |>
    arrange(desc(diff))
# A tibble: 3 × 4
  species   female  male  diff
  <fct>      <dbl> <dbl> <dbl>
1 Gentoo      4700  5500   800
2 Adelie      3400  4000   600
3 Chinstrap   3550  3950   400

toy data

The same idea, on a simpler toy dataset: the data in table2 is not tidy — each country/year combination is split across two rows, one for cases and one for population. Make it tidy with pivot_wider().

table2
# A tibble: 12 × 4
   country      year type            count
   <chr>       <dbl> <chr>           <dbl>
 1 Afghanistan  1999 cases             745
 2 Afghanistan  1999 population   19987071
 3 Afghanistan  2000 cases            2666
 4 Afghanistan  2000 population   20595360
 5 Brazil       1999 cases           37737
 6 Brazil       1999 population  172006362
 7 Brazil       2000 cases           80488
 8 Brazil       2000 population  174504898
 9 China        1999 cases          212258
10 China        1999 population 1272915272
11 China        2000 cases          213766
12 China        2000 population 1280428583
table2 |> pivot_wider(names_from = type, values_from = count)
# A tibble: 6 × 4
  country      year  cases population
  <chr>       <dbl>  <dbl>      <dbl>
1 Afghanistan  1999    745   19987071
2 Afghanistan  2000   2666   20595360
3 Brazil       1999  37737  172006362
4 Brazil       2000  80488  174504898
5 China        1999 212258 1272915272
6 China        2000 213766 1280428583

Gather columns back into rows

The data in table4a is not tidy either. Instead of one row per country/year, each year has its own column of case counts.

table4a
# A tibble: 3 × 3
  country     `1999` `2000`
  <chr>        <dbl>  <dbl>
1 Afghanistan    745   2666
2 Brazil       37737  80488
3 China       212258 213766

Make it tidy with pivot_longer(), turning those year columns into a year column and a cases column.

table4a |> pivot_longer(
    cols = -country,
    names_to = "year",
    values_to = "cases"
)
# A tibble: 6 × 3
  country     year   cases
  <chr>       <chr>  <dbl>
1 Afghanistan 1999     745
2 Afghanistan 2000    2666
3 Brazil      1999   37737
4 Brazil      2000   80488
5 China       1999  212258
6 China       2000  213766

By default, the new columns are named name and value. Here we overrode those defaults with names_to = "year" and values_to = "cases" so the result is self-documenting.

Split a string into columns

table3 has two variables — cases and population — occupying a single cell in the rate column, separated by a /.

table3
# A tibble: 6 × 3
  country      year rate             
  <chr>       <dbl> <chr>            
1 Afghanistan  1999 745/19987071     
2 Afghanistan  2000 2666/20595360    
3 Brazil       1999 37737/172006362  
4 Brazil       2000 80488/174504898  
5 China        1999 212258/1272915272
6 China        2000 213766/1280428583

Split rate into two separate, usable columns with separate_wider_delim().

table3 |> separate_wider_delim(cols = rate, delim = "/", names = c("cases", "pop"))
# A tibble: 6 × 4
  country      year cases  pop       
  <chr>       <dbl> <chr>  <chr>     
1 Afghanistan  1999 745    19987071  
2 Afghanistan  2000 2666   20595360  
3 Brazil       1999 37737  172006362 
4 Brazil       2000 80488  174504898 
5 China        1999 212258 1272915272
6 China        2000 213766 1280428583