Common Code 1 7— Iteration with purrr

map(), map_df(), nest_by(), and reading multiple files

packages
setup

How to redo things over and over

Author

Bill Perry

Published

July 5, 2026

Iteration with purrr

purrr replaces for-loops and copy-paste with clean, readable single lines. The core idea: apply a function to every element of a list.

⬇️ Download the companion R script: 17_purrr.R

library(tidyverse)
library(palmerpenguins)
library(broom)

1 · The map family

NoteChoose your map_* by what you want back
Function Returns
map(x, f) A list (always safe)
map_dbl(x, f) A numeric vector
map_chr(x, f) A character vector
map_lgl(x, f) A logical vector
map_df(x, f) A data frame (row-binds)
walk(x, f) Nothing — for side effects (printing, saving)
map2(x, y, f) Iterate over two inputs simultaneously

The ~ shorthand: ~ some_function(.x) means “apply this function to each element, where .x is the current element.”

numbers <- list(a = c(1,2,3,4), b = c(10,20,NA), c = c(5,6,7,8,9))

map(numbers, mean)                          # list of three means
map_dbl(numbers, mean)                      # named numeric vector
map_dbl(numbers, ~ mean(.x, na.rm = TRUE))  # with na.rm

2 · Fit a model to each group

The most common ecological use: separate regression (or ANOVA) per species, lake, or site — in one pipeline.

models <- penguins |>
  drop_na() |>
  nest_by(species) |>                     # one list-row per species
  mutate(
    fit       = list(lm(body_mass_g ~ flipper_length_mm, data = data)),
    coef      = list(tidy(fit, conf.int = TRUE)),
    fit_stats = list(glance(fit))
  )

models

Extract all coefficients into one flat table

models |>
  unnest(coef) |>
  select(species, term, estimate, std.error, p.value) |>
  filter(term != "(Intercept)")

R² for each species

models |>
  unnest(fit_stats) |>
  select(species, r.squared, adj.r.squared, p.value)
Tipnest_by() + mutate(list(...)) pattern

nest_by(species) creates a grouped data frame where the data column holds one mini-data-frame per species. mutate(fit = list(lm(..., data = data))) fits a model to each. unnest(coef) unpacks the results back into a flat tibble. This pattern scales from 3 species to 300 sites without changing a single line.


3 · Map over a named list of data frames

split() turns a data frame into a named list — one element per group:

species_list <- penguins |>
  drop_na() |>
  split(~ species)   # named list: $Adelie, $Chinstrap, $Gentoo

# Apply a summary function to each species
map_df(species_list, ~ tibble(
  n         = nrow(.x),
  mean_mass = mean(.x$body_mass_g),
  se_mass   = sd(.x$body_mass_g) / sqrt(nrow(.x))
), .id = "species")   # .id creates a column from the list names

4 · Reading multiple files

TipThe most practically useful purrr pattern

When you have annual survey files survey_2022.csv, survey_2023.csv, … you do not need to manually read each one. list.files() + map() + list_rbind() handles any number of files in three lines:

# List all matching files
file_paths <- list.files("data_raw/", pattern = "survey_.*\\.csv",
                          full.names = TRUE)

# Read all, bind into one data frame
all_surveys <- map(file_paths, read_csv) |>
  list_rbind(names_to = "source_file")

# One-liner version
all_surveys <- list.files("data_raw/", "survey.*csv",
                           full.names = TRUE) |>
  map(read_csv) |>
  list_rbind()

list_rbind() is the modern replacement for bind_rows() on a list. names_to = "source_file" adds a column recording which file each row came from — invaluable for debugging.


5 · Generate and save multiple plots

# One plot per species
plots <- penguins |>
  drop_na() |>
  split(~ species) |>
  map(~ ggplot(.x, aes(x = flipper_length_mm, y = body_mass_g)) +
        geom_point(color = "steelblue", alpha = 0.6) +
        geom_smooth(method = "lm", color = "tomato", se = FALSE) +
        labs(title = unique(.x$species),
             x = "Flipper length (mm)", y = "Body mass (g)") +
        theme_classic())

# Print each
walk(plots, print)

# Save each — map2() iterates two lists at once
filenames <- paste0("figures/regression_", names(plots), ".pdf")
map2(filenames, plots,
     ~ ggsave(.x, plot = .y, width = 5, height = 4, units = "in"))
Notewalk() for side effects

map() returns a list of results. walk() runs the function purely for its side effect (printing, saving, writing) and returns nothing. Use walk() for print(), ggsave(), write_csv() — anywhere you do not need the output.


6 · map2() — iterate over two inputs

sites   <- c("A","B","C")
surveys <- c(2022, 2023, 2024)

map2_chr(sites, surveys,
         ~ paste0("Site ", .x, " surveyed in ", .y))
# "Site A surveyed in 2022" "Site B surveyed in 2023" ...

7 · Safe mapping — handle errors gracefully

safely() wraps any function so that errors return NULL instead of stopping the whole map():

safe_log <- safely(log)

results <- map(list(10, -1, 100, "a"), safe_log)
# Each element: $result (the value) or $error (the error message)

# Keep only successful results
results |> map("result") |> compact()   # compact() drops NULLs
Tipsafely() is essential for batch processing

When reading 50 field survey files, one corrupted file would crash the whole pipeline. Wrapping read_csv with safely(read_csv) lets you process all 50 files and then inspect which ones failed — without losing all the successful reads.


8 · Ecological application — eBird pipeline sketch

# Read abundance rasters for multiple species → extract breeding centroid
species_codes <- c("ovenbird", "blackthroated_warbler", "veery")

# Each file is a raster CSV exported from the ebirdst pipeline
abundance_data <- set_names(
  paste0("data_raw/ebirdst/", species_codes, "_abundance.csv"),
  species_codes
) |>
  map(read_csv) |>
  map(~ filter(.x, !is.na(abd))) |>
  map(~ mutate(.x, log_abd = log10(abd + 0.001)))

# Summary across species
map_df(abundance_data, ~ tibble(
  max_abd  = max(.x$abd),
  mean_lat = weighted.mean(.x$lat, .x$abd, na.rm = TRUE)
), .id = "species")

Quick reference

Task Code
Apply function → list map(x, ~ f(.x))
Apply function → numbers map_dbl(x, ~ f(.x))
Apply function → data frame map_df(x, ~ f(.x), .id = "name")
Side effects only walk(x, ~ f(.x))
Two inputs map2(x, y, ~ f(.x, .y))
Split by group df |> split(~ group_var)
Fit model per group nest_by(group) |> mutate(fit = list(lm(..., data=data)))
Extract nested results unnest(coef) after mutate(coef = list(tidy(fit)))
Read multiple files list.files(path, pattern) |> map(read_csv) |> list_rbind()
Handle errors safely(f) then map("result") |> compact()
Save multiple plots map2(filenames, plots, ~ ggsave(.x, .y))

End of Common Code 18 — Iteration with purrr.