Skip to main content
R beginner Lesson 4 of 10

Tidying and Reshaping Data

Pivot between wide and long with tidyr, split and combine columns, unnest list-columns from JSON, and fill the gaps a time series leaves behind.

Most real data arrives in the wrong shape. tidyr is four ideas — pivot, split, nest, fill — that convert it into the shape every other package expects.

Wide to long

A report exported from a spreadsheet, with months as columns:

suppressPackageStartupMessages(library(tidyverse))

wide <- tibble(
  channel   = c("web", "app", "phone"),
  `2026-01` = c(41200.50, 28100.00, 9400.25),
  `2026-02` = c(43880.75, 31020.40, 8800.00),
  `2026-03` = c(45120.00, 34410.10, 9120.60)
)
print(wide)
# A tibble: 3 × 4
  channel `2026-01` `2026-02` `2026-03`
  <chr>       <dbl>     <dbl>     <dbl>
1 web        41200.    43881.    45120 
2 app        28100     31020.    34410.
3 phone       9400.     8800      9121.

Readable for a person, useless for a computer — “which month” is stored in a column name, so you cannot filter, group or plot by it.

long <- wide |>
  pivot_longer(
    cols = -channel,
    names_to = "month",
    values_to = "revenue"
  )
print(long)
# A tibble: 9 × 3
  channel month   revenue
  <chr>   <chr>     <dbl>
1 web     2026-01  41200.
2 web     2026-02  43881.
3 web     2026-03  45120 
4 app     2026-01  28100 
5 app     2026-02  31020.
6 app     2026-03  34410.
7 phone   2026-01   9400.
8 phone   2026-02   8800 
9 phone   2026-03   9121.

Now month is data. Everything downstream works:

long |> summarise(revenue = sum(revenue), .by = month) |> print()
long |> slice_max(revenue, n = 1, by = channel) |> print()
# A tibble: 3 × 2
  month   revenue
  <chr>     <dbl>
1 2026-01  78701.
2 2026-02  83701.
3 2026-03  88651.

# A tibble: 3 × 3
  channel month   revenue
  <chr>   <chr>     <dbl>
1 web     2026-03  45120 
2 app     2026-03  34410.
3 phone   2026-01   9400.

Convert types during the pivot rather than afterwards:

long <- wide |>
  pivot_longer(-channel, names_to = "month", values_to = "revenue",
               names_transform = list(month = \(x) as.Date(paste0(x, "-01"))))
print(long, n = 3)
# A tibble: 9 × 3
  channel month      revenue
  <chr>   <date>       <dbl>
1 web     2026-01-01  41200.
2 web     2026-02-01  43881.
3 web     2026-03-01  45120 

Column names carrying two variables

messy <- tibble(
  channel = c("web", "app"),
  jan_orders = c(1204L, 880L),  jan_revenue = c(41200.50, 28100.00),
  feb_orders = c(1288L, 942L),  feb_revenue = c(43880.75, 31020.40)
)

tidy <- messy |>
  pivot_longer(
    -channel,
    names_to = c("month", ".value"),
    names_sep = "_"
  )
print(tidy)
# A tibble: 4 × 4
  channel month orders revenue
  <chr>   <chr>  <int>   <dbl>
1 web     jan     1204  41200.
2 web     feb     1288  43881.
3 app     jan      880  28100 
4 app     feb      942  31020.

.value is the piece worth learning: it tells pivot_longer that part of the name is the column the value belongs in. One call turned four columns into two variables and a key, and orders stayed an integer while revenue stayed a double — a plain pivot would have forced both into one column and one type.

Long to wide

orders <- tibble(
  order_id = rep(1001:1004, each = 2),
  field    = rep(c("status", "channel"), 4),
  value    = c("completed","web", "returned","app", "completed","phone", "pending","web")
)
print(orders, n = 4)

orders |> pivot_wider(names_from = field, values_from = value) |> print()
# A tibble: 8 × 3
  order_id field   value    
     <int> <chr>   <chr>    
1     1001 status  completed
2     1001 channel web      
3     1002 status  returned 
4     1002 channel app      
# ℹ 4 more rows

# A tibble: 4 × 3
  order_id status    channel
     <int> <chr>     <chr>  
1     1001 completed web    
2     1002 returned  app    
3     1003 completed phone  
4     1004 pending   web    

That key-value shape is what you get from an EAV table or a survey export, and pivot_wider is the way back.

It warns when the combination is not unique, which is the failure worth knowing:

dupes <- bind_rows(orders, tibble(order_id = 1001L, field = "status", value = "refunded"))
dupes |> pivot_wider(names_from = field, values_from = value) |> print(n = 2)
Warning message:
Values from `value` are not uniquely identified; output will contain list-cols.
• Use `values_fn = list` to suppress this warning.

# A tibble: 4 × 3
  order_id status    channel  
     <int> <list>    <list>   
1     1001 <chr [2]> <chr [1]>
2     1002 <chr [1]> <chr [1]>

The columns became list-columns rather than values. Decide explicitly what a duplicate means:

dupes |>
  pivot_wider(names_from = field, values_from = value, values_fn = dplyr::last) |>
  print(n = 2)
# A tibble: 4 × 3
  order_id status   channel
     <int> <chr>    <chr>  
1     1001 refunded web    
2     1002 returned app    

Splitting and combining columns

raw <- tibble(
  ref = c("GB-2026-1001", "US-2026-1002", "NL-2026-1003"),
  customer = c("Lovelace, Ada", "Hopper, Grace", "Dijkstra, Edsger")
)

clean <- raw |>
  separate_wider_delim(ref, delim = "-", names = c("country", "year", "order_id")) |>
  separate_wider_delim(customer, delim = ", ", names = c("last_name", "first_name")) |>
  mutate(order_id = as.integer(order_id), year = as.integer(year))
print(clean)
# A tibble: 3 × 5
  country  year order_id last_name first_name
  <chr>   <int>    <int> <chr>     <chr>     
1 GB       2026     1001 Lovelace  Ada       
2 US       2026     1002 Hopper    Grace     
3 NL       2026     1003 Dijkstra  Edsger    

A row that does not split into the expected number of pieces is an error, not a silent NA:

bad <- tibble(ref = c("GB-2026-1001", "MALFORMED"))
bad |> separate_wider_delim(ref, delim = "-", names = c("country", "year", "order_id"))
Error in `separate_wider_delim()`:
! Expected 3 pieces in each element of `ref`.
! 1 value was too short.
ℹ Use `too_few = "debug"` to diagnose the problem.
ℹ Use `too_few = "align_start"/"align_end"` to silence this message.
bad |>
  separate_wider_delim(ref, delim = "-", names = c("country", "year", "order_id"),
                       too_few = "debug") |>
  print()
# A tibble: 2 × 6
  country year  order_id ref_ok ref          ref_pieces
  <chr>   <chr> <chr>    <lgl>  <chr>             <int>
1 GB      2026  1001     TRUE   GB-2026-1001          3
2 MALFORMED NA  NA       FALSE  MALFORMED             1

too_few = "debug" adds _ok and _pieces columns so you can quarantine the bad rows rather than guess at them. Going the other way is unite():

clean |> unite("ref", country, year, order_id, sep = "-") |> print(n = 2)
# A tibble: 3 × 3
  ref          last_name first_name
  <chr>        <chr>     <chr>     
1 GB-2026-1001 Lovelace  Ada       
2 US-2026-1002 Hopper    Grace     

List-columns and nested JSON

library(jsonlite)

events <- tibble(
  order_id = c(1001L, 1002L),
  payload = list(
    list(customer = list(id = 1, country = "GB"),
         items = list(list(sku = "BK-1041", qty = 1, price = 25.50),
                      list(sku = "BK-2277", qty = 2, price = 12.00))),
    list(customer = list(id = 2, country = "US"),
         items = list(list(sku = "BK-9001", qty = 1, price = 40.00)))
  )
)

lines <- events |>
  unnest_wider(payload) |>
  unnest_wider(customer, names_sep = "_") |>
  unnest_longer(items) |>
  unnest_wider(items) |>
  mutate(line_total = qty * price)
print(lines)
# A tibble: 3 × 7
  order_id customer_id customer_country sku       qty price line_total
     <int>       <dbl> <chr>            <chr>   <dbl> <dbl>      <dbl>
1     1001           1 GB               BK-1041     1  25.5       25.5
2     1001           1 GB               BK-2277     2  12         24  
3     1002           2 US               BK-9001     1  40         40  

Two functions, used in a pattern worth memorising:

FunctionTurnsInto
unnest_wider()a list of named fieldsone column per field
unnest_longer()a list of itemsone row per item

An object becomes columns; an array becomes rows. Chaining them flattens any JSON document, which is how you get from an event feed to a table you can aggregate.

Note the row count: two events became three order lines. Watch that number — an empty items array drops the parent row entirely unless you pass keep_empty = TRUE:

with_empty <- tibble(order_id = 1003L, items = list(list()))
with_empty |> unnest_longer(items) |> nrow() |> print()
with_empty |> unnest_longer(items, keep_empty = TRUE) |> nrow() |> print()
[1] 0
[1] 1

Nesting on purpose

The reverse operation groups rows into sub-tables, which is how you fit one model per group in lesson 8:

sales <- tibble(
  channel = rep(c("web", "app"), each = 4),
  month   = rep(1:4, 2),
  revenue = c(41200, 43880, 45120, 47010, 28100, 31020, 34410, 37800)
)

nested <- sales |> nest(data = c(month, revenue))
print(nested)
print(nested$data[[1]])
# A tibble: 2 × 2
  channel data            
  <chr>   <list>          
1 web     <tibble [4 × 2]>
2 app     <tibble [4 × 2]>

# A tibble: 4 × 2
  month revenue
  <int>   <dbl>
1     1   41200
2     2   43880
3     3   45120
4     4   47010
nested |>
  mutate(
    growth = map_dbl(data, \(d) (last(d$revenue) - first(d$revenue)) / first(d$revenue))
  ) |>
  select(channel, growth) |>
  mutate(growth = scales::percent(growth, accuracy = 0.1)) |>
  print()
# A tibble: 2 × 2
  channel growth
  <chr>   <chr> 
1 web     14.1% 
2 app     34.5% 

One row per group, the group’s data alongside it, and any function applied per group. This is R’s answer to a split-apply-combine that returns something more complicated than a number.

Filling gaps

daily <- tibble(
  day = as.Date(c("2026-01-01","2026-01-02","2026-01-05","2026-01-06")),
  channel = c("web","web","web","web"),
  orders = c(120L, 138L, 141L, 129L)
)

filled <- daily |>
  complete(day = seq(min(day), max(day), by = "day"), channel,
           fill = list(orders = 0L))
print(filled)
# A tibble: 6 × 3
  day        channel orders
  <date>     <chr>    <int>
1 2026-01-01 web        120
2 2026-01-02 web        138
3 2026-01-03 web          0
4 2026-01-04 web          0
5 2026-01-05 web        141
6 2026-01-06 web        129

The 3rd and 4th did not exist in the source. Without complete() a line chart draws straight through them and a weekly average divides by four instead of six — a missing day and a zero-order day look identical in the data and mean very different things.

For a value that should carry forward rather than reset, fill():

prices <- tibble(
  day = as.Date(c("2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04")),
  list_price = c(25.50, NA, NA, 27.00)
)
prices |> fill(list_price, .direction = "down") |> print()
# A tibble: 4 × 2
  day        list_price
  <date>          <dbl>
1 2026-01-01       25.5
2 2026-01-02       25.5
3 2026-01-03       25.5
4 2026-01-04       27  

A price recorded only when it changes is the classic case. .direction takes "down", "up", "downup" and "updown".

Practice

1. Pivot a monthly report long and total by month.
wide |>
  pivot_longer(-channel, names_to = "month", values_to = "revenue") |>
  summarise(revenue = sum(revenue), .by = month) |>
  print()
# A tibble: 3 × 2
  month   revenue
  <chr>     <dbl>
1 2026-01  78701.
2 2026-02  83701.
3 2026-03  88651.

Impossible in the wide shape without naming every column. Pivoting first is nearly always cheaper than writing the wide version of an operation.

2. Pivot wider with a duplicated key.
Warning: Values from `value` are not uniquely identified; output will contain list-cols.

  order_id status    channel  
     <int> <list>    <list>   
1     1001 <chr [2]> <chr [1]>

List-columns where you expected values. values_fn = last or first makes the tie-break explicit — silently keeping one is the thing to avoid.

3. Flatten a nested JSON payload into order lines.
  order_id customer_id customer_country sku       qty price line_total
     <int>       <dbl> <chr>            <chr>   <dbl> <dbl>      <dbl>
1     1001           1 GB               BK-1041     1  25.5       25.5
2     1001           1 GB               BK-2277     2  12         24  

unnest_wider for objects, unnest_longer for arrays. Check the row count afterwards — an empty array silently removes its parent row without keep_empty = TRUE.

4. Complete a date range with missing days.
3 2026-01-03 web          0
4 2026-01-04 web          0

Two days that did not exist in the source now read as zero. A chart drawn from the incomplete version interpolates across the gap and shows a trend that never happened.

Next: strings, dates and factors — the three column types that need their own tools.

Frequently Asked Questions

What is tidy data?
Each variable is a column, each observation is a row, and each cell is one value. The payoff is practical rather than aesthetic — dplyr, ggplot2 and the modelling packages all assume it, so data in tidy shape needs no per-function reshaping.
When should data be wide rather than long?
Long for anything computational — grouping, plotting, modelling — because the variable name becomes a value you can filter and facet on. Wide for human reading and for reports, which is usually the last step before output rather than an intermediate form.
What is a list-column?
A data frame column whose entries are lists rather than single values, which is how nested JSON and grouped sub-tables are held. `unnest_longer()` and `unnest_wider()` flatten them, and `nest()` creates them deliberately for per-group modelling.
How do I add rows for missing combinations?
`complete()` expands the data to every combination of the columns you name and fills the gaps, with `fill = list(...)` supplying a value instead of `NA`. It is what you need before plotting a time series with missing days, so the gap shows as zero rather than as a straight line.