Prof. Marc Los Huertos • Pomona College
Approach Comparison for Loops apply() Family Why purrr? map() Family Anonymous Functions map2() & pmap() walk() Real-World Examples List Columns
What this module covers. R gives you three distinct tools for repeating an operation:
forloops, the base-Rapplyfamily, andpurrr::map()and friends. This module shows all three, explains when each shines, and provides a side-by-side reference table so you can choose confidently. By the end you will be able to replace most loops with a single, readable pipeline — and know the handful of cases where a loop is still the right tool.
Before diving in, here is the full comparison. Return to this table whenever you need to pick the right tool.
| Approach | Origin | Returns | ✔ Pros | ✘ Cons | Best for… |
|---|---|---|---|---|---|
| for loop | base R | nothing (side effect) |
Universal; readable step-by-step logic; easy to debug with
print(); handles early-exit (break) and
skipping (next); no extra packages.
|
Verbose boilerplate (pre-allocate, index, accumulate); output container must be managed manually; easy to accidentally grow a vector inside the loop (slow). | Sequential steps where each iteration depends on the previous result; simulations with carry-forward state; teaching iteration concepts. |
| while / repeat | base R | nothing (side effect) | Only option when the number of iterations is unknown in advance; handles convergence loops cleanly. |
Risk of infinite loops; even more boilerplate than for;
rarely needed in data analysis.
|
Iterative numerical algorithms (Newton–Raphson, EM); scraping until a condition is met. |
| apply() | base R | matrix or list | No packages; fast on matrices; MARGIN= argument is elegant for rows vs columns. | Only works on matrices/arrays; output type is inconsistent (sometimes simplifies, sometimes does not); confusing when function returns varying-length results. | Row-wise or column-wise summaries on a numeric matrix. |
| lapply() | base R | always a list | No packages; predictable list output; works on any list or vector; simple mental model. |
Always a list — must call unlist() or do.call(rbind,
…) to flatten; no typed variants; anonymous function syntax is
wordier.
|
Quick one-off tasks in base R scripts; environments without tidyverse. |
| sapply() | base R | vector, matrix, or list |
Convenient when the result is a simple scalar per element — no
unlist() needed.
|
Silently returns a list when simplification fails — a major source of hard-to-find bugs; not type-safe; avoid in production code or functions. |
Interactive console exploration only. Prefer vapply() in
scripts.
|
| vapply() | base R | typed vector |
Type-safe version of sapply(); errors loudly if result does
not match declared type; no packages.
|
Verbose — must declare FUN.VALUE template; less readable than purrr typed variants. | Base R scripts where you want the safety of purrr without the dependency. |
| mapply() | base R | vector or list | Iterates over multiple arguments in parallel; no packages. |
Argument order is function-first, which feels backwards; same
silent-simplification problem as sapply(); superseded by
map2()/pmap().
|
Base R scripts needing parallel iteration; legacy code. |
| tapply() | base R | array | Computes a summary statistic by group from a vector + factor in one call. |
Narrow use case; output is an array, not a data frame; superseded by
dplyr::summarise().
|
Quick group summaries when you have a response vector and a grouping factor. |
| purrr::map() | purrr | list |
Consistent interface; typed variants (map_dbl,
map_chr, …) enforce output type; terse ~
shorthand; composes naturally in pipes;
map2()/pmap() for parallel inputs; excellent
error messages.
|
Requires tidyverse/purrr; ~/.x syntax has a
short learning curve; slight overhead vs bare lapply()
(negligible in practice).
|
Almost all iteration in modern tidyverse workflows; list columns; multi-input mapping. |
💡 Quick decision rule Does the next iteration
depend on the previous result? → for loop. Are you in a
pure base-R environment? → vapply() (safe)
or lapply() (simple). Otherwise? →
purrr::map() and friends.
for Loops — The FoundationA for loop is the most transparent way to express
iteration: you see exactly what happens at each step. Understanding it
well also makes the motivation for map() concrete.
sites <- list(
kern = c(12.1, 14.3, 11.8, 15.6, 13.2),
mojave = c(8.4, 7.9, 9.1, 8.6),
coast = c(18.0, 19.4, 17.5, 20.1, 18.8, 19.0)
)
# Pre-allocate output — never grow a vector inside a loop
site_means <- vector("double", length(sites))
names(site_means) <- names(sites)
for (i in seq_along(sites)) {
site_means[i] <- mean(sites[[i]])
}
site_means
## kern mojave coast
## 13.4 8.5 18.8
The three-part boilerplate — pre-allocate, index, assign — is required every single time. That is the price for full visibility.
# Often cleaner to iterate over names directly
for (nm in names(sites)) {
cat(nm, "→ n =", length(sites[[nm]]),
" mean =", round(mean(sites[[nm]]), 2), "\n")
}
## kern → n = 5 mean = 13.4
## mojave → n = 4 mean = 8.5
## coast → n = 6 mean = 18.8
next and break — flow control inside
loops# Skip sites with fewer than 5 observations; stop at first site with mean > 17
for (nm in names(sites)) {
if (length(sites[[nm]]) < 5) { cat("Skipping", nm, "(too few obs)\n"); next }
if (mean(sites[[nm]]) > 17) { cat("Found high-mean site:", nm, "\n"); break }
cat(nm, "passed filter, mean =", round(mean(sites[[nm]]), 2), "\n")
}
## kern passed filter, mean = 13.4
## Skipping mojave (too few obs)
## Found high-mean site: coast
⚠️ The cardinal sin: growing a vector inside a loop
results <- c(results, new_value) inside a loop copies
the entire vector on every iteration — O(n²) memory and time. Always
pre-allocate with vector(“double”, n) and fill by
index.
for loop is genuinely the right choiceA loop is preferable to map() when each
iteration depends on the result of the previous one —
map() has no mechanism for carry-forward state:
# Simulate a random walk — each step depends on the last
set.seed(42)
n_steps <- 200
position <- numeric(n_steps)
position[1] <- 0
for (i in 2:n_steps) {
position[i] <- position[i - 1] + rnorm(1, mean = 0, sd = 1)
}
plot(position, type = "l", col = "#4a7c5e", lwd = 1.5,
xlab = "Step", ylab = "Position",
main = "Random walk — carry-forward state requires a loop",
frame.plot = FALSE)
abline(h = 0, col = "#c8832a", lty = 2)
while and repeatUse while when the stopping criterion is a condition,
not a count:
# Newton–Raphson: find square root of 50 by iteration
x <- 25 # initial guess
tol <- 1e-10
iter <- 0
while (abs(x^2 - 50) > tol) {
x <- x - (x^2 - 50) / (2 * x) # Newton step
iter <- iter + 1
}
cat(sprintf("sqrt(50) ≈ %.10f (converged in %d iterations)\n", x, iter))
## sqrt(50) ≈ 7.0710678119 (converged in 6 iterations)
sqrt(50) # confirm with built-in
## [1] 7.071068
apply FamilyBefore purrr, R users relied on a family of apply
functions. You will encounter them in legacy code and Stack Overflow
answers, so it is worth understanding each one.
apply() — rows and columns of a matrix# apply() is for matrices; MARGIN = 1 → rows, MARGIN = 2 → columns
m <- matrix(c(3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8), nrow = 4)
m
## [,1] [,2] [,3]
## [1,] 3 5 5
## [2,] 1 9 3
## [3,] 4 2 5
## [4,] 1 6 8
apply(m, MARGIN = 1, FUN = mean) # row means
## [1] 4.333333 4.333333 3.666667 5.000000
apply(m, MARGIN = 2, FUN = sum) # column sums
## [1] 9 22 21
apply() does not work well on data
frames with mixed types — use purrr::map_dbl() or
dplyr::summarise() instead.
lapply() — list in, list out (always)# lapply() always returns a list of the same length
lapply(sites, mean)
## $kern
## [1] 13.4
##
## $mojave
## [1] 8.5
##
## $coast
## [1] 18.8
# To get a plain vector you must unlist() or use sapply()
unlist(lapply(sites, mean))
## kern mojave coast
## 13.4 8.5 18.8
sapply() — convenient but treacherous# Works fine when every result is a scalar
sapply(sites, mean)
## kern mojave coast
## 13.4 8.5 18.8
# ⚠ Silent failure: returns a list when simplification is impossible
# (e.g. results have different lengths — hard to catch downstream)
sapply(sites, range) # 2-row matrix this time — not always obvious
## kern mojave coast
## [1,] 11.8 7.9 17.5
## [2,] 15.6 9.1 20.1
⚠️ Never use sapply() inside a function you plan
to reuse Its return type depends on the data it sees at
runtime. Use vapply() (base R) or
map_dbl()/map_chr() (purrr) to declare what
you expect and get an error if the data disagrees.
vapply() — the safe alternative to
sapply()# Declare the expected return type via FUN.VALUE
vapply(sites, mean, FUN.VALUE = numeric(1))
## kern mojave coast
## 13.4 8.5 18.8
# vapply() errors if the result does not match — safety at zero extra cost
# vapply(sites, range, FUN.VALUE = numeric(1)) # ← would error: length 2 ≠ 1
mapply() — multiple inputs in parallel# mapply() is like map2() / pmap() but function-first, data-second
site_means_vec <- c(kern = 12, mojave = 8, coast = 18)
site_sds_vec <- c(kern = 2, mojave = 1, coast = 3)
set.seed(42)
mapply(function(mu, s) round(mean(rnorm(30, mu, s)), 2),
mu = site_means_vec,
s = site_sds_vec)
## kern mojave coast
## 12.14 7.88 18.57
Compare to the purrr equivalent (function-last, which reads better in pipes):
set.seed(42)
map2_dbl(site_means_vec, site_sds_vec,
~ round(mean(rnorm(30, .x, .y)), 2))
## kern mojave coast
## 12.14 7.88 18.57
tapply() — grouped summaries from a vector# tapply(values, groups, FUN) — quick group summaries
ndvi_vals <- c(0.45, 0.48, 0.50, 0.62, 0.65, 0.70, 0.30, 0.28, 0.32)
watershed <- c("W1","W1","W1","W2","W2","W2","W3","W3","W3")
tapply(ndvi_vals, watershed, mean)
## W1 W2 W3
## 0.4766667 0.6566667 0.3000000
# Modern equivalent using dplyr:
data.frame(ndvi = ndvi_vals, ws = watershed) |>
dplyr::summarise(mean_ndvi = mean(ndvi), .by = ws)
## ws mean_ndvi
## 1 W1 0.4766667
## 2 W2 0.6566667
## 3 W3 0.3000000
# Task: compute the coefficient of variation (CV) for each site
cv <- function(x) sd(x) / mean(x) * 100
# 1. for loop
cv_loop <- vector("double", length(sites)); names(cv_loop) <- names(sites)
for (nm in names(sites)) cv_loop[nm] <- cv(sites[[nm]])
# 2. lapply + unlist
cv_lapply <- unlist(lapply(sites, cv))
# 3. sapply (convenient, but not type-safe)
cv_sapply <- sapply(sites, cv)
# 4. vapply (type-safe base R)
cv_vapply <- vapply(sites, cv, numeric(1))
# 5. purrr::map_dbl (type-safe, tidyverse-friendly)
cv_purrr <- map_dbl(sites, cv)
# All identical?
stopifnot(all.equal(cv_loop, cv_lapply),
all.equal(cv_loop, cv_sapply),
all.equal(cv_loop, cv_vapply),
all.equal(cv_loop, cv_purrr))
cat("All five approaches produce identical results.\n")
## All five approaches produce identical results.
map()?A for loop in R is perfectly valid. But it forces you to
manage three things at once: the index, the
output container, and the logic.
map() collapses all three into one expression focused on
the logic alone.
Consider computing the mean of each numeric column in a data frame:
# ----- the for-loop way -----
df <- data.frame(a = rnorm(50, 10, 2),
b = rnorm(50, 20, 4),
c = rnorm(50, 5, 1))
results_loop <- vector("list", ncol(df)) # pre-allocate
for (i in seq_along(df)) {
results_loop[[i]] <- mean(df[[i]])
}
results_loop
## [[1]]
## [1] 9.743503
##
## [[2]]
## [1] 19.59302
##
## [[3]]
## [1] 4.96217
# ----- the purrr way -----
map(df, mean)
## $a
## [1] 9.743503
##
## $b
## [1] 19.59302
##
## $c
## [1] 4.96217
The map() version removes the bookkeeping entirely. The
intent — apply mean to every column — is
immediately readable.
🔄 The core contract of map() map(.x,
.f) takes every element of .x and feeds it to
.f, collecting the results in a list. The
input is a list or vector; the output is always a list of the same
length.
The mental model is a conveyor belt: items flow in one end, the function processes each one, results flow out the other end — all in parallel, none skipped, none out of order.
map() Family — Choosing Your Output Typemap() always returns a list. When you
know what type the results should be, use a typed variant to get a flat
vector instead of a list:
sites <- list(
kern = c(12.1, 14.3, 11.8, 15.6, 13.2),
mojave = c(8.4, 7.9, 9.1, 8.6),
coast = c(18.0, 19.4, 17.5, 20.1, 18.8, 19.0)
)
# map() → list of numeric vectors (raw output)
map(sites, summary)
## $kern
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 11.8 12.1 13.2 13.4 14.3 15.6
##
## $mojave
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 7.900 8.275 8.500 8.500 8.725 9.100
##
## $coast
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 17.5 18.2 18.9 18.8 19.3 20.1
# map_dbl() → named double vector — mean per site
map_dbl(sites, mean)
## kern mojave coast
## 13.4 8.5 18.8
# map_int() → sample sizes
map_int(sites, length)
## kern mojave coast
## 5 4 6
# map_lgl() → which sites have mean > 15?
map_lgl(sites, ~ mean(.x) > 15)
## kern mojave coast
## FALSE FALSE TRUE
💡 Type safety is a feature, not a restriction If
you use map_dbl() and the function accidentally returns a
character string, purrr errors immediately — at the source
of the problem. A plain list would silently carry the wrong type for
many lines before breaking somewhere unexpected.
.f InlineYou don’t have to write a named function to use map().
The tidyverse shorthand ~ expr creates an anonymous
function where .x refers to the current element:
# Three equivalent ways to compute CV (sd/mean * 100) per site:
# 1. Named function (verbose but reusable)
coef_var <- function(x) sd(x) / mean(x) * 100
map_dbl(sites, coef_var)
## kern mojave coast
## 11.764092 5.843006 5.001132
# 2. purrr formula shorthand ~ (most common in pipelines)
map_dbl(sites, ~ sd(.x) / mean(.x) * 100)
## kern mojave coast
## 11.764092 5.843006 5.001132
# 3. Base R anonymous function (R ≥ 4.1)
map_dbl(sites, \(x) sd(x) / mean(x) * 100)
## kern mojave coast
## 11.764092 5.843006 5.001132
The ~ shorthand is idiomatic purrr and reads well in
pipes. The \(x) syntax is portable to any function that
takes a function argument, not just purrr.
When your function needs more than just the current element, pass
extra arguments after .f:
# Trim 10% from each tail when computing mean
map_dbl(sites, mean, trim = 0.1)
## kern mojave coast
## 13.4 8.5 18.8
# Or equivalently with the formula shorthand
map_dbl(sites, ~ mean(.x, trim = 0.1))
## kern mojave coast
## 13.4 8.5 18.8
⚠️ Watch out: passing extra args to the wrong
function Extra arguments go to .f, not to
map() itself. If your anonymous function calls multiple
functions internally, use the ~ form and be explicit about
which gets which argument.
map2() and pmap() — Iterating Over
Multiple Inputsmap() iterates over one thing at a time.
map2() iterates over two things in
parallel, and pmap() handles any number of
inputs as a named list.
# Simulate data for multiple sites with different means AND sd's
site_means <- c(kern = 12, mojave = 8, coast = 18)
site_sds <- c(kern = 2, mojave = 1, coast = 3)
# map2(): iterate over means and sds simultaneously
set.seed(42)
simulated <- map2(site_means, site_sds,
~ rnorm(n = 30, mean = .x, sd = .y))
map_dbl(simulated, mean) # check recovered means
## kern mojave coast
## 12.137174 7.878091 18.572308
# pmap(): same idea, but inputs come as a named list (or data frame)
params <- list(
n = c(20, 30, 40),
mean = c(10, 50, 100),
sd = c( 2, 5, 10)
)
set.seed(7)
pmap(params, rnorm) |> map_dbl(mean)
## [1] 10.88649 50.51177 100.20658
pmap() is especially powerful when your parameters live
in a data frame — each row becomes one call to the function:
model_specs <- tibble::tribble(
~species, ~intercept, ~slope,
"oak", 2.1, 0.84,
"chaparral", 1.4, 1.12,
"riparian", 3.5, 0.61
)
# predict biomass at a fixed precipitation value (450 mm)
precip <- 450
model_specs |>
dplyr::mutate(
biomass_kg = pmap_dbl(
list(a = intercept, b = slope),
~ ..1 + ..2 * log(precip)
)
)
## # A tibble: 3 × 4
## species intercept slope biomass_kg
## <chr> <dbl> <dbl> <dbl>
## 1 oak 2.1 0.84 7.23
## 2 chaparral 1.4 1.12 8.24
## 3 riparian 3.5 0.61 7.23
walk() — When You Only Care About Side Effectsmap() always returns a value. Sometimes you want to
do something — save a file, print a plot, write a message —
without collecting any return value. That is walk().
# Create a small report for each site (printing here; in practice, write CSVs)
walk2(names(sites), sites, function(nm, vals) {
cat(sprintf("Site: %-10s n = %d mean = %.2f sd = %.2f\n",
nm, length(vals), mean(vals), sd(vals)))
})
## Site: kern n = 5 mean = 13.40 sd = 1.58
## Site: mojave n = 4 mean = 8.50 sd = 0.50
## Site: coast n = 6 mean = 18.80 sd = 0.94
The walk() family mirrors map() exactly —
walk2(), pwalk() — but returns .x
invisibly, making it pipeline-friendly:
# A pipeline that processes data AND saves outputs — walk() at the end
sites |>
map(~ tibble::tibble(value = .x, z = scale(.x)[,1])) |>
walk2(names(sites), ~ readr::write_csv(.x, paste0(.y, "_processed.csv")))
A common ecological workflow: split data by group, fit a model to each group, extract coefficients.
# Simulate a rainfall–NDVI dataset across 4 watersheds
set.seed(123)
n_sites <- 4
obs_df <- purrr::map_dfr(seq_len(n_sites), function(s) {
n <- 25
ppt <- runif(n, 200, 600)
tibble::tibble(
site = paste0("WS", s),
ppt = ppt,
ndvi = 0.3 + s * 0.05 + 0.0008 * ppt + rnorm(n, 0, 0.04)
)
})
# Split → fit → extract slope, all in one pipeline
slopes <- obs_df |>
split(~site) |>
map(~ lm(ndvi ~ ppt, data = .x)) |>
map_dfr(~ broom::tidy(.x)[2, c("term", "estimate", "p.value")],
.id = "site")
slopes
## # A tibble: 4 × 4
## site term estimate p.value
## <chr> <chr> <dbl> <dbl>
## 1 WS1 ppt 0.000834 7.59e-12
## 2 WS2 ppt 0.000660 1.30e- 9
## 3 WS3 ppt 0.000922 1.90e-12
## 4 WS4 ppt 0.000923 5.55e-10
# Suppose you have data/site_A.csv, data/site_B.csv, ...
csv_files <- list.files("data", pattern = "\\.csv$", full.names = TRUE)
all_data <- csv_files |>
set_names(~ tools::file_path_sans_ext(basename(.x))) |>
map_dfr(readr::read_csv, .id = "site")
The set_names() step labels each element with its
filename before mapping, so .id = "site" in
map_dfr() can populate a site column
automatically. No loop, no rbind() accumulation
pattern.
site_plots <- obs_df |>
split(~site) |>
imap(function(df, nm) {
ggplot(df, aes(ppt, ndvi)) +
geom_point(color = "#4a7c5e", alpha = .7, size = 2) +
geom_smooth(method = "lm", se = FALSE, color = "#c8832a", linewidth = .8) +
labs(title = nm, x = "Precipitation (mm)", y = "NDVI") +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(family = "serif", face = "bold",
color = "#1e3a2f"))
})
# Print just the first two
patchwork::wrap_plots(site_plots[1:2], ncol = 2)
imap() is a shortcut for
map2(.x, names(.x), .f) — it hands you both the element
and its name, which is useful any time you need the
label inside the function.
purrr Meets the Tidyversemap() returns lists. A data frame column can be
a list. Combining these two facts gives you list
columns — a powerful pattern for keeping model objects, plots,
or data frames associated with their metadata in a single tidy
tibble.
library(tidyr)
nested <- obs_df |>
nest(data = -site) |>
mutate(
model = map(data, ~ lm(ndvi ~ ppt, data = .x)),
r2 = map_dbl(model, ~ summary(.x)$r.squared),
coefs = map(model, broom::tidy)
)
nested |> select(site, r2)
## # A tibble: 4 × 2
## site r2
## <chr> <dbl>
## 1 WS1 0.874
## 2 WS2 0.804
## 3 WS3 0.889
## 4 WS4 0.818
# Unnest to recover a flat table of coefficients
nested |>
unnest(coefs) |>
filter(term == "ppt") |>
select(site, estimate, std.error, p.value)
## # A tibble: 4 × 4
## site estimate std.error p.value
## <chr> <dbl> <dbl> <dbl>
## 1 WS1 0.000834 0.0000659 7.59e-12
## 2 WS2 0.000660 0.0000679 1.30e- 9
## 3 WS3 0.000922 0.0000680 1.90e-12
## 4 WS4 0.000923 0.0000907 5.55e-10
🧩 Why list columns matter Without list columns,
per-group model objects have to live in separate named lists outside the
data frame, divorced from their metadata. List columns keep everything
together: the raw data, the model, its diagnostics, and its predictions
all in one row per group. unnest() and map_*()
let you extract exactly what you need downstream.
for loop when each step depends on the
last. Random walks, iterative algorithms, simulations with
carry-forward state — these cannot be expressed with map().
Pre-allocate with vector(“double”, n); never grow a vector
inside a loop.
sapply() in scripts and functions.
It silently changes its return type depending on the data. Use
vapply() (base R) or a typed map_*() variant
(purrr) so mismatches surface as errors, not silent wrong answers.
lapply() is honest: list in, list out,
always. It is perfectly fine for base-R workflows.
purrr::map() is essentially lapply() with a
consistent interface, typed variants, and better error messages.
map(.x, .f) is the core purrr pattern.
Feed a list or vector into a function, get a list back. Choose a typed
variant — map_dbl(), map_chr(),
map_dfr() — when you know the output type.
~ shorthand is idiomatic purrr. Write
~ expr and use .x for the current element. Use
(x) expr (R ≥ 4.1) when you need portability outside purrr.
map2() and pmap() supersede
mapply(). They put data first (pipeline-friendly)
and return consistently typed results when you use the typed variants.
walk() for side effects. Saving files,
printing output, writing messages — anything where the return value is
irrelevant.
nest()
+ mutate(map(…)) keeps models, plots, and summaries
tethered to their group metadata in a single tidy tibble.
stringr and regex for cleaning messy text datadplyr and tidyr for reshaping data
framesggplot2 for publication-quality graphics