Reshaping: pivot_longer & pivot_wider

Reviewed & published by Brayan K

Reshaping is the act of moving data between a wide layout (a variable spread across many columns) and a long, tidy layout (one row per observation) — the form that dplyr and ggplot2 expect.

Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll use tidyr::pivot_longer() to gather wide columns into tidy long form, pivot_wider() to spread it back out, and you'll see exactly why long data unlocks grouped summaries and clean plots.

What You'll Learn in This Lesson

1️⃣ Wide Data & pivot_longer()

Look at a typical wide table: a variable — here, the year — is hidden in the column headers. That's fine to read, but you can't group_by(year) a value that isn't a column.

# Most data arrives "wide": one row per subject, one column per
# measurement. Here, each YEAR is its own column.
library(tidyr)
library(tibble)

revenue <- tibble(
  company = c("Acme", "Globex"),
  `2021`  = c(100, 200),
  `2022`  = c(120, 210),
  `2023`  = c(150, 260)
)

revenue
# company  2021  2022  2023
# Acme      100   120   150
# Globex    200   210   260
#
# This is readable for humans but awkward for analysis: "year" is
# trapped in the column HEADERS instead of being a real variable.

pivot_longer() fixes this by stacking the chosen columns into two new ones: names_to captures the old headers, values_to captures the cells.

# pivot_longer() turns WIDE into LONG (a.k.a. "tidy"): it stacks the
# chosen columns into two new columns — one for the old names, one for
# the values.
library(tidyr)

long <- revenue |>
  pivot_longer(
    cols      = c(`2021`, `2022`, `2023`),  # which columns to gather
    names_to  = "year",                        # header -> this column
    values_to = "revenue"                      # cells  -> this column
  )

long
# Now there is ONE row per company-per-year:
# company  year   revenue
# Acme     2021   100
# Acme     2022   120
# ... and so on. 'year' is finally a real variable you can group by.

Listing columns by hand gets tedious, so use selection helpers and let names_transform fix the type of the gathered names in one step.

# You rarely list columns by hand. Selection helpers make it concise:
library(tidyr)

# Everything EXCEPT the id column:
revenue |> pivot_longer(cols = -company, names_to = "year", values_to = "rev")

# All columns whose names start with "20":
revenue |> pivot_longer(cols = starts_with("20"), names_to = "year", values_to = "rev")

# Convert the gathered names to numbers as you go (year as <int>):
revenue |>
  pivot_longer(
    cols = -company,
    names_to = "year",
    names_transform = list(year = as.integer),
    values_to = "rev"
  )

2️⃣ pivot_wider(): Long → Wide

The inverse operation spreads a long table back out. You tell it which column supplies the new headers (names_from) and which fills the cells (values_from). This is how you build human-friendly report tables, or place two measures side by side to compute a ratio.

# pivot_wider() is the inverse: LONG back to WIDE. You name the column
# that supplies the new HEADERS and the column that fills the CELLS.
library(tidyr)

wide_again <- long |>
  pivot_wider(
    names_from  = year,      # values of 'year' become column names
    values_from = revenue    # 'revenue' fills the cells
  )

wide_again
# Back to one column per year. Use this for human-friendly report
# tables, or to put two measures side by side for a ratio.

3️⃣ Why Tidy/Long Data Wins

The payoff for reshaping is that the rest of the tidyverse just works. Grouped summaries with dplyr and layered plots with ggplot2 both assume every variable is its own column — which is precisely what long form guarantees.

# WHY bother going long? Because dplyr and ggplot2 expect tidy data:
# one variable per column, one observation per row.
library(tidyr)
library(dplyr)
library(ggplot2)

# With the LONG data, grouped summaries are trivial:
long |>
  group_by(year) |>
  summarise(total = sum(revenue))

# And plotting a line per company needs 'year' on the x-axis and
# 'company' as the colour — only possible when both are real columns:
ggplot(long, aes(x = year, y = revenue, colour = company, group = company)) +
  geom_line() +
  geom_point()
# In the WIDE shape, 'year' lived in the headers, so neither of these
# would work without reshaping first.

A useful rule of thumb: pivot longer to compute and plot; pivot wider to present. Most analyses spend their middle in long form and only widen at the very end for a readable table.

Your turn. Fill in the ___ blanks and confirm you can round-trip the data.

# 🎯 YOUR TURN — replace each ___ then run it.
library(tidyr)
library(tibble)

# A wide table of test scores: one column per subject.
scores <- tibble(
  student = c("Mia", "Leo"),
  math    = c(90, 75),
  science = c(85, 95),
  art     = c(70, 88)
)

# 1) Pivot it LONGER so there is one row per student-per-subject.
tidy_scores <- scores |>
  pivot_longer(
    cols      = -student,
    names_to  = ___,   # 👉 replace ___ with "subject"
    values_to = "score"
  )

tidy_scores

# 2) Pivot it back WIDER to recover the original shape.
back <- tidy_scores |>
  pivot_wider(names_from = subject, values_from = score)

back   # should match the original 'scores' tibble

Mini-Challenge: Quarterly Sales

This one chains reshaping with a grouped summary — the everyday pattern in real analysis. Tidy the data, summarise it, then optionally widen the result for presentation.

# 🎯 MINI-CHALLENGE: tidy then summarise.
#
# Start from this wide quarterly-sales tibble:
#   region   Q1   Q2   Q3   Q4
#   East     10   12   15   20
#   West     22   18   25   30
#
# 1. Build it with tibble().
# 2. pivot_longer() the four quarter columns into 'quarter' & 'sales'.
# 3. With dplyr, group_by(region) and summarise the TOTAL annual sales.
# 4. Bonus: pivot_wider() your summary so each region is a column.
#
# Write it yourself, then run in RStudio or rdrr.io/snippets.

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Reshaping

TaskR Syntax
Wide → longpivot_longer(df, -id, names_to="k", values_to="v")
Long → widepivot_wider(df, names_from=k, values_from=v)
Select all but idcols = -id
Select by prefixcols = starts_with("20")
Type the namesnames_transform = list(k = as.integer)
Fill missing cellsvalues_fill = 0

🎉 Lesson Complete!

Practice quiz

Which tidyr function turns wide data into long (tidy) form?

  • pivot_wider()
  • pivot_longer()
  • spread()
  • separate()

Answer: pivot_longer(). pivot_longer() gathers many columns into name/value pairs, producing long data.

In pivot_longer(), which argument names the new column that holds the old column HEADERS?

  • names_to
  • values_to
  • names_from
  • cols

Answer: names_to. names_to receives the old header names; values_to receives the cell values.

In pivot_longer(), which argument names the column that holds the CELL values?

  • names_to
  • cols
  • values_to
  • values_from

Answer: values_to. values_to is the new column that captures the gathered cell values.

Which pivot_wider() argument supplies the values that fill the new cells?

  • names_from
  • values_to
  • cols
  • values_from

Answer: values_from. values_from names the column whose values populate the widened cells.

Which pivot_wider() argument supplies the new column HEADERS?

  • names_from
  • names_to
  • values_from
  • id_cols

Answer: names_from. names_from gives the column whose values become the new column names.

To gather every column except the id column 'company', what cols selection works?

  • cols = company
  • cols = -company
  • cols = all()
  • cols = only(company)

Answer: cols = -company. cols = -company selects everything except company.

Which selection helper gathers all columns whose names start with '20'?

  • ends_with("20")
  • contains("20")
  • starts_with("20")
  • matches(20)

Answer: starts_with("20"). starts_with("20") matches column names beginning with 20.

Why might pivot_wider() return a list-column and warn?

  • The data has too few rows
  • Each cell is not uniquely identified, so duplicates collide
  • names_from was numeric
  • You forgot library(tidyr)

Answer: Each cell is not uniquely identified, so duplicates collide. Duplicate id combinations map to the same cell; summarise() first to get one value per cell.

Which older tidyr functions did pivot_longer() and pivot_wider() supersede?

  • melt() and dcast()
  • stack() and unstack()
  • gather() and spread()
  • cast() and reshape()

Answer: gather() and spread(). pivot_longer/pivot_wider replaced the retired gather()/spread() pair.

What does names_transform = list(year = as.integer) do during pivot_longer()?

  • Renames the year column
  • Converts the gathered year names to integers
  • Drops missing years
  • Sorts by year

Answer: Converts the gathered year names to integers. names_transform applies a function to convert the gathered name column's type.

Continue this course

Frequently asked questions

What's the difference between 'wide' and 'long' data?

Wide data spreads a variable across several columns — e.g. one column per year — so a single row holds many observations. Long (or 'tidy') data has one row per observation and one column per variable, so 'year' becomes a single column with repeated values. Humans often prefer wide for reading; computers (dplyr, ggplot2) almost always prefer long because every variable is then addressable by name.

pivot_longer and pivot_wider replaced which older functions?

They superseded gather() and spread() from older tidyr, which in turn replaced reshape2's melt() and dcast(). pivot_longer/pivot_wider have a clearer, more consistent interface (names_to, values_to, names_from, values_from) and many extra arguments for splitting names, transforming types, and handling missing combinations. gather() and spread() still run but are retired — new code should use the pivot_* pair.

Why do I get list-columns or a warning about duplicate identifiers from pivot_wider?

pivot_wider expects each cell to be uniquely identified by the id columns plus the names_from value. If more than one row maps to the same cell, it can't pick one value, so it returns a list-column and warns. The fix is usually to summarise first (e.g. group_by then summarise(mean(...))) so there's exactly one value per combination, or to add the missing identifier column that distinguishes the duplicate rows.

Do pivots change my data or just its shape?

Just the shape — the underlying numbers are preserved. pivot_longer and pivot_wider are (almost) exact inverses: pivot one way and back and you recover the original, aside from row order and the type of the gathered names (which come back as character unless you convert them). Because no values are lost, reshaping is a safe, routine step you can do as often as a given analysis or plot requires.