Skip to main content
R advanced Lesson 10 of 10

Functions, purrr, and Reproducible Pipelines

Write functions that take column names, iterate with purrr instead of loops, catch failures without stopping, and pin an environment so the script runs the same next year.

A script that runs once on your laptop is not a pipeline. This lesson is the four things that turn it into one: functions that generalise, iteration that returns values, failures that do not stop the batch, and an environment that is the same next year.

Functions that take column names

The obvious version does not work:

suppressPackageStartupMessages(library(tidyverse))

orders <- tibble(
  channel = c("web","app","web","phone","app","web"),
  country = c("GB","US","GB","NL","US","GB"),
  status  = c("completed","completed","returned","completed","pending","completed"),
  amount  = c(25.50, 12.00, 40.00, 8.75, 63.20, 19.99)
)

summarise_by <- function(data, group_col, value_col) {
  data |> summarise(total = sum(value_col), n = n(), .by = group_col)
}

summarise_by(orders, channel, amount)
Error in `summarise()`:
ℹ In argument: `total = sum(value_col)`
Caused by error:
! object 'value_col' not found

dplyr evaluated value_col as a column name literally. {{ }} — “embrace” — says use what the caller passed:

summarise_by <- function(data, group_col, value_col) {
  data |>
    summarise(total = sum({{ value_col }}), n = n(), .by = {{ group_col }})
}

print(summarise_by(orders, channel, amount))
print(summarise_by(orders, country, amount))
# A tibble: 3 × 3
  channel total     n
  <chr>   <dbl> <int>
1 web      85.5     3
2 app      75.2     2
3 phone     8.75    1

# A tibble: 3 × 3
  country total     n
  <chr>   <dbl> <int>
1 GB      85.5      3
2 US      75.2      2
3 NL       8.75     1

Now the function is a dplyr verb. Name the output column after the input with := and englue():

summarise_by <- function(data, group_col, value_col) {
  data |>
    summarise(
      "{{ value_col }}_total" := sum({{ value_col }}),
      n = n(),
      .by = {{ group_col }}
    )
}
print(summarise_by(orders, channel, amount))
# A tibble: 3 × 3
  channel amount_total     n
  <chr>          <dbl> <int>
1 web            85.5      3
2 app            75.2      2
3 phone           8.75     1

For several columns, ... passes them straight through:

summarise_many <- function(data, ..., value_col) {
  data |> summarise(total = sum({{ value_col }}), n = n(), .by = c(...))
}
print(summarise_many(orders, channel, country, value_col = amount))
# A tibble: 4 × 4
  channel country total     n
  <chr>   <chr>   <dbl> <int>
1 web     GB      85.5      3
2 app     US      75.2      2
3 phone   NL       8.75     1
4 app     GB       0        0

Validate inputs

load_orders <- function(path, min_rows = 1L) {
  stopifnot(
    "path must be a single string" = is.character(path) && length(path) == 1,
    "file does not exist" = file.exists(path)
  )

  data <- readr::read_csv(path, show_col_types = FALSE)

  required <- c("order_id", "amount", "status")
  missing <- setdiff(required, names(data))
  if (length(missing)) {
    cli::cli_abort("Missing required column{?s}: {.field {missing}}")
  }
  if (nrow(data) < min_rows) {
    cli::cli_abort("Expected at least {min_rows} row{?s}, got {nrow(data)}")
  }
  data
}

load_orders("missing.csv")
Error in load_orders("missing.csv") : file does not exist
writeLines(c("order_id,total", "1001,25.50"), "wrong_schema.csv")
load_orders("wrong_schema.csv")
Error in `load_orders()`:
! Missing required columns: `amount` and `status`

cli_abort() handles pluralisation (column vs columns) and formatting, and the message names exactly what is wrong. A pipeline function should fail on its own preconditions rather than producing a confusing error three steps later.

purrr instead of loops

files <- c("orders_jan.csv", "orders_feb.csv", "orders_mar.csv")
for (f in files) writeLines(c("order_id,amount", "1001,25.50", "1002,12.00"), f)

# the loop
all_data <- list()
for (f in files) all_data[[f]] <- read_csv(f, show_col_types = FALSE)
combined_loop <- bind_rows(all_data, .id = "source")

# the map
combined <- map(files, \(f) read_csv(f, show_col_types = FALSE)) |>
  set_names(files) |>
  list_rbind(names_to = "source")

print(combined)
print(identical(dim(combined_loop), dim(combined)))
# A tibble: 6 × 3
  source         order_id amount
  <chr>             <dbl>  <dbl>
1 orders_jan.csv     1001   25.5
2 orders_jan.csv     1002   12  
3 orders_feb.csv     1001   25.5
4 orders_feb.csv     1002   12  
5 orders_mar.csv     1001   25.5
6 orders_mar.csv     1002   12  
[1] TRUE

Same result, and the map version is an expression — there is no partially-filled all_data to reason about if it fails halfway.

The typed variants are the real reason to prefer purrr:

print(map_dbl(list(1:5, 6:10, 11:15), sum))
print(map_chr(files, \(f) as.character(file.size(f))))
print(map_lgl(files, file.exists))

map_dbl(list(1:5, "not a number"), sum)
[1]  15  40  65
[1] "34" "34" "34"
[1] TRUE TRUE TRUE

Error in `map_dbl()`:
ℹ In index: 2.
Caused by error in `sum()`:
! invalid 'type' (character) of argument

map_dbl guarantees a double vector, so a wrong type fails at the element that caused it, with the index. A loop appending to a list would have produced a mixed list and failed somewhere else entirely.

Two inputs at once, and iteration with the index:

thresholds <- c(web = 20, app = 30, phone = 10)

print(map2_dbl(names(thresholds), thresholds,
               \(ch, t) sum(orders$amount[orders$channel == ch & orders$amount > t])))

print(imap_chr(thresholds, \(t, ch) str_glue("{ch}: threshold {t}")))
[1] 65.49 63.20  0.00
                    web                     app                   phone 
   "web: threshold 20"     "app: threshold 30"   "phone: threshold 10" 

walk() is map() for side effects — it returns its input invisibly, so it chains:

walk(files, \(f) cat("processing", f, "\n"))
processing orders_jan.csv 
processing orders_feb.csv 
processing orders_mar.csv 

Failures that do not stop the batch

read_maybe <- function(path) {
  if (!file.exists(path)) stop("no such file: ", path)
  read_csv(path, show_col_types = FALSE)
}

paths <- c("orders_jan.csv", "does_not_exist.csv", "orders_mar.csv")
map(paths, read_maybe)
Error in `map()`:
ℹ In index: 2.
Caused by error in `read_maybe()`:
! no such file: does_not_exist.csv

One bad file and two good ones were wasted. safely() captures instead:

safe_read <- safely(read_maybe)
results <- map(paths, safe_read)

ok     <- keep(results, \(r) is.null(r$error)) |> map("result")
failed <- keep(results, \(r) !is.null(r$error))

cat("succeeded:", length(ok), " failed:", length(failed), "\n")
walk2(paths[map_lgl(results, \(r) !is.null(r$error))], map(failed, \(r) conditionMessage(r$error)),
      \(p, m) cat("  FAILED", p, "-", m, "\n"))

combined <- list_rbind(ok)
cat("rows loaded:", nrow(combined), "\n")
succeeded: 2  failed: 1 
  FAILED does_not_exist.csv - no such file: does_not_exist.csv 
rows loaded: 4 

Two files processed, one reported by name. That is what a batch job should do — partial progress plus an explicit list of what needs attention, rather than all-or-nothing.

possibly() is the shorter form when you only need a default:

sizes <- map_dbl(paths, possibly(\(p) file.size(p), otherwise = NA_real_))
print(sizes)
[1] 34 NA 34

And insistently() retries transient failures:

fetch <- insistently(\(url) httr2::request(url) |> httr2::req_perform(),
                     rate = purrr::rate_backoff(pause_base = 1, max_times = 3))

Testing the functions

# tests/testthat/test-summarise.R
library(testthat)

test_that("summarise_by totals by group", {
  d <- tibble(g = c("a","a","b"), v = c(1, 2, 10))
  out <- summarise_by(d, g, v)

  expect_s3_class(out, "tbl_df")
  expect_equal(nrow(out), 2)
  expect_equal(out$v_total[out$g == "a"], 3)
})

test_that("load_orders rejects a missing column", {
  writeLines(c("order_id,total", "1,2"), tmp <- tempfile(fileext = ".csv"))
  expect_error(load_orders(tmp), "Missing required column")
})

test_that("empty input returns an empty result, not an error", {
  d <- tibble(g = character(), v = numeric())
  expect_equal(nrow(summarise_by(d, g, v)), 0)
})
Rscript -e 'testthat::test_dir("tests/testthat")'
✔ | F W  S  OK | Context
✔ |          3 | summarise                                                    

══ Results ═══════════════════════════════════════════════════════════════
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 3 ]

The third test is the one worth copying — empty input is the case that reaches production untested, because nobody thinks to try it.

Assert on the data too, not only the code:

validate_orders <- function(data) {
  problems <- c(
    if (anyDuplicated(data$order_id)) "duplicate order_id",
    if (any(is.na(data$amount))) str_glue("{sum(is.na(data$amount))} null amounts"),
    if (any(data$amount < 0, na.rm = TRUE)) "negative amounts",
    if (!all(data$status %in% c("completed","returned","refunded","pending")))
      str_glue("unexpected status: {paste(setdiff(unique(data$status), c('completed','returned','refunded','pending')), collapse=', ')}")
  )
  if (length(problems)) cli::cli_abort("Validation failed: {problems}")
  invisible(data)
}

bad <- tibble(order_id = c(1L, 1L), amount = c(25.5, -3), status = c("completed", "unknown"))
validate_orders(bad)
Error in `validate_orders()`:
! Validation failed: duplicate order_id, negative amounts, and unexpected status: unknown

All three problems in one message, so one run tells you everything to fix.

Pinning the environment

Rscript -e 'renv::init()'
* Initializing project ...
* Discovering package dependencies ... Done!
* Copying packages into the cache ... [47/47] Done!
The following package(s) will be updated in the lockfile:

# CRAN -----------------------------------------------------------------
- dplyr         [* -> 1.1.4]
- ggplot2       [* -> 3.5.1]
- tidyr         [* -> 1.3.1]
...
* Lockfile written to '~/bookshop/renv.lock'.
Rscript -e 'renv::snapshot()'
Rscript -e 'renv::status()'
* The project is already synchronized with the lockfile.

On another machine, or next year:

Rscript -e 'renv::restore()'
The following package(s) will be installed:
- dplyr   [1.1.4]
- ggplot2 [3.5.1]
...
Do you want to proceed? [Y/n]: Y
* Installing dplyr [1.1.4] ... OK

Commit renv.lock. Without it a dependency update changes results silently — and in analysis code you find out from the numbers rather than from an error, which is the worst way.

Record the environment in the output as well:

cat("R:", R.version.string, "\n")
cat("run at:", format(Sys.time(), tz = "UTC"), "UTC\n")
print(map_chr(c("dplyr","tidyr","ggplot2"), \(p) as.character(packageVersion(p))) |>
      set_names(c("dplyr","tidyr","ggplot2")))
R: R version 4.5.1 (2026-06-14) 
run at: 2026-09-10 08:14:02 UTC
  dplyr   tidyr ggplot2 
"1.1.4" "1.3.1" "3.5.1" 

A pipeline that skips unchanged work

# _targets.R
library(targets)
tar_option_set(packages = c("dplyr", "readr", "ggplot2"))

list(
  tar_target(raw_file, "data/orders.csv", format = "file"),
  tar_target(orders, read_csv(raw_file, show_col_types = FALSE)),
  tar_target(validated, validate_orders(orders)),
  tar_target(daily, summarise_by(validated, ordered_at, amount)),
  tar_target(plot, ggplot(daily, aes(ordered_at, amount_total)) + geom_line()),
  tar_target(report, ggsave("daily.png", plot, width = 8, height = 4), format = "file")
)
Rscript -e 'targets::tar_make()'
▶ dispatched target raw_file
● completed target raw_file [0.002 seconds]
▶ dispatched target orders
● completed target orders [0.184 seconds]
▶ dispatched target validated
● completed target validated [0.011 seconds]
▶ dispatched target daily
● completed target daily [0.042 seconds]
▶ dispatched target plot
● completed target plot [0.221 seconds]
▶ dispatched target report
● completed target report [0.402 seconds]
▶ ended pipeline [1.104 seconds]

Run it again with nothing changed:

✔ skipped target raw_file
✔ skipped target orders
✔ skipped target validated
✔ skipped target daily
✔ skipped target plot
✔ skipped target report
✔ skipped pipeline [0.088 seconds]

Everything skipped. Change one thing and only what depends on it re-runs:

# edit the plot target only
Rscript -e 'targets::tar_make()'
✔ skipped target raw_file
✔ skipped target orders
✔ skipped target validated
✔ skipped target daily
▶ dispatched target plot
● completed target plot [0.216 seconds]
▶ dispatched target report
● completed target report [0.398 seconds]
▶ ended pipeline [0.702 seconds]

targets hashes each target’s inputs and code, so the dependency graph is derived rather than declared — the same idea as ref() in dbt, applied to R objects. On a pipeline with a 40-minute model fit, this is the difference between iterating on a plot in seconds and in an hour.

Rscript -e 'targets::tar_visnetwork()'
Rscript -e 'targets::tar_read(daily)' 
# A tibble: 90 × 3
  ordered_at amount_total     n
  <date>            <dbl> <int>
1 2026-01-01         289.    12
2 2026-01-02         312.    14
# ℹ 88 more rows

tar_read() pulls any intermediate out of the store without re-running anything, which is how you debug a pipeline step without a script full of saveRDS calls.

Running it unattended

#!/usr/bin/env Rscript
# run_pipeline.R
suppressPackageStartupMessages(library(optparse))

opts <- OptionParser() |>
  add_option("--date", type = "character", default = as.character(Sys.Date() - 1),
             help = "logical date to process [default: yesterday]") |>
  add_option("--dry-run", action = "store_true", default = FALSE) |>
  parse_args()

run_date <- as.Date(opts$date)
cli::cli_inform("Processing {run_date}{if (opts$`dry-run`) ' (dry run)' else ''}")

result <- tryCatch({
  data <- load_orders(str_glue("data/orders_{run_date}.csv"))
  validate_orders(data)
  if (!opts$`dry-run`) write_results(data, run_date)
  list(status = "success", rows = nrow(data))
}, error = function(e) {
  cli::cli_alert_danger("Pipeline failed: {conditionMessage(e)}")
  list(status = "failed", error = conditionMessage(e))
})

jsonlite::write_json(c(result, list(run_date = as.character(run_date),
                                    finished_at = format(Sys.time(), tz = "UTC"))),
                     str_glue("logs/run_{run_date}.json"), auto_unbox = TRUE)

if (result$status == "failed") quit(status = 1)
Rscript run_pipeline.R --date 2026-01-04
ℹ Processing 2026-01-04
✔ Wrote 4,812 rows to daily_revenue
Rscript run_pipeline.R --date 2026-01-05; echo "exit: $?"
ℹ Processing 2026-01-05
✖ Pipeline failed: file does not exist
exit: 1

The three things a scheduler needs: a parameter for the window (lesson 4 of the data engineering track — never Sys.Date() inside the logic), a non-zero exit code on failure, and a machine-readable run record. Without the exit code, cron reports success on every run regardless of what happened.

0 5 * * *  cd /opt/bookshop && Rscript run_pipeline.R >> logs/cron.log 2>&1

Practice

1. Write a function taking a column name without {{ }}.
Error in `summarise()`:
! object 'value_col' not found

dplyr looked for a column literally named value_col. Embracing with {{ }} passes the caller’s expression through — it is what makes a function usable like a dplyr verb.

2. Use map_dbl over a list containing a wrong type.
Error in `map_dbl()`:
ℹ In index: 2.
! invalid 'type' (character) of argument

The failing index is named. map() would have returned a mixed list and failed somewhere unrelated later.

3. Read a batch of files where one is missing, with and without safely().
# map()
Error: no such file: does_not_exist.csv

# safely()
succeeded: 2  failed: 1
  FAILED does_not_exist.csv - no such file

Partial progress plus a named failure. For any batch over files, APIs or partitions, this is the pattern.

4. Run a targets pipeline twice, changing one step.
✔ skipped target orders
✔ skipped target daily
▶ dispatched target plot
● completed target plot [0.216 seconds]

Only the changed target and its dependents re-ran. The graph is derived from the code, so it cannot fall out of step with it the way a hand-maintained script order does.

That closes the R track. The thread through all ten lessons: the data frame is the unit of work, the same dplyr code serves analysis and pipelines, and everything hard — types, timezones, missing values, leakage, package versions — is a decision made explicit rather than left to a default.

Frequently Asked Questions

How do I pass a column name to a function in R?
Wrap the argument in `{{ }}` inside the function — `summarise(data, total = sum({{ col }}))`. That is tidy evaluation: it tells dplyr to treat the argument as a column reference rather than a value, which is what lets your function be called like a dplyr verb.
Why use purrr::map instead of a for loop?
`map()` returns a value, so the result is the expression rather than something accumulated in a pre-allocated object. The typed variants — `map_dbl`, `map_chr`, `map_dfr` — also fail immediately if a step returns the wrong type, which a loop discovers much later.
How do I stop one failure from killing a whole batch?
Wrap the function in `safely()` or `possibly()`. `safely()` returns a list with `result` and `error` for every element, so you can process what succeeded and report what did not — essential when iterating over files or API calls.
What does renv do?
It records the exact package versions a project uses in a lockfile and installs them into a project-local library. Without it, a script that worked last year silently changes behaviour when a dependency updates — and you find out from the numbers, not from an error.