Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

R (4.6)  Module 5 – Pipes, functionals and iteration

Iteration with purrr

Iterate in R with purrr 1.2: typed map functions, map2(), pmap() and imap(), failures caught with safely() and possibly(), results stacked with list_rbind().

  • Intermediate
  • 15 minutes
  • Examples run with R 4.6.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Map over lists and vectors with typed purrr functions
  • Iterate over two or more inputs with map2(), pmap() and imap()
  • Handle failures without stopping with safely() and possibly()
  • Combine results with list_rbind() and list_c()
  • Choose between purrr and base R functionals for a given piece of code

Before you start

What you’ll learn · Before you start

What you will learn

  • Map over lists and vectors with typed purrr functions
  • Iterate over two or more inputs with map2(), pmap() and imap()
  • Handle failures without stopping with safely() and possibly()
  • Combine results with list_rbind() and list_c()
  • Choose between purrr and base R functionals for a given piece of code

Before you start

On this page

Five clinics send a fee export each night, and one of them has the letter O typed where a zero should be: map(exports, read_fees) stops at that third export, and the four good tables are lost with it. purrr’s typed maps, its maps over several inputs and its two functions for failures let the same loop keep the four tables, name the one that failed and stack the rest into a single data frame.

On recorded runs of R 4.6.1 with purrr 1.2.2 you will map with functions that promise a type, walk two or more inputs with map2(), imap() and pmap(), catch failures with safely() and possibly(), stack results with list_rbind() and list_c(), and decide when the base functionals of the previous lesson are the better choice. purrr is a package: install it once with install.packages("purrr") and load it with library(purrr).

map() returns a list, and a typed map returns one type or stops

map(admissions, max) returns a list, like lapply(); map_dbl(admissions, mean) returns a vector of doubles. purrr’s reference page describes the typed maps, map_lgl(), map_int(), map_dbl() and map_chr(), as returning an atomic vector of the indicated type, or dying trying: every result must be one value of that type. A typed map is therefore type-stable: Said of a function whose result has a type and length you can tell from its inputs alone, not from their values. vapply() and purrr's map_dbl() are type-stable; sapply() is not., as vapply() is, without a template to write:

Function Each result must be Returns
map() anything a list
map_lgl() one TRUE or FALSE a logical vector
map_int() one whole number an integer vector
map_dbl() one number a double vector
map_chr() one string a character vector

The run tries each, then two results that break the promise, then the shorthand that extracts one element from each record:

map_typed.R, typed at the R console Python · map_typed_session.py
from session import run, version

version()
# R reads map_typed.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f map_typed.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f map_typed.R
> library(purrr)
> packageVersion("purrr")
[1] ‘1.2.2’
> source("wards.R")
> # map() always returns a list.
> map(admissions, max)
$ward_a
[1] 18

$ward_b
[1] 10

$ward_c
[1] 25

> # The typed maps return a vector of one type, or stop.
> map_dbl(admissions, mean)
   ward_a    ward_b    ward_c 
14.142857  8.142857 22.000000 
> map_int(admissions, \(d) sum(d > 15))
ward_a ward_b ward_c 
     2      0      7 
> map_lgl(admissions, \(d) any(d > 20))
ward_a ward_b ward_c 
 FALSE  FALSE   TRUE 
> map_chr(admissions, \(d) if (mean(d) > 15) "busy" else "normal")
  ward_a   ward_b   ward_c 
"normal" "normal"   "busy" 
> # A result of the wrong type stops the map, and the error names the element.
> tryCatch(map_dbl(admissions, \(d) if (max(d) > 24) "full" else max(d)), error = \(e) cat(conditionMessage(e), "\n"))
ℹ In index: 3.
ℹ With name: ward_c.
Caused by error:
! Can't coerce from a string to a double. 
> # map_chr() no longer turns numbers into text (purrr 1.2.0): convert them yourself.
> tryCatch(map_chr(admissions, max), error = \(e) cat(conditionMessage(e), "\n"))
ℹ In index: 1.
ℹ With name: ward_a.
Caused by error:
! Can't coerce from a number to a string. 
> map_chr(admissions, \(d) as.character(max(d)))
ward_a ward_b ward_c 
  "18"   "10"   "25" 
> # A name or a position extracts one element from each record.
> patients <- list(list(id = "P1", age = 34), list(id = "P2", age = 51))
> map_chr(patients, "id")
[1] "P1" "P2"
> map_dbl(patients, 2)
[1] 34 51

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 map_typed_session.py

The errors say where the map stopped: “In index: 3”, “With name: ward_c”, then the cause, “Can’t coerce from a string to a double”. The reference page adds that every error inside a map is wrapped this way, in an error of class purrr_error_indexed. The second error is a change of purrr 1.2.0: map_chr() no longer converts numbers to text, so map_chr(admissions, max) stops, and the fix is to say what you mean, \(d) as.character(max(d)). A string or a position in place of the function extracts an element from each record: map_chr(patients, "id") read each id.

Formulas such as ~ .x + 1

Older purrr code writes functions as formulas, map_dbl(x, ~ .x * 10), with .x for the argument. They still work, but purrr’s reference page marks them “No longer recommended”; write \(x) x * 10 instead.

These changes arrived in three releases, so code written for an older purrr may stop on a newer one:

Timeline · 3 events in order Three purrr releases that changed everyday code Walk through the releases in order. Code written for an older purrr may need the changes of a later one.
  1. purrr 1.0.0

    list_rbind() and list_c() arrive new functions

    list_c(), list_rbind() and list_cbind() combine the results of a map. map_dfr(), map_dfc() and flatten() became superseded, and map_chr() turning numbers into text became deprecated.

  2. purrr 1.1.0

    R 4.1 or later, and in_parallel() requirement

    purrr now needs R 4.1 or later, so its code relies on the native pipe and the \(x) shorthand. in_parallel() runs a map in parallel worker processes through the mirai package.

  3. purrr 1.2.0

    Old functions removed, map_chr() strict breaking change

    Every function deprecated in purrr 0.3.0 was removed, among them at_depth() and cross_d(). map_chr() no longer converts logical, integer or double results to text.

The timeline has 3 events, from purrr 1.0.0 to purrr 1.2.0.

Checkpoint

Check: map() and the typed maps

  1. Question 1 of 2 Which call returns a character vector with one string per element, or stops?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: map_chr(x, f)

    map_lgl(), map_int(), map_dbl() and map_chr() each promise a vector of their own type, one value per element.

  2. Question 2 of 2 What does this do with purrr 1.2.2?

    Read the code, then choose one answer.

    map_chr(c(a = 1.5, b = 2), \(v) v * 2)
    Show the answer: Question 2 of 2

    Answer: It stops, because map_chr() no longer turns numbers into text

    Write the conversion you mean inside the function: map_chr(x, \(v) as.character(v * 2)) or format(v * 2).

map2(), imap() and pmap() take several inputs at once

map2_int(admissions, targets, ...) counts each ward’s days above its own target, the job mapply() did in the previous lesson, with a typed result. imap() is map2() over each element and its name, or its position when there are no names. pmap() takes any number of inputs as a list, or the columns of a data frame, and calls the function once per row, matching columns to arguments by name. Watch it work through a grid of study sizes and effects:

Interactive · message by message pmap_dbl() calls the function once per row of the grid Step through pmap_dbl(grid, \(n, delta, sd) ...). Each call gets one row, its columns matched to the function's arguments by name.
pmap_dbl() calls the function once per row of the grid, step 5 of 5. Messages so far: pmap_dbl() to the function: n 20, delta 0.5, sd 1; the function to pmap_dbl(): returns 0.338; pmap_dbl() to the function: n 40, delta 0.5, sd 1; the function to pmap_dbl(): returns 0.869, 0.993.pmap_dbl()returned 4 doublesthe functionpower.t.test(...)$powern 20, delta 0.5, sd 1returns 0.338n 40, delta 0.5, sd 1returns 0.869, 0.993

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first row. The first row has n = 20, delta = 0.5 and sd = 1. pmap_dbl() passes the three columns to the arguments with the same names.
  2. Its power. With 20 people a group and an effect of half a standard deviation, a two-sample t-test has a power of only 0.338.
  3. Twice the people. The second row doubles n to 40. The power rises to 0.598, so twice the people did not give twice the power.
  4. A larger effect. Rows three and four take delta = 1, a whole standard deviation, and give 0.869 for 20 people and 0.993 for 40.
  5. One number per row. Every call returned one double, so pmap_dbl() returns a vector of four, which the run stores as a new column of the grid.

The run puts all three to work, and gives map2() two inputs whose lengths do not match:

several_inputs.R, typed at the R console Python · several_inputs_session.py
from session import run, version

version()
# R reads several_inputs.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f several_inputs.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f several_inputs.R
> library(purrr)
> source("wards.R")
> # map2() walks two inputs in step and returns a typed result.
> map2_int(admissions, targets, \(days, target) sum(days > target))
ward_a ward_b ward_c 
     3      1      4 
> map2_chr(c("x", "y"), 1:2, \(s, n) strrep(s, n))
[1] "x"  "yy"
> # Unlike mapply(), it recycles only an input of length 1, and stops otherwise.
> map2_dbl(1:3, 10, \(a, b) a * b)
[1] 10 20 30
> tryCatch(map2_int(1:4, 1:2, \(a, b) a + b), error = \(e) cat(conditionMessage(e), "\n"))
Can't recycle `.x` (size 4) to match `.y` (size 2). 
> # imap() passes each element with its name, or with its position when there are no names.
> imap_chr(targets, \(target, ward) paste(ward, "aims for", target))
              ward_a               ward_b               ward_c 
"ward_a aims for 14"  "ward_b aims for 9" "ward_c aims for 21" 
> imap_chr(c(70, 82), \(score, i) paste("candidate", i, "scored", score))
[1] "candidate 1 scored 70" "candidate 2 scored 82"
> # pmap() takes a list of inputs, or a data frame, and calls once per row.
> grid <- expand.grid(n = c(20, 40), delta = c(0.5, 1), sd = 1)
> grid$power <- pmap_dbl(grid, \(n, delta, sd) power.t.test(n = n, delta = delta, sd = sd)$power)
> grid$power <- round(grid$power, 3)
> grid
   n delta sd power
1 20   0.5  1 0.338
2 40   0.5  1 0.598
3 20   1.0  1 0.869
4 40   1.0  1 0.993

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 several_inputs_session.py

The grid answers a planning question in one call: with 20 people a group, a two-sample t-test detects an effect of half a standard deviation with a power of 0.338, and with 40 people 0.598, while an effect of a whole standard deviation reaches 0.869 and 0.993. The unequal lengths show a difference from base R that protects you: map2() recycles only an input of length 1, such as the 10 in map2_dbl(1:3, 10, ...), and stops with “Can’t recycle .x (size 4) to match .y (size 2)” where mapply() would have recycled without a word.

Checkpoint

Check: map2(), imap() and pmap()

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    imap_chr(c(a = 1, b = 2), \(v, name) paste(name, v))
    Show the answer: Question 1 of 2

    Answer: "a 1" and "b 2", named a and b

    imap(x, f) is map2(x, names(x), f) for a named input and map2(x, seq_along(x), f) otherwise.

  2. Question 2 of 2 What does map2_int(1:4, 1:2, \(a, b) a + b) do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It stops, because only an input of length 1 is recycled

    map2_dbl(1:3, 10, ...) shows the allowed case: a single value is used for every element.

safely() and possibly() keep a loop going after a failure

map(exports, read_fees) stops at the third export and returns nothing, although four of the five exports were fine. purrr calls safely() and possibly() adverb: In purrr, a function that takes a function and returns a changed version of it, such as safely() and possibly(): it modifies a verb, hence the name.: each takes a function and returns a changed version of it. possibly(f, otherwise = NULL) returns the fallback where f would fail. safely(f) always succeeds and returns a list of two, result and error, one of them NULL. Follow the five exports through possibly():

Interactive · stage by stage Five exports through possibly(read_fees) Step through the map. Each stage is one export; possibly() turns the one that fails into NULL instead of stopping the map.
Five exports through possibly(read_fees), step 5 of 5. Showing aundh (a table of 2 rows), baner (a table of 1 row), hadapsar (NULL, the fallback), kothrud (a table of 3 rows), wakad (a table of 0 rows).aundha table of 2 rowsbanera table of 1 rowhadapsarNULL, the fallbackkothruda table of 3 rowswakada table of 0 rows

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first exports. read_fees() reads Aundh's export into a data frame of 2 rows, then Baner's into 1 row. Nothing has gone wrong yet.
  2. The bad export. Hadapsar's fee column holds 2O0, with a letter O. read.csv() stops with "scan() expected 'a real', got '2O0'". On its own, map() would stop here and return nothing at all.
  3. Caught. possibly() catches the error and returns its fallback, NULL, as Hadapsar's result. The map moves on to the next export.
  4. The rest. Kothrud's export gives 3 rows. Wakad's has a header and no visits, so it gives a table of 0 rows, which is not an error.
  5. Five results. The map returns a list of five, named after the clinics. Four hold tables and one holds NULL, which map_lgl(tables, is.null) points out.

The run shows the plain map stopping, then the two adverbs:

read_many.R, typed at the R console Python · read_many_session.py
from session import run, version

version()
# R reads read_many.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f read_many.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f read_many.R
> library(purrr)
> source("exports.R")
> # One bad export stops map(), and the error says which one.
> tryCatch(map(exports, read_fees), error = \(e) cat(conditionMessage(e), "\n"))
ℹ In index: 3.
ℹ With name: hadapsar.
Caused by error in `scan()`:
! scan() expected 'a real', got '2O0' 
> # possibly() returns a fallback instead of the error, here NULL.
> tables <- map(exports, possibly(read_fees, otherwise = NULL))
> map_lgl(tables, is.null)
   aundh    baner hadapsar  kothrud    wakad 
   FALSE    FALSE     TRUE    FALSE    FALSE 
> # safely() returns each result with its error, so failures can be reported.
> attempts <- map(exports, safely(read_fees))
> str(attempts$hadapsar, max.level = 1)
List of 2
 $ result: NULL
 $ error :List of 2
  ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"
> failed <- keep(attempts, \(a) !is.null(a$error))
> map_chr(failed, \(a) conditionMessage(a$error))
                             hadapsar 
"scan() expected 'a real', got '2O0'" 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_many_session.py

The plain map’s error named the export, “With name: hadapsar”, and the cause, scan() expecting a number and finding 2O0. With possibly() the map finished and map_lgl(tables, is.null) marked Hadapsar as the one that failed. With safely(), Hadapsar’s attempt held a NULL result and the error, so keep(), purrr’s version of Filter(), picked out the failures and map_chr() turned them into a report of one line per file.

A fallback that hides the failure

possibly(read_fees, otherwise = NULL) keeps the job running, but on its own nobody learns that Hadapsar’s export was lost. Use safely() when failures must be reported, or count the fallbacks and stop the job, or warn, when there are any.

Checkpoint

Check: Failures with safely() and possibly()

  1. Question 1 of 2 What does safely(log)("a") return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A list with result NULL and error, the error object

    purrr's reference page: safely() creates a modified version of .f that always succeeds, returning a list with components result and error.

  2. Question 2 of 2 What does this return?

    Read the code, then choose one answer.

    map_dbl(list(1, "a", 100), possibly(log, otherwise = NA_real_))
    Show the answer: Question 2 of 2

    Answer: 0, NA and 4.60517

    The fallback must fit the typed map: NA_real_ for map_dbl(), NA_character_ for map_chr(), NULL only for map().

The results are stacked into one table or one vector

list_rbind(tables, names_to = "clinic") turns the four tables into one data frame of 6 rows, with a column that says which clinic each row came from. purrr 1.0.0 added list_c(), list_rbind() and list_cbind() to combine the results of a map, and made the older map_dfr() and map_dfc() superseded: A lifecycle stage of a function that still works and is kept but has a better replacement, such as list_rbind(map(...)) for purrr's map_dfr().: they still work, but list_rbind(map(...)) is the form to write. Step through the stacking:

Interactive · stage by stage list_rbind() stacks the tables into one Step through list_rbind(tables, names_to = "clinic") on the five results of the map.
list_rbind() stacks the tables into one, step 4 of 4. Showing tables, NULL results dropped (hadapsar skipped), names_to = "clinic" (clinic column added), fees, one data frame (6 rows: clinic, visit, fee).tablesa list of 5 resultsNULL results droppedhadapsar skippednames_to = "clinic"clinic column addedfees, one data frame6 rows: clinic, visit, fee

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The input. tables holds four data frames and one NULL, named aundh, baner, hadapsar, kothrud and wakad.
  2. Nothing to stack. list_rbind() skips the NULL that possibly() left for Hadapsar. Wakad's table has no rows, so it adds no rows either.
  3. Where each row came from. names_to = "clinic" adds a first column holding the name of the list element each row came from.
  4. One table. The rows are stacked in list order into one data frame of 6 rows, 2 from Aundh, 1 from Baner and 3 from Kothrud.

The run stacks the tables, joins the fee vectors with list_c(), and prints one line per clinic with iwalk(), the side-effect form of imap():

combine.R, typed at the R console Python · combine_session.py
from session import run, version

version()
# R reads combine.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f combine.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f combine.R
> library(purrr)
> source("exports.R")
> tables <- map(exports, possibly(read_fees, otherwise = NULL))
> # list_rbind() stacks the data frames; names_to adds a column from the list's names.
> fees <- list_rbind(tables, names_to = "clinic")
> fees
   clinic visit fee
1   aundh     1 250
2   aundh     2 400
3   baner     1 300
4 kothrud     1 500
5 kothrud     2 500
6 kothrud     3 200
> # The failed export left NULL, which list_rbind() skipped; Wakad's export had no rows.
> table(fees$clinic)

  aundh   baner kothrud 
      2       1       3 
> # list_c() joins vectors of any lengths into one vector.
> list_c(map(tables, \(t) t$fee))
[1] 250 400 300 500 500 200
> # walk() is map() for side effects, such as printing, and returns its input invisibly.
> iwalk(compact(tables), \(t, clinic) cat(clinic, "visits:", nrow(t), "\n"))
aundh visits: 2 
baner visits: 1 
kothrud visits: 3 
wakad visits: 0 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 combine_session.py

table(fees$clinic) shows 2 rows from Aundh, 1 from Baner and 3 from Kothrud: Hadapsar’s NULL was skipped, and Wakad’s export had no visits that night. list_c() joined the six fees into one vector. walk() and iwalk() call the function for what it does, here printing, and return their input invisibly, so they suit printing, writing files and plotting; compact() dropped the NULL before the walk.

Key idea

Map over the inputs, keep failures as values with safely() or possibly(), then combine the good results once, with list_rbind() or list_c(), at the end.

Checkpoint

Check: Combining results with list_rbind() and list_c()

  1. Question 1 of 2 What does names_to = "clinic" add in list_rbind(tables, names_to = "clinic")?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A first column, clinic, holding the name of the list element each row came from

    In the run, fees had 2 rows from aundh, 1 from baner and 3 from kothrud.

  2. Question 2 of 2 What does this return?

    Read the code, then choose one answer.

    list_c(list(1:2, 3, 4:5))
    Show the answer: Question 2 of 2

    Answer: 1 2 3 4 5

    list_c() is c() applied to all the elements of a list at once.

purrr or base R: the same loops, different promises

Every map in this lesson has a base R twin, and the run confirms that they give identical() results for the wards of the first section. The choice is therefore not about what can be computed, but about what each call promises when the data turn out to be unusual, and what it costs to have:

Task purrr Base R
One result per element, as a list map(x, f) lapply(x, f)
One number per element map_dbl(x, f) vapply(x, f, numeric(1))
Two inputs in step map2(x, y, f), stops on unequal lengths Map(f, x, y), recycles
One call per row pmap(df, f) Map(f, df$a, df$b) or a loop
Keep going after an error possibly(), safely() tryCatch() inside the function
Stack data frames list_rbind(l, names_to = "id") do.call(rbind, l)
purrr_or_base.R, typed at the R console Python · purrr_or_base_session.py
from session import run, version

version()
# R reads purrr_or_base.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f purrr_or_base.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f purrr_or_base.R
> library(purrr)
> source("wards.R")
> # The same results with purrr and with base R.
> identical(map_dbl(admissions, mean), vapply(admissions, mean, numeric(1)))
[1] TRUE
> identical(map(admissions, max), lapply(admissions, max))
[1] TRUE
> identical(map2_int(admissions, targets, \(d, t) sum(d > t)), mapply(\(d, t) sum(d > t), admissions, targets))
[1] TRUE
> # What purrr brings with it: the packages it imports.
> cat(packageDescription("purrr")$Imports, "\n")
cli (>= 3.6.1), lifecycle (>= 1.0.3), magrittr (>= 1.5.0),
rlang (>= 1.1.1), vctrs (>= 0.6.3) 

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 purrr_or_base_session.py

purrr 1.2.2 imports five packages, cli, lifecycle, magrittr, rlang and vctrs, which a project or a package then depends on as well. In return it gives the same argument order everywhere, .x first, errors that name the element, recycling that refuses to guess, and in_parallel(), which purrr 1.1.0 added to run a map in parallel worker processes through the mirai package; this lesson’s runs do not use it.

Choosing

In analysis scripts that already load purrr, use it for the error messages and the typed results. In a package that should stay light, or in a short script, vapply(), Map() and do.call(rbind, ...) do the same work with no dependency.

Checkpoint

Check: Choosing between purrr and base R

  1. One question You are writing a small package with no dependencies so far, and one function needs one number per group. What do you use?

    Choose one answer.

    Show the answer: One question

    Answer: vapply(groups, f, numeric(1))

    In scripts that already load purrr, map_dbl() is the natural choice; in a light package, vapply() does the same job.

Practise: predict, then check

Before you run the four lines, write down the type and the values you expect from each.

Your predictions

Predict map_int(list(1:3, 4:9), length), map2_dbl(c(2, 3), c(10, 100), \(a, b) a * b), map_dbl(list(4, "x", 16), possibly(sqrt, otherwise = NA_real_)), and the rows of list_rbind(list(north = data.frame(n = 1:2), south = data.frame(n = 5)), names_to = "region"). Then type the lines into R.

practice.R, typed at the R console Python · practice_session.py
from session import run, version

version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> library(purrr)
> # 1. A typed map:
> map_int(list(1:3, 4:9), length)
[1] 3 6
> # 2. Two inputs:
> map2_dbl(c(2, 3), c(10, 100), \(a, b) a * b)
[1]  20 300
> # 3. A fallback:
> map_dbl(list(4, "x", 16), possibly(sqrt, otherwise = NA_real_))
[1]  2 NA  4
> # 4. Stacking with a source column:
> list_rbind(list(north = data.frame(n = 1:2), south = data.frame(n = 5)), names_to = "region")
  region n
1  north 1
2  north 2
3  south 5

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

The lengths 3 and 6 came back as integers; the products 20 and 300 as doubles; possibly() turned the failed square root of "x" into NA between 2 and 4; and the stacked table has three rows, two from north and one from south.

When you are ready, answer the questions below. They start where your answers have been weakest, and each choice explains the promise or the rule it relies on:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 A map stops with "In index: 7. With name: ward_g. Caused by error: Can't coerce from a string to a double." Where do you look?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: At what the function returned for the seventh element, ward_g, which was text where a number was promised

    Every error inside a map comes wrapped in an error of class purrr_error_indexed, with the index and, for a named input, the name.

  2. Question 2 of 11 What does the last line return?

    Read the code, then choose one answer.

    patients <- list(list(id = "P1", age = 34), list(id = "P2", age = 51))
    map_dbl(patients, "age")
    Show the answer: Question 2 of 11

    Answer: 34 51

    map_chr(patients, "id") in the run read each id the same way, and map_dbl(patients, 2) took the second element.

  3. Question 3 of 11 Old code reads map_dbl(x, ~ .x / sum(.x)). What do you do with it?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: Rewrite it as \(v) v / sum(v); the formula still works but is no longer recommended

    purrr's own examples use the native pipe and \(x) since purrr 1.0.0.

  4. Question 4 of 11 grid has the columns n, delta and sd. Which function can pmap_dbl(grid, f) call?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: \(n, delta, sd) power.t.test(n = n, delta = delta, sd = sd)$power

    A function with ... would accept the columns too, but naming them makes the call read like the grid.

  5. Question 5 of 11 Two vectors read from two different files should have one value per patient each. Why might map2() be a safer choice than mapply() to walk them?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: If one file lost a row, map2() stops on the unequal lengths while mapply() recycles and gives wrong pairs

    Matching by id first, for example with match() or a join, is still the real fix when the order may differ.

  6. Question 6 of 11 Why does purrr call safely() and possibly() adverbs?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: They take a function and return a changed version of it, as an adverb modifies a verb

    The reference page of safely(): this function is called an adverb because it modifies the effect of a function (a verb).

  7. Question 7 of 11 A nightly job reads 300 files. Bad files must be skipped, every failure listed with its file name, and the scheduler must still learn that something failed. Which design fits?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: map() with safely(), stack the good results, write the failures to a report, then stop with an error at the end if there were any

    Keeping failures as values with safely() and deciding at the end is the pattern of the run, where keep() picked out Hadapsar's error.

  8. Question 8 of 11 Old code reads map_dfr(files, read_one). What do you write instead?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: list_rbind(map(files, read_one))

    Add names_to = "file" when the files are named, so each row keeps its source.

  9. Question 9 of 11 Which function suits printing one line for each table in a named list?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: iwalk()

    The run printed "aundh visits: 2" and the other clinics with iwalk(), after compact() dropped the NULL.

  10. Question 10 of 11 What is the base R twin of map_dbl(x, f)?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: vapply(x, f, numeric(1))

    map() pairs with lapply(), map2() with Map(), and list_rbind() with do.call(rbind, ...).

  11. Question 11 of 11 What does a project take on when it starts to use purrr 1.2.2?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: purrr and the five packages it imports, cli, lifecycle, magrittr, rlang and vctrs

    For a script that already loads purrr, the cost is paid; for a light package it is a real choice.

Interview questions

Warm-up: what does map_dbl() promise that map() does not?

map() returns a list with one element per input, and each element can be anything, so the caller has to check what came back. map_dbl() promises a double vector of the same length as its input, one number per element, with the input’s names. If a result is not a single number, for example text, a vector of two values or NULL, it stops with an error that gives the index and the name of the element, wrapped in the class purrr_error_indexed. That makes it type-stable, like vapply(x, f, numeric(1)). Since purrr 1.2.0 the typed maps also refuse conversions they used to make: map_chr() no longer turns numbers into text, so write as.character() inside the function when that is the intent.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A nightly job reads 300 CSV exports with map(files, read_one) and stops when one file is malformed, so the other 299 are lost for the night. Restructure it so the good files are processed, each failure is reported with its file name, and the scheduler still learns that something went wrong.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    After an update to purrr 1.2, a report that ran for years stops with "Can't coerce from a number to a string" in a call to map_chr(). What changed, how do you fix the call, and how do you find every other call with the same problem before it fails?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • map() returns a list, and map_lgl(), map_int(), map_dbl() and map_chr() return a vector of their type or stop with an error that names the element; since purrr 1.2.0 map_chr() no longer turns numbers into text.
  • map2() walks two inputs in step and refuses unequal lengths, imap() passes each element with its name or position, and pmap() calls the function once per row of a data frame, matching columns to arguments by name.
  • possibly() replaces a failure with a fallback, and safely() returns every result with its error, so one bad input no longer stops the loop and the failure can still be reported.
  • list_rbind(), with names_to for the source, and list_c() combine the results at the end, replacing the superseded map_dfr(); walk() and iwalk() are for side effects such as printing.
  • purrr and base R give identical results; purrr adds clearer errors and stricter recycling at the cost of five imported packages.

Terms in this lesson

adverb
In purrr, a function that takes a function and returns a changed version of it, such as safely() and possibly(): it modifies a verb, hence the name.
superseded
A lifecycle stage of a function that still works and is kept but has a better replacement, such as list_rbind(map(...)) for purrr's map_dfr().
type-stable
Said of a function whose result has a type and length you can tell from its inputs alone, not from their values. vapply() and purrr's map_dbl() are type-stable; sapply() is not.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.