Functional Programming with purrr
Reviewed & published by Brayan K
purrr is the tidyverse's functional-programming toolkit: it lets you apply a function to every element of a list or vector with map(), replacing hand-written loops with short, type-safe, composable pipelines.
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 learn map() and its typed variants, the ~ .x formula shorthand, parallel iteration with map2()/pmap(), folding with reduce(), and filtering with keep()/discard() — and how they compare to the base apply family.
What You'll Learn in This Lesson
- How map() applies a function to every element and returns a list
- Why typed variants map_dbl/map_chr/map_lgl/map_int are safer
- The ~ .x formula shorthand for anonymous functions
- Parallel iteration with map2() and pmap()
- Folding a list to one value with reduce()
- Filtering with keep()/discard() vs the base apply family
1️⃣ map() and the Typed Variants
The core verb is map(): give it a list or vector and a function, and it runs the function on each element, returning a list of the same length. You can pass a function by name, write one inline, or use the formula shorthand.
# map() applies a function to EVERY element of a list or vector and
# always returns a LIST — one result per input element.
library(purrr)
nums <- list(1, 2, 3, 4)
map(nums, function(x) x^2)
# [[1]] 1 [[2]] 4 [[3]] 9 [[4]] 16 (a list of 4)
# Pass an existing function by name — no wrapper needed:
map(c(1, 4, 9, 16), sqrt)
# A list: 1, 2, 3, 4A plain map() always returns a list, which is awkward if you wanted a numeric vector. The typed variants return an atomic vector of exactly that type — and error loudly if a result doesn't fit, catching bugs early.
# A bare map() always returns a list. The TYPED variants return an
# atomic vector and ERROR if the result isn't that type — a built-in
# safety check that base sapply() doesn't give you.
library(purrr)
map_dbl(c(1, 4, 9), sqrt) # -> numeric vector 1 2 3
map_chr(c(1, 2, 3), ~ paste0("n", .x)) # -> character "n1" "n2" "n3"
map_lgl(c(2, 5, 8), ~ .x > 4) # -> logical FALSE TRUE TRUE
map_int(1:3, ~ .x * 2L) # -> integer 2 4 6
# Because map_dbl() guarantees a double vector, you can feed its result
# straight into sum(), mean(), or a tibble column with no surprises.2️⃣ The ~ .x Shorthand & Parallel Maps
For short transformations, the ~ formula is the idiomatic purrr style: .x stands for the current element. It's just a compact way to write an anonymous function.
# The ~ formula is purrr shorthand for an anonymous function.
# Inside it, .x is the current element (and .y the second in map2).
library(purrr)
# These three lines are equivalent:
map_dbl(1:3, function(x) x * 10)
map_dbl(1:3, ~ .x * 10) # ~ + .x (most idiomatic)
map_dbl(1:3, \(x) x * 10) # R 4.1+ native lambda
# .x reads naturally for one-liners:
prices <- c(10, 25, 40)
map_chr(prices, ~ paste0("$", .x)) # "$10" "$25" "$40"When you need to walk two or more vectors in lockstep, reach for map2() (exactly two, with .x and .y) or pmap() (any number, passed as a list).
# map2() walks TWO vectors in parallel; pmap() walks ANY number,
# passed as a list of equal-length vectors.
library(purrr)
qty <- c(2, 3, 5)
price <- c(10, 20, 4)
map2_dbl(qty, price, ~ .x * .y) # element-wise product: 20 60 20
# pmap takes a list; name the pieces and use them by name (..1, ..2,
# or named args matching a function's parameters):
params <- list(
base = c(100, 200),
rate = c(0.1, 0.2)
)
pmap_dbl(params, function(base, rate) base * (1 + rate)) # 110 2403️⃣ reduce(), keep() & discard()
Not every job is one-to-one. reduce() folds a whole list into a single value, and keep()/discard() filter a list by a predicate. Together with map(), they let you express most iteration as one readable pipe.
# reduce() collapses a list to a SINGLE value by applying a 2-arg
# function repeatedly, carrying the running result forward.
library(purrr)
reduce(1:5, `+`) # 1+2+3+4+5 = 15
reduce(c(4, 9, 1, 7), max) # running max -> 9
reduce(list(c(1,2), c(2,3), c(3,4)), union) # combine sets
# keep()/discard() filter a list by a predicate (like Filter):
keep(1:10, ~ .x %% 2 == 0) # even numbers: 2 4 6 8 10
discard(1:10, ~ .x %% 2 == 0) # the odds: 1 3 5 7 9
# compose nicely in a pipe:
1:10 |> keep(~ .x > 3) |> map_dbl(~ .x^2) |> reduce(`+`)Notice the final pipe: keep then map_dbl then reduce. Each verb does one thing, and the data flows left to right — far easier to read and modify than a nested for loop.
Your turn. Fill in the ___ blank and run it.
# 🎯 YOUR TURN — replace each ___ then run it.
library(purrr)
words <- c("apple", "kiwi", "banana", "fig")
# 1) Use map_int() with nchar to get each word's length.
lengths <- map_int(words, ___) # 👉 replace ___ with nchar
print(lengths) # 5 4 6 3
# 2) Keep only the words longer than 4 letters.
long_words <- keep(words, ~ nchar(.x) > 4)
print(long_words) # "apple" "banana"
# 3) Use reduce() with paste to join all words into one string,
# separated by " - ".
joined <- reduce(words, ~ paste(.x, .y, sep = " - "))
print(joined) # "apple - kiwi - banana - fig"Mini-Challenge: Per-Group Means
This is the everyday purrr task: summarising a list of vectors without a single loop. Combine map_dbl(), map_int(), keep(), and reduce().
# 🎯 MINI-CHALLENGE: per-group means without a loop.
#
# You have sales for three regions stored as a named list of vectors:
# sales <- list(east = c(10, 20, 30),
# west = c(5, 15),
# north = c(100, 50, 25, 25))
#
# 1. Use map_dbl() to get the MEAN of each region in one call.
# 2. Use map_int() to count how many months each region has (length).
# 3. Use keep() to keep only the regions whose mean is above 20.
# 4. Bonus: reduce() the original list with c() to flatten every
# number into one big vector, then take its overall mean.
#
# Write it yourself, then run in RStudio or rdrr.io/snippets.
# your code hereCommon Errors (and the fix)
- ❌ map_dbl() errors with "must be a single double" — ✅ your function returned the wrong type or length; use plain map(), or fix the function to return one number.
- ❌ Using .x outside a ~ formula — ✅ .x only exists inside purrr's formula shorthand; in a normal function(x) use x.
- ❌ map2() length mismatch — ✅ the two inputs must be the same length (or one of length 1); check with length().
- ❌ Forgetting to library(purrr) — ✅ load the package (or library(tidyverse)) before using map().
Pro Tips
- 💡 Extract by name or position: map_chr(records, "name") pulls the name element from each list — a shortcut for ~ .x$name.
- 💡 Build data frames: map_dfr() row-binds each result into one tibble — ideal for reading many files.
- 💡 Side effects only: use walk() instead of map() when you just want to print or save and don't need the return value.
📋 Quick Reference — purrr
| Task | R Syntax |
|---|---|
| Map → list | map(x, f) |
| Map → numeric | map_dbl(x, ~ .x * 2) |
| Two inputs | map2_dbl(x, y, ~ .x + .y) |
| Many inputs | pmap_dbl(list(a, b), f) |
| Fold to one value | reduce(x, `+`) |
| Filter a list | keep(x, ~ .x > 0) |
🎉 Lesson Complete!
- ✅ map() applies a function to each element and returns a list
- ✅ Typed variants (map_dbl, map_chr, …) return a safe atomic vector
- ✅ ~ .x is shorthand for an anonymous function
- ✅ map2()/pmap() iterate over several inputs at once
- ✅ reduce() folds to one value; keep()/discard() filter
Practice quiz
What does a bare map() always return?
- A numeric vector
- A list, one element per input
- A data frame
- A single value
Answer: A list, one element per input. map() always returns a list of the same length as its input.
Which variant guarantees a double (numeric) vector or errors?
- map_dbl()
- map()
- map_chr()
- map_lgl()
Answer: map_dbl(). map_dbl() returns a double vector and errors if a result is not a single double.
In a ~ formula, what does .x refer to?
- The output list
- The function name
- The current element being processed
- The index position
Answer: The current element being processed. .x is the current element inside purrr's formula shorthand.
Which function walks TWO vectors in parallel?
- pmap()
- map()
- reduce()
- map2()
Answer: map2(). map2() iterates over exactly two vectors, exposing .x and .y.
What does reduce(1:5, `+`) return?
- 15
- A list of 5 sums
- 120
- 5
Answer: 15. reduce() folds with + : 1+2+3+4+5 = 15.
Which helper keeps only elements matching a predicate?
- discard()
- keep()
- map_lgl()
- filter()
Answer: keep(). keep() retains elements for which the predicate is TRUE; discard() removes them.
Which typed variant returns a character vector?
- map_int()
- map_dbl()
- map_chr()
- map_lgl()
Answer: map_chr(). map_chr() returns a character vector, erroring on a type mismatch.
How do map() and reduce() differ?
- map() folds to one value; reduce() is one-to-one
- map() is one-to-one; reduce() folds many into one value
- They are identical
- reduce() only works on numbers
Answer: map() is one-to-one; reduce() folds many into one value. map() transforms each element independently; reduce() collapses the list to a single value.
Why are typed map_*() variants safer than base sapply()?
- They run faster
- They print nicer output
- They promise an exact type or error, never silently guess
- They avoid loading packages
Answer: They promise an exact type or error, never silently guess. sapply() guesses its return type; the typed map variants guarantee one or fail loudly.
How do you access the value of a reactive-style purrr formula's second input in map2()?
- .z
- .x only
- .y
- ..2 only
Answer: .y. In map2(), .x is the first vector's element and .y the second's.
Continue this course
- Previous: Reshaping: pivot_longer & pivot_wider
- Next: Dates with lubridate — Parse, extract from and do arithmetic with dates the easy way
- Quick reference: R cheat sheet
Frequently asked questions
How is purrr's map() different from base sapply() and lapply()?
lapply() is closest to map(): both apply a function to each element and return a list. The big wins with purrr are consistency and safety. map() always returns a list; the typed variants (map_dbl, map_chr, ...) always return that exact atomic type or error — whereas sapply() guesses and can silently hand you a list, a vector, or a matrix depending on the data, which causes subtle bugs. purrr also adds the ~ .x shorthand, map2/pmap, and predicate helpers like keep/discard that base R spreads across several differently-named functions.
When should I use map_dbl vs map vs map_chr?
Pick the variant that matches the type you expect back. Use map() when each result is itself complex (a vector, data frame, or model) so a list is the right container. Use map_dbl() when every result is a single number, map_chr() for a single string, map_lgl() for TRUE/FALSE, and map_int() for whole numbers. The typed versions are preferable whenever they apply because the error you get on a type mismatch catches mistakes immediately instead of downstream.
What exactly do .x and .y mean in ~ formulas?
They're the auto-supplied argument names inside purrr's formula shorthand. In map(x, ~ ...), .x is the current element. In map2(x, y, ~ ...), .x is from the first vector and .y from the second. In pmap(), the positional arguments are ..1, ..2, ..3, or you can name a regular function's parameters. The formula form (~ .x * 2) is just a terse way to write function(x) x * 2; for anything longer than a line, a named function or the \(x) lambda is clearer.
What's the difference between map() and reduce()?
map() is one-to-one: n inputs produce n outputs, transforming each element independently. reduce() is many-to-one: it folds the whole list into a single value by repeatedly applying a two-argument function, carrying the accumulated result forward (sum, running max, merging data frames, unioning sets). A common pattern is to map() a transformation across elements and then reduce() the results together — for example map() each CSV to a data frame, then reduce() with bind_rows() to stack them.