How to Use safely() and possibly() in R

purrr
purrr safely()
Handle errors gracefully in purrr iterations with safely() and possibly(). Learn to capture errors, provide defaults, and debug failing operations.
Published

April 3, 2026

Introduction

When iterating over many elements with map(), a single error can stop the entire operation. The safely() and possibly() functions wrap other functions to handle errors gracefully:

  • safely() - captures both results and errors, returns a list with $result and $error
  • possibly() - returns a default value when an error occurs
  • quietly() - captures output, messages, and warnings (not errors)

Getting Started

library(tidyverse)

The Problem: One Error Stops Everything

# This will error on the second element
inputs <- list("1", "a", "3")

# Uncomment to see the error:
# map(inputs, as.numeric)  # Works
# map(inputs, \(x) log(as.numeric(x)))  # Error on "a"

safely(): Capture Results and Errors

Basic usage

safely() wraps a function and returns a list with result and error:

safe_log <- safely(log)

# Successful call
safe_log(10)
# $result: 2.302585
# $error: NULL

# Failed call
safe_log("a")
# $result: NULL
# $error: <error object>

Use with map()

inputs <- list(10, -1, "a", 100)

# Wrap log to handle errors
safe_log <- safely(log)
results <- map(inputs, safe_log)

results

Extract successful results

# Get just the results (NULLs for errors)
map(results, "result")

# Get just the errors (NULLs for successes)
map(results, "error")

# Use transpose for cleaner access
results_transposed <- transpose(results)
results_transposed$result
results_transposed$error

Identify which inputs failed

# Find which had errors
error_indices <- map_lgl(results, \(x) !is.null(x$error))
inputs[error_indices]  # "a" and -1 (log of negative)

possibly(): Return Default on Error

When you just want a default value without capturing the error:

# Create a "safe" version with default
safe_log <- possibly(log, otherwise = NA_real_)

inputs <- list(10, -1, "a", 100)
map_dbl(inputs, safe_log)
# 2.302585, NA, NA, 4.605170

Practical example: Reading files

# Some files might not exist
files <- c("data1.csv", "missing.csv", "data2.csv")

# Create safe reader with empty tibble as default
safe_read <- possibly(read_csv, otherwise = tibble())

# This won't error on missing files
# all_data <- map(files, safe_read)

Parsing mixed data

# Mixed numeric strings
values <- c("1.5", "2.0", "three", "4.5", "N/A")

# Parse with NA for failures
safe_parse <- possibly(as.numeric, otherwise = NA_real_)
map_dbl(values, safe_parse)

quietly(): Capture Messages and Warnings

For functions that produce warnings or messages:

quiet_log <- quietly(log)

# This produces a warning normally
quiet_log(-1)
# $result: NaN
# $output: ""
# $warnings: "NaNs produced"
# $messages: ""

Filter results with warnings

inputs <- list(10, -1, 100, -5)

quiet_log <- quietly(log)
results <- map(inputs, quiet_log)

# Find which had warnings
map_lgl(results, \(x) length(x$warnings) > 0)

Practical Example: Web Scraping

Fetching multiple URLs

# Simulate URL fetching (would use httr2 in practice)
fetch_url <- function(url) {
  if (grepl("bad", url)) stop("Connection failed")
  paste("Content from", url)
}

urls <- c(
  "https://example.com/page1",
  "https://bad-url.com",
  "https://example.com/page2"
)

# Safe version
safe_fetch <- safely(fetch_url)
results <- map(urls, safe_fetch)

# Extract successful results
successes <- keep(results, \(x) is.null(x$error))
map_chr(successes, "result")

Practical Example: Model Fitting

Fit models to subsets that might fail

# Some groups might have insufficient data
fit_model <- function(df) {
  if (nrow(df) < 5) stop("Insufficient data")
  lm(mpg ~ wt, data = df)
}

# Split by cylinder count
by_cyl <- mtcars |> split(~cyl)

# Safe fitting
safe_fit <- safely(fit_model)
models <- map(by_cyl, safe_fit)

# Check which succeeded
map_lgl(models, \(x) is.null(x$error))

# Extract successful models
successful_models <- models |>
  keep(\(x) is.null(x$error)) |>
  map("result")

Combining with Other Error Handling

Custom error handling with possibly()

# More sophisticated default behavior
safe_divide <- possibly(
  \(x, y) x / y,
  otherwise = Inf,
  quiet = FALSE  # Still show warning
)

map2_dbl(c(10, 20, 30), c(2, 0, 5), safe_divide)

Chain safely with other operations

# Complex pipeline with error handling
process_data <- function(x) {
  x |>
    as.numeric() |>
    log() |>
    round(2)
}

safe_process <- safely(process_data)

inputs <- list("10", "hello", "100", "-5")
results <- map(inputs, safe_process)

# Summary of what worked
tibble(
  input = unlist(inputs),
  success = map_lgl(results, \(x) is.null(x$error)),
  result = map_dbl(results, \(x) x$result %||% NA_real_)
)

insistently(): Retry on Failure

For flaky operations (like network requests), retry automatically:

# Simulate unreliable function
flaky_function <- function(x) {
  if (runif(1) < 0.7) stop("Random failure!")
  x * 2
}

# Retry up to 5 times
resilient_fn <- insistently(
 flaky_function,
  rate = rate_backoff(
    pause_base = 0.1,    # Start with 0.1s delay
    pause_cap = 2,       # Max 2s delay
    max_times = 5        # Try up to 5 times
  )
)

# Now more likely to succeed
resilient_fn(10)

Practical use: API calls

# Rate-limited API wrapper
rate_limited_fetch <- insistently(
  fetch_data,
  rate = rate_delay(0.5),  # Wait 0.5s between attempts
  quiet = FALSE            # Show retry messages
)

Comparison: safely() vs tryCatch()

# Base R tryCatch - more verbose
result <- tryCatch(
  log("a"),
  error = function(e) {
    list(result = NA, error = e$message)
  }
)

# purrr possibly() - concise for simple defaults
safe_log <- possibly(log, otherwise = NA)
result <- safe_log("a")

# purrr safely() - captures full error
safe_log <- safely(log)
result <- safe_log("a")
result$error  # Full error object with message, call, etc.

When to use each

Use Case Best Choice
Just need a default value possibly()
Need to log/report errors safely()
Complex error handling logic tryCatch()
Retry on failure insistently()
Debug interactively auto_browse()

Performance Considerations

Wrapping functions adds overhead. For performance-critical code:

# Slow for millions of iterations
map(huge_list, possibly(f, NA))

# Faster: handle errors at a higher level
tryCatch(
  map(huge_list, f),
  error = \(e) {
    # Handle or log the error
    NA
  }
)

# Or: pre-validate inputs
valid_inputs <- keep(huge_list, is_valid)
map(valid_inputs, f)

Common Mistakes

1. Forgetting that safely() returns a list

safe_log <- safely(log)

# This is a list, not a number
result <- safe_log(10)
result  # List with $result and $error

# Extract the actual value
result$result

2. Using possibly() when you need error info

# If you need to know WHY something failed, use safely()
safe_fn <- safely(log)
result <- safe_fn("a")
result$error  # Contains error message

# possibly() discards error information
poss_fn <- possibly(log, otherwise = NA)
poss_fn("a")  # Just NA, no error info

3. Not specifying the right default type

# Wrong: default type doesn't match expected output
# possibly(as.numeric, otherwise = "NA")  # Character default!

# Right: match the expected output type
possibly(as.numeric, otherwise = NA_real_)  # Numeric default

Summary

Function Returns Use When
safely() list with result + error Need to know what failed and why
possibly() result or default Just need a fallback value
quietly() result + warnings/messages Capturing non-error output
  • Use safely() when debugging or when you need error details
  • Use possibly() for simple default-on-error behavior
  • Always match your default type to the expected output type
  • Combine with map(), keep(), and transpose() for powerful workflows