R purrr - possibly() and safely() - Error Handling

• `possibly()` and `safely()` transform functions into error-resistant versions that return default values or captured error objects instead of halting execution

Key Insights

possibly() and safely() transform functions into error-resistant versions that return default values or captured error objects instead of halting execution • safely() returns a list with result and error elements, enabling detailed error inspection and conditional logic based on failure types • These functional programming tools excel in data pipelines where partial failures shouldn’t stop processing, particularly when mapping over lists or API calls

Why Standard Error Handling Falls Short in Functional Pipelines

When applying functions across lists using map(), a single error stops the entire operation. Traditional tryCatch() requires verbose boilerplate that clutters code and breaks the functional flow.

library(purrr)
library(dplyr)

# Standard approach fails on first error
urls <- c("https://api.example.com/data/1",
          "https://api.example.com/data/2",
          "invalid_url",
          "https://api.example.com/data/4")

# This would stop at the invalid URL
# results <- map(urls, readLines)
# Error in file(con, "r") : cannot open the connection

The purrr package provides possibly() and safely() as functional wrappers that handle errors gracefully without breaking pipelines. They transform any function into a failure-tolerant version.

Using possibly() for Simple Default Values

possibly() wraps a function and returns a default value when errors occur. The syntax is possibly(function, otherwise = default_value).

# Create error-resistant version of a function
safe_log <- possibly(log, otherwise = NA_real_)

# Works with valid input
safe_log(10)
# [1] 2.302585

# Returns default instead of error
safe_log("invalid")
# [1] NA

# Perfect for mapping operations
values <- list(100, "text", -5, 50, NULL, 25)
results <- map_dbl(values, safe_log)
results
# [1] 4.605170       NA      NaN 3.912023       NA 3.218876

The otherwise parameter accepts any value. Choose defaults that make sense for your data type:

# Different default values for different contexts
safe_parse_number <- possibly(as.numeric, otherwise = 0)
safe_read_file <- possibly(readLines, otherwise = character(0))
safe_api_call <- possibly(httr::GET, otherwise = NULL)

# Using in a data pipeline
data <- tibble(
  id = 1:5,
  value = c("123", "456", "invalid", "789", "abc")
)

data %>%
  mutate(numeric_value = map_dbl(value, safe_parse_number))
# # A tibble: 5 × 3
#   id value   numeric_value
#   <int> <chr>         <dbl>
# 1     1 123             123
# 2     2 456             456
# 3     3 invalid           0
# 4     4 789             789
# 5     5 abc               0

Using safely() for Detailed Error Inspection

safely() provides more control by returning a list with two elements: result (the successful output or NULL) and error (NULL or the error object).

safe_sqrt <- safely(sqrt)

# Successful execution
safe_sqrt(16)
# $result
# [1] 4
# 
# $error
# NULL

# Failed execution
safe_sqrt("invalid")
# $result
# NULL
# 
# $error
# <simpleError in sqrt("invalid"): non-numeric argument to mathematical function>

This structure enables sophisticated error handling logic:

# Process files with detailed error tracking
files <- c("data1.csv", "missing.csv", "data2.csv")
safe_read <- safely(read.csv)

results <- map(files, safe_read)

# Extract successful results
successful <- results %>%
  keep(~ is.null(.x$error)) %>%
  map("result")

# Extract and analyze errors
errors <- results %>%
  keep(~ !is.null(.x$error)) %>%
  map_chr(~ .x$error$message)

errors
# [1] "cannot open file 'missing.csv': No such file or directory"

Practical Pattern: API Calls with Error Logging

Combining safely() with transpose() creates clean error-handling workflows for API operations:

library(httr)
library(jsonlite)

# Simulate API call function
fetch_user <- function(id) {
  if (id > 100) stop("User ID out of range")
  list(id = id, name = paste0("User", id), status = "active")
}

safe_fetch <- safely(fetch_user)

user_ids <- c(1, 50, 150, 75, 200)
results <- map(user_ids, safe_fetch)

# Transpose to separate results and errors
transposed <- transpose(results)

# Get all successful results
valid_users <- transposed$result %>%
  compact() # Remove NULLs

# Get all errors with their indices
error_indices <- which(!map_lgl(transposed$error, is.null))
error_messages <- transposed$error %>%
  compact() %>%
  map_chr("message")

# Create error report
error_report <- tibble(
  user_id = user_ids[error_indices],
  error = error_messages
)

error_report
# # A tibble: 2 × 2
#   user_id error                 
#     <dbl> <chr>                 
# 1     150 User ID out of range  
# 2     200 User ID out of range

Advanced Pattern: Retry Logic with safely()

Build retry mechanisms by checking error types:

# Function that fails randomly
unreliable_api <- function(x) {
  if (runif(1) < 0.7) stop("Connection timeout")
  x * 2
}

# Retry wrapper using safely()
retry_call <- function(f, x, max_attempts = 3) {
  safe_f <- safely(f)
  
  for (attempt in seq_len(max_attempts)) {
    result <- safe_f(x)
    
    if (is.null(result$error)) {
      return(result$result)
    }
    
    if (attempt < max_attempts) {
      message(sprintf("Attempt %d failed: %s. Retrying...", 
                      attempt, result$error$message))
      Sys.sleep(0.5)
    }
  }
  
  stop(sprintf("Failed after %d attempts: %s", 
               max_attempts, result$error$message))
}

# Use with map
set.seed(42)
values <- 1:5
results <- map_dbl(values, ~ retry_call(unreliable_api, .x))

Combining possibly() and safely() with quietly()

The purrr package also provides quietly() for capturing warnings and messages:

noisy_function <- function(x) {
  if (x < 0) warning("Negative value detected")
  if (x == 0) message("Processing zero")
  sqrt(abs(x))
}

quiet_sqrt <- quietly(noisy_function)

result <- quiet_sqrt(-4)
result
# $result
# [1] 2
# 
# $output
# [1] ""
# 
# $warnings
# [1] "Negative value detected"
# 
# $messages
# character(0)

# Combine with safely() for comprehensive error handling
safe_quiet <- safely(quietly(noisy_function))

Performance Considerations

Error-handling wrappers add minimal overhead but accumulate in large-scale operations:

library(bench)

test_data <- 1:10000

mark(
  standard = map_dbl(test_data, sqrt),
  possibly = map_dbl(test_data, possibly(sqrt, otherwise = NA)),
  safely = map(test_data, safely(sqrt)),
  check = FALSE
)
# # A tibble: 3 × 6
#   expression      min   median `itr/sec` mem_alloc `gc/sec`
#   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
# 1 standard     1.2ms   1.35ms      710.    78.2KB     2.08
# 2 possibly    1.89ms   2.05ms      476.   156.3KB     2.11
# 3 safely      8.45ms   9.12ms      108.   1.79MB      6.38

Use possibly() when you only need default values. Reserve safely() for situations requiring error inspection or conditional logic based on failure types.

Choosing Between possibly() and safely()

Use possibly() when:

  • You need simple default values for failures
  • Performance is critical with large datasets
  • Errors don’t require investigation
  • Working with map_dbl(), map_chr(), or other typed variants

Use safely() when:

  • You need to log or analyze errors
  • Different errors require different handling
  • Building retry mechanisms
  • Debugging pipelines with intermittent failures

Both functions transform error-prone operations into robust, production-ready code without sacrificing the elegance of functional programming patterns.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.