Functional Programming with purrr
If you find yourself writing the same for loop pattern across your R code, purrr deserves a place in your workflow. The purrr package brings a consistent, composable set of tools for applying functions to vectors and lists. Instead of managing loop variables and pre-allocating results, you express what you want to happen to each element, and purrr handles the iteration.
This tutorial covers the core map family, shows how they chain with the pipe operator, and introduces the error-handling utilities that make purrr production-ready.
What you’ll learn
This tutorial covers the key concepts and practical techniques for working with Functional Programming with purrr. By the end, you will know how to apply the core functions in real data analysis workflows.
Getting started with map()
The map() function applies a function to every element of a vector or list and always returns a list:
library(purrr)
x <- list(1:3, 4:6, 7:9)
map(x, sum)
# [[1]]
# [1] 6
# [[2]]
# [1] 15
# [[3]]
# [1] 24
The first argument is your input. The second is the function to apply. You can pass a real function, an anonymous function defined with ~, or even a numeric index for extraction:
df <- data.frame(a = 1:3, b = 4:6)
# Extract column "a" from df
map(df, "a")
# $a
# [1] 1 2 3
# $b
# NULL
# Extract by index
map(df, 1)
# $a
# [1] 1 2 3
The ~ formula syntax creates anonymous functions on the fly. Inside the formula, .x refers to the current element:
map_dbl(x, ~ mean(.x))
# [1] 2 5 8
Typed maps: predictable output every time
The base R sapply() function tries to simplify its output, which sometimes returns a vector and sometimes returns a list, you have to check. purrr’s typed variants eliminate this guesswork. Each typed map expects a specific output type and throws an error if the function returns something incompatible.
| Function | Returns |
|---|---|
map_lgl() | logical vector |
map_int() | integer vector |
map_dbl() | numeric vector |
map_chr() | character vector |
temps <- list(c(72, 75, 68), c(81, 79, 85), c(62, 58, 65))
# map() would return a list of numeric vectors
# map_dbl() returns one clean numeric vector
map_dbl(temps, mean)
# [1] 71.66667 81.66667 61.66667
Use typed maps when you know what type you expect. The error-on-mismatch behavior catches bugs early rather than letting incompatible types propagate through your pipeline.
Mapping over two inputs with map2()
When you need to apply a function to two inputs in parallel, map2() does exactly that. It pairs elements from two vectors or lists and processes them together:
x <- c(10, 20, 30)
y <- c(1, 2, 3)
map2_dbl(x, y, ~ .x / .y)
# [1] 10 10 10
Inside the formula, .x refers to elements from the first input and .y from the second. This is particularly useful for operations that need two datasets aligned element-wise.
Handling three or more inputs with pmap()
For operations that need three or more inputs, pmap() maps over a list of vectors positionally. Each vector in the list provides one argument to the function:
df <- data.frame(
x = 1:3,
y = c(10, 20, 30),
z = c(100, 200, 300)
)
pmap_dbl(df, sum)
# [1] 111 231 351
pmap() is especially natural with data frames, where each row represents one observation and each column represents one variable. You can pass any function to pmap(), named arguments get matched by name from the list, unnamed by position.
Side effects with walk() and iwalk()
Not every operation produces a return value. Sometimes you want to print something, save a file, or generate a plot. walk() calls a function for its side effects and returns the original input invisibly, making it safe to use mid-pipe:
files <- c("report_a.csv", "report_b.csv", "report_c.csv")
# Save each dataset to its own file
# walk() returns the filenames, which is useful for chaining
files %>%
walk(~ write.csv(get(.x), .x))
iwalk() adds an index or name as a second argument. This comes in handy when you need the name alongside the value:
configs <- list(api_key = "abc123", endpoint = "https://example.com", timeout = 30)
iwalk(configs, ~ message(str_c(.y, " = ", .x)))
# api_key = abc123
# endpoint = https://example.com
# timeout = 30
The pronoun .y carries the name, .x carries the value.
Filtering with keep() and discard()
The keep() and discard() functions filter a list or vector based on a predicate. keep() retains elements where the predicate is TRUE; discard() removes them:
x <- list(a = 1, b = NULL, c = 3, d = integer(0))
keep(x, ~ length(.x) > 0)
# $a
# [1] 1
# $c
# [1] 3
discard(x, ~ length(.x) > 0)
# $b
# NULL
# $d
# integer(0)
compact() is a shorthand for discarding empty elements (anything with length zero):
compact(x)
# $a
# [1] 1
# $c
# [1] 3
These functions work on any vector or list, not just lists.
Folding with reduce() and accumulate()
reduce() takes a list and applies a function cumulatively until only one value remains. It is useful for collapsing a list into a single object:
list_of_dfs <- list(
data.frame(x = 1:3),
data.frame(y = 4:6),
data.frame(z = 7:9)
)
reduce(list_of_dfs, full_join)
accumulate() is the same operation but returns every intermediate result:
1:5 %>% accumulate(`+`)
# [1] 1 3 6 10 15
Both accept an .init argument if you need to start with a seed value rather than the first element of the list.
Error handling with safely() and possibly()
Production code needs to handle failures gracefully. safely() wraps a function so it never throws an error, instead, it returns a list with result and error elements:
safe_read <- safely(read.csv)
result <- safe_read("possibly_missing.csv")
result$result # NULL if file didn't exist
result$error # NULL if successful, error object otherwise
possibly() is simpler: it returns a default value when the function fails:
map_chr(list_files, possibly(read_file, otherwise = "FILE NOT FOUND"))
You can compose safely() and possibly() together for layered error handling strategies.
Accessing nested data with pluck() and chuck()
When working with deeply nested lists, pluck() extracts elements by path:
api_response <- list(
data = list(
users = list(
list(name = "Alice", age = 30),
list(name = "Bob", age = 25)
)
)
)
pluck(api_response, "data", "users", 1, "name")
# [1] "Alice"
pluck() returns NULL with a warning if the path doesn’t exist. chuck() does the same thing but throws an error instead, use it when missing data is genuinely a bug rather than an expected condition.
Putting it together: a real pipeline
Here is how these functions compose in a realistic data pipeline. You have a list of URLs, you want to fetch each one, parse the JSON, extract a field, and save only the successful results:
library(purrr)
library(httr)
fetch_and_parse <- safely(function(url) {
resp <- GET(url)
content(resp, as = "parsed")
})
results <- urls %>%
map(fetch_and_parse) %>%
map("result") %>%
compact() %>%
map_chr("username")
The pipeline reads cleanly from top to bottom: fetch each URL, pull out the result, drop failures, extract the usernames. This kind of composition is where purrr shines.
Map variants for specific return types
map_dbl() returns a double vector. map_int() returns integer. map_chr() returns character. map_lgl() returns logical. If the function returns the wrong type, the variant throws an error immediately, this prevents hard-to-find type bugs downstream. Use typed variants when you know the expected return type.
map_dfr(list, fn) binds returned data frames by rows. map_dfc(list, fn) binds by columns. For iteration that returns nothing useful, walk(list, fn) calls fn for its side effects (writing files, printing) and returns the original list invisibly.
map2 and pmap
map2(x, y, fn) iterates over two lists in parallel: map2(1:3, 4:6, ~ .x + .y) returns c(5, 7, 9). pmap(list(x, y, z), fn) generalizes to any number of lists. When the lists are data frame columns, use the data frame directly: pmap(df, function(col1, col2, ...) ...). Argument names in the function must match column names.
Working with nested data
map() is the engine for nested data frame workflows. nest(df, data = -group) creates a list-column; mutate(models = map(data, ~ lm(y ~ x, .x))) fits a model per group; mutate(coefs = map(models, coef)) extracts coefficients. unnest(df, coefs) expands the results back to rows. This map-over-groups pattern replaces explicit for loops with a declarative pipeline.
Functional iteration philosophy
purrr’s map functions embody a different approach to iteration than for loops. A for loop describes how to iterate: initialize an index, increment it, and perform some action at each step. A map call describes what to do with each element, leaving the iteration mechanics implicit. The result is code that is easier to read because the reader can focus on the transformation rather than the iteration bookkeeping.
The functional approach also makes the type of result explicit. In a for loop, the result type depends on how you accumulate values, appending to a pre-allocated vector, using an index, or collecting into a list. With map, you state the desired output type: map returns a list, map_dbl returns a double vector, map_chr returns a character vector. The return type is part of the function name, not buried in accumulation logic.
Type-Stable mapping
The typed map variants — map_dbl, map_int, map_chr, map_lgl — are stricter than map. They require that the function returns exactly one value of the specified type from each input element. If the function returns the wrong type or length, map_dbl raises an error immediately. This strictness is a feature: it catches mistakes where a function returns NULL for some inputs or returns a vector instead of a scalar. Type-stable code is easier to reason about and debug.
map_dfr and map_dfc combine list results into data frames by binding rows or columns. These are convenient for the common pattern of applying a function that returns a data frame to a list of inputs and combining all results into one data frame. However, they require that all returned data frames have compatible schemas. When schemas might differ, use map and then dplyr’s bind_rows, which handles missing columns more gracefully.
Parallel mapping
map2 and pmap extend mapping to functions that take multiple arguments. map2 takes two lists and passes corresponding pairs to the function. pmap takes a list of lists (or a data frame, which is a list of columns) and passes corresponding elements of all lists as named arguments. This makes it natural to call a function with parameters drawn from different sources simultaneously.
A data frame of parameters is the natural input for pmap when each row represents a different set of arguments for a function call. This pattern replaces nested loops for parameter sweeps and simulation studies. pmap passes columns as named arguments if the data frame columns are named correctly to match the function parameters, which is both readable and reliable to column reordering.
Next steps
Now that you understand functional programming with purrr, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.
See also
- purrr Iteration Patterns — goes deeper into conditional mapping, working with grouped data, and common recipes
- dplyr Basics — the data manipulation complement to purrr in the tidyverse
- ggplot2 Facets and Themes — visualize data generated from purrr pipelines with faceted plots