Lazy Evaluation and Promises

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Everything on this page is base R, so no package versions apply: the packages that are named in the prose (rlang, lobstr, pryr) are community material and none of them is run. Every [source,r] block is part of a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)) and that passes the default lintr linters; the lines that start with #> are what it printed, in the order it printed them (messages and normal output interleaved). A script that is cut into several blocks with text between them is one script: run its blocks in order. Where a line does exactly what a default linter dislikes, it carries a # nolint: comment so that the linter does not hide the lesson. The names books, loans, shelf_today, loan_days and the book class come from the scenario.R file of the running library-shelf scenario described on the section index; scripts that use them start with source("scenario.R").

Calling a function in R does not evaluate its arguments. Each argument is wrapped in a promise, a small object that remembers the expression the caller wrote and the environment it has to be evaluated in, and the function evaluates it, once, if and when it uses the argument. That one decision explains why a default argument can refer to a variable created later in the body, why missing() and substitute() work, why tryCatch(expr, ...) can catch an error that is raised in the code you typed as expr, why a closure made in a for loop can see the wrong value, and why a message() placed inside an argument sometimes never prints. This page takes the mechanism apart, shows each consequence with a script, and lists the real error and warning messages it produces. It builds on Functions (argument matching and defaults) and Scoping, Environments and Closures (environments, frames and lexical scope); the function-factory part continues on Function Factories and Higher-Order Functions.

Why R evaluates arguments lazily

R’s argument passing is call by value with lazy evaluation. The Language Definition states both halves in one section: supplied arguments behave as if they were local variables initialised with the value supplied, so changing one inside the function does not change the variable in the calling frame, and yet the arguments are not evaluated until needed, so in some cases an argument is never evaluated at all (R-lang: Argument evaluation). The technical name for the combination is call by need: an argument is evaluated at most once, on demand, and its value is kept.

Four features of the language rest on that design.

  • Defaults that depend on the other arguments or on the body. A default is an expression, not a value fixed when the function is defined, and it is evaluated in the function’s own frame, so it can use any argument and any local variable that exists by the time it is needed.

  • Optional and absent arguments. A formal argument that was not supplied only becomes an error if the body evaluates it, so a function can accept fewer arguments than it declares, and missing() can ask whether the caller supplied one.

  • Access to what the caller typed. Because the promise keeps the expression, substitute() can return it. This is the basis of the default axis labels of plot(), and of the non-standard evaluation in subset(), with() and the tidyverse; see Language Objects and Quoting and Evaluation and Non-Standard Evaluation.

  • Functions that look like control flow. tryCatch(), suppressWarnings(), system.time(), local() and on.exit() receive the code they guard as an unevaluated argument and choose where and when to run it. A language with eager arguments would need a special syntax or a closure for each of them.

The price is that the order and the number of evaluations are decided by the function body, not by the call, so side effects placed in an argument are unreliable. The manual says so plainly: it is bad style to use arguments to cause side effects, because nothing guarantees that the argument is ever evaluated (R-lang: Argument evaluation). The sections below show where this bites and how to write code that does not depend on it.

The R FAQ records one place where this design differs from S: S’s substitute() looked for names in the actual and default arguments of the matching call and in the local frame, whereas R looks in the local frame only, with a special rule that uses the promise when the variable has not been evaluated yet (R FAQ 3.3.3: Others). The two agree until an assignment takes place, which is exactly what the substitute() section below shows.

The anatomy of a promise

A promise has three slots: an expression, an environment and a value (R-lang: Promise objects). The internals manual describes the same object as the PROMSXP type: pointers to the value, the expression and the environment in which to evaluate the expression, with the remark that once a promise has been evaluated, the environment is set to NULL (R-ints: The data). The expression and the environment are filled in when the promise is created, the value when it is first needed.

Promises are created in four places.

  • When a closure is called. Argument matching replaces the list of actual arguments with a list of promises, and a new environment is built whose bindings are those promises, with the caller’s environment in the environment slot. A formal that has no actual argument but has a default gets a promise whose expression is the default and whose environment is the new function frame (R-ints: Argument evaluation).

  • In the dots. Each argument that matches ... is also a promise, kept in the ... object until it is used.

  • By hand, with delayedAssign(). The Language Definition notes that this function makes a promise out of an expression.

  • When a package is loaded. Lazy loading fills the namespace with promises that read the real object from a database on first use; the internals manual describes it as populating the environment with promises (R-ints: Lazy loading).

Calls to primitive functions behave differently. The arguments of a builtin such as + are evaluated before the function runs, and those of a special such as quote or missing are handed over unevaluated and the function decides what to do with them (R-ints: Argument evaluation). The promises in this page are those of closures, which is every function written in R.

R code cannot see a promise as an object. The manual is explicit: there is generally no way in R code to check whether an object is a promise, nor to determine the environment of a promise (R-lang: Promise objects). Writing the name of the argument evaluates it, which is the point. What R does expose is the expression slot, through substitute(), and a way to look at the promises in an environment without forcing them (shown in Creating and inspecting promises below).

The life cycle of a promise

The diagram follows one promise from its creation to the end of its life. A promise that has been forced is never evaluated again; one whose evaluation was interrupted by an error is evaluated again the next time it is used; a promise that needs its own value while it is being evaluated is an error.

stateDiagram-v2 direction LR [*] --> Created : closure call or delayedAssign() Created --> Created : missing(), substitute(), quote(), match.call(), nargs() Created --> Discarded : x <- 1 before any read Created --> Evaluating : first use, force(), print(), get() Evaluating --> Forced : value stored, environment dropped Evaluating --> Failed : the expression signals an error Failed --> Evaluating : used again, warning restarting interrupted promise evaluation Evaluating --> Failed : the expression needs this promise, error promise already under evaluation Forced --> Forced : later uses return the stored value, substitute() still returns the expression Forced --> [*] Discarded --> [*] note right of Created expression and environment set value empty end note note right of Forced value set environment set to NULL end note

The loop on Created is what makes missing(x) and substitute(x) safe to call on an argument that would fail if evaluated: neither touches the value slot. Discarded is a state of the binding, not of the promise: assigning to a formal argument before reading it replaces the promise with an ordinary value, and the promise is never evaluated. The first script exercises the three rules that matter most: a promise is not evaluated at the call, an argument that is never used is never evaluated, and a promise is evaluated once. It ends with the definition of force().

# A closure's arguments are not evaluated when the function is called.
trace_arg <- function(x) {
  cat("the body starts\n")
  value <- x
  cat("the body has used x\n")
  value
}
trace_arg({
  cat("the argument is evaluated now\n")
  21 * 2
})
#> the body starts
#> the argument is evaluated now
#> the body has used x
#> [1] 42

# An argument that is never used is never evaluated.
ignore <- function(x) "x was never touched"
ignore(stop("this would fail"))
#> [1] "x was never touched"
ignore(message("this message never appears"))
#> [1] "x was never touched"

# A promise is forced at most once: its value is kept.
ticks <- new.env()
ticks$n <- 0L
tick <- function() {
  ticks$n <- ticks$n + 1L
  ticks$n
}
twice <- function(x) c(x, x)
twice(tick())
#> [1] 1 1
ticks$n
#> [1] 1

# force() is the identity function: its only job is to use the argument.
body(force)
#> x
names(formals(force))
#> [1] "x"
identical(body(force), quote(x))
#> [1] TRUE

trace_arg() prints "the body starts" before "the argument is evaluated now": the cat() inside the braces of the argument ran only when the body executed value <- x. ignore(stop("this would fail")) returns normally, and the message() in the second call never prints. In twice(tick()) the argument appears twice in the body but tick() ran once, which ticks$n confirms. Finally, force is nothing but function(x) x: its body is the symbol x, and the help page says so too, calling it "semantic sugar" for just evaluating the symbol.

A promise keeps its environment alive until it is forced

Because the environment slot points at the frame where the expression has to be evaluated, a promise that has not been forced keeps that whole frame, with everything in it, from being garbage-collected. Forcing the promise sets the slot to NULL, which releases the frame. The script registers a finalizer on the frame of build(); it runs only after the closure that holds the promise has used it.

# An unforced promise keeps the environment it must be evaluated in alive;
# forcing it lets the environment go (R-ints: the environment is set to NULL).
make_adder <- function(n) {
  function(x) x + n
}
build <- function() {
  frame <- environment()
  reg.finalizer(frame, \(e) cat("the frame of build() was collected\n"))
  size <- 1000L
  make_adder(size * 2L)
}
adder <- build()
invisible(gc())
cat("collected? not yet: the promise still needs that frame\n")
#> collected? not yet: the promise still needs that frame
adder(1)
#> [1] 2001
invisible(gc())
#> the frame of build() was collected
cat("after forcing the promise, the frame can go\n")
#> after forcing the promise, the frame can go

The first gc() finds the frame of build() still reachable, because the promise for size * 2L inside adder needs it. Calling adder(1) forces the promise, and the next gc() collects the frame. This is rarely a problem, but it explains why long-lived closures that capture a large local object through an unforced promise can use more memory than expected, and it is one more reason to force() an argument that a closure keeps (see below). The finalizer message appears before the last cat() because gc() ran the finalizer synchronously. The Memory and Copy Semantics page covers the rest of R’s memory model.

When a promise is forced

A promise is forced when its value is needed. The Language Definition says this usually happens inside internal functions, but that a promise can also be forced by evaluating the promise itself, which is occasionally useful when a default expression depends on the value of another formal argument or other variable in the local environment (R-lang: Argument evaluation). It adds that the expression of a promise can itself contain other promises, whenever an unevaluated argument is passed on to another function, and that forcing the outer promise forces the inner ones as they are evaluated. The easiest way to learn the rule is to try it. The script below gives each operation a function with one argument, passes an argument that records whether it was evaluated and tabulates the result.

# Which operations force an argument? Run each one on an argument that
# records whether it was evaluated.
state <- new.env()
state$forced <- FALSE
probe_arg <- function() {
  state$forced <- TRUE
  1
}
forces <- function(f) {
  state$forced <- FALSE
  f(probe_arg())
  state$forced
}

generic <- function(x) UseMethod("generic")
generic.default <- function(x) "default method"

checks <- list(
  "x" = function(x) x,
  "force(x)" = function(x) force(x),
  "length(x)" = function(x) length(x),
  "y <- x" = function(x) {
    y <- x
    NULL
  },
  "x <- 1 (assign, never read)" = function(x) {
    x <- 1
    NULL
  },
  "if (FALSE) x" = function(x) if (FALSE) x,
  "missing(x)" = function(x) missing(x),
  "substitute(x)" = function(x) substitute(x),
  "quote(x)" = function(x) quote(x),
  "match.call()" = function(x) match.call(),
  "sys.call()" = function(x) sys.call(),
  "nargs()" = function(x) nargs(),
  "exists(\"x\")" = function(x) exists("x"),
  "get(\"x\")" = function(x) get("x"),
  "pass x to a function that ignores it" = function(x) {
    (function(y) "ignored")(x)
  },
  "pass x to a function that uses it" = function(x) {
    (function(y) y)(x)
  },
  "...length()" = function(...) ...length(),
  "...elt(1)" = function(...) ...elt(1L),
  "list(...)" = function(...) list(...),
  "S3 generic dispatching on x" = function(x) generic(x)
)

data.frame(
  operation = names(checks),
  forces_the_argument = vapply(checks, forces, logical(1)),
  row.names = NULL
)
#>                               operation forces_the_argument
#> 1                                     x                TRUE
#> 2                              force(x)                TRUE
#> 3                             length(x)                TRUE
#> 4                                y <- x                TRUE
#> 5           x <- 1 (assign, never read)               FALSE
#> 6                          if (FALSE) x               FALSE
#> 7                            missing(x)               FALSE
#> 8                         substitute(x)               FALSE
#> 9                              quote(x)               FALSE
#> 10                         match.call()               FALSE
#> 11                           sys.call()               FALSE
#> 12                              nargs()               FALSE
#> 13                          exists("x")               FALSE
#> 14                             get("x")                TRUE
#> 15 pass x to a function that ignores it               FALSE
#> 16    pass x to a function that uses it                TRUE
#> 17                          ...length()               FALSE
#> 18                            ...elt(1)                TRUE
#> 19                            list(...)                TRUE
#> 20          S3 generic dispatching on x                TRUE

The table reads as follows.

Operation Forces x? Why

x, force(x), get("x"), y <- x, length(x)

yes

each one needs the value of x

list(...), ...elt(1)

yes

a builtin or an element access needs the value; ...elt(1) forces only the first element of the dots

a call to an S3 generic that dispatches on x

yes

UseMethod() evaluates the dispatch argument if it has not been evaluated (R-ints: Argument evaluation), even when the method that runs never uses it

missing(x)

no

a special primitive that inspects the binding, not the value

substitute(x), quote(x), match.call(), sys.call()

no

they return the expression, the symbol or the call, never the value

nargs(), ...length(), exists("x")

no

they count or look up bindings

passing x to a function that never uses its argument

no

the inner promise holds the outer one, and nobody forces either

if (FALSE) x

no

the branch is not taken

x <- 1 with no earlier read

no

the binding is replaced; the promise is dropped without being evaluated

Three of those rows deserve a second look.

  • The S3 generic. generic(x) forced x although generic.default() ignores its argument, because the generic is function(x) UseMethod("generic") and dispatch needs the class of x. Every S3 generic therefore forces its dispatch argument, normally the first, at once. See S3 Classes and Generics.

  • Passing on. A promise passed to another function becomes the expression of a new promise. Using the argument in the outer function forces the whole chain; not using it anywhere forces nothing. This is how missing() can look through several calls (see missing() and promises).

  • Assignment before a read. A formal argument is a variable in the function frame. Assigning to it before reading it replaces the promise, so the argument that the caller typed is never evaluated. That is legal, but it hides a bug when the caller’s expression had a side effect that the caller expected.

Supplied arguments and default arguments

The Language Definition separates the two cases carefully: supplied arguments are evaluated in the evaluation frame of the calling function, default arguments are evaluated in the evaluation frame of the function (R-lang: Argument evaluation). Both are promises; only the environment slot differs. The consequences are in the next script.

# A default is a promise in the function's own frame, so it can use a
# variable that the body creates later.
source("scenario.R")
due_label <- function(loan_date, label = paste("due", due)) {
  due <- loan_date + loan_days # nolint: object_usage_linter.
  label
}
due_label(as.Date("2026-06-01"))
#> [1] "due 2026-06-22"

# A supplied argument is evaluated in the caller's frame; a default in the
# function's frame.
scale <- 100
where <- function(supplied, default = scale) {
  scale <- 1
  c(supplied = supplied, default = default)
}
where(scale)
#> supplied  default
#>      100        1

due_label() has a default, label = paste("due", due), that mentions due, a variable that does not exist when the function is entered. The body creates due and only then evaluates label, so the default finds it. That would be an error in a language that evaluates defaults when the function is called. where() shows the two environments side by side: where(scale) is called from the global environment where scale is 100, so the supplied argument evaluates to 100, but the default scale is evaluated in the function frame, where the body has set scale <- 1. The supplied argument is evaluated in the caller’s frame, where the function’s own scale is not visible.

The timing of a default matters

A default is evaluated when it is first needed, which may be after the arguments it refers to have changed. The manual’s own example makes the point with a lone label that forces the default early.

# The manual's example: when the default is first forced matters.
late <- function(x, label = deparse(x)) {
  x <- x + 1
  label
}
early <- function(x, label = deparse(x)) {
  label
  x <- x + 1
  label
}
late(10)
#> [1] "11"
early(10)
#> [1] "10"

# A default may refer to another argument, whatever the order.
rect <- function(area = width * height, width = 3, height = 2) area
rect()
#> [1] 6
rect(width = 10)
#> [1] 20

# Defaults can be inspected without evaluating them.
formals(rect)$area
#> width * height
class(formals(rect)$area)
#> [1] "call"

late(10) returned "11" because label = deparse(x) was evaluated after x <- x + 1. early(10) returned "10": the line that contains just label forced the default before x changed, and the second label returned the stored value. The same promise rule applies when a function modifies an argument that a default depends on, which is a classic source of off-by-one surprises in code that was not written with it in mind. Three further points from the script:

  • Defaults may refer to each other in either order: area = width * height is evaluated after the other two are available.

  • formals() returns the defaults as unevaluated language objects. formals(rect)$area is the call width * height and no evaluation happened.

  • The default is part of the function’s definition, not a value computed at definition time. Programmers coming from Python, where a default is evaluated once when the function is defined, should note that in R a default is evaluated again at every call that needs it, in a fresh frame, so there is no shared mutable default.

An expensive default that is never needed

The shelf report below has a default for overdue that calls a function which has to scan all the loans. When the caller passes a table with no loans, the function returns early and the scan never runs; when the caller supplies overdue explicitly, the default is never evaluated either. The message() inside overdue_count() records every time the work is done.

# An expensive default that is never needed is never computed.
source("scenario.R")
overdue_count <- function(loans, today, days) {
  message("counting overdue loans")
  end <- loans$return_date
  end[is.na(end)] <- today
  sum(end - loans$loan_date > days, na.rm = TRUE)
}
report <- function(loans,
                   today = shelf_today, # nolint: object_usage_linter.
                   days = loan_days, # nolint: object_usage_linter.
                   overdue = overdue_count(loans, today, days)) {
  if (nrow(loans) == 0L) {
    return("no loans to report")
  }
  paste(nrow(loans), "loans,", overdue, "overdue")
}
report(loans[0L, ])
#> [1] "no loans to report"
report(loans)
#> counting overdue loans
#> [1] "60 loans, 32 overdue"
report(loans, days = 30L)
#> counting overdue loans
#> [1] "60 loans, 12 overdue"
report(loans, overdue = 0L)
#> [1] "60 loans, 0 overdue"

report(loans[0L, ]) returned without a message: the promise for overdue was created and never forced. The next call forced it once, and days = 30L changes what the default sees, because the default refers to the days argument of the same call. The last call supplied the value, so no scan happened. This is the practical argument for lazy defaults: an optional computation can be described where the argument is declared and paid for only by the callers that need it. The shelf constants shelf_today and loan_days are defaults in the same way, which is why no caller has to pass them. The # nolint comments are there because lintr cannot see the objects that source() creates.

A default that refers to itself

If forcing a promise needs the value of the same promise, R detects it and stops. The message names both usual causes. A default that mentions its own argument, or two defaults that refer to each other, produce it, and so does an earlier error that left the promise in the "being evaluated" state.

# The real error text of a default that refers to itself, and of a pair
# of defaults that refer to each other.
message_of <- function(expr) {
  tryCatch(expr, error = function(e) conditionMessage(e))
}
self_ref <- function(x = x) x
message_of(self_ref())
#> [1] "promise already under evaluation: recursive default argument reference or earlier problems?"
cycle <- function(a = b, b = a) a
message_of(cycle())
#> [1] "promise already under evaluation: recursive default argument reference or earlier problems?"

# Supplying the argument avoids the cycle.
cycle(a = 1)
#> [1] 1
self_ref(2)
#> [1] 2

Supplying either argument breaks the cycle. The text, as the interpreter prints it, is promise already under evaluation: recursive default argument reference or earlier problems?. A default of x = x is a mistake in R, not a way to say "use the variable x from the caller": the default is evaluated in the function frame, where x is the argument itself. To use the caller’s x, give the argument another name, or write the default as get("x", envir = parent.frame()), which looks the variable up in the caller’s frame when the default is first used.

force(): evaluate an argument now

force(x) forces the promise of the formal argument it is given. Its help page states the purpose: it is useful if the argument will be captured in a closure by the lexical scoping rules and will later be altered by an explicit assignment or an implicit assignment in a loop or an apply function. The same page says that it is "semantic sugar", that just evaluating the symbol does the same thing, and that it does not force other promises (it forces the promise that was created when the actual arguments were matched to the formal arguments) (R documentation: force). Three consequences:

  • force(x) and a bare x on its own line are equivalent. force(x) states the intent, so linters, reviewers and the next reader do not mistake the bare symbol for a leftover and delete it. Prefer it.

  • Forcing is not copying. force(x) evaluates the promise and keeps the value; if x is later modified, R copies on modification as usual (Memory and Copy Semantics).

  • The earliest line of a function is the right place to force an argument that must be evaluated before anything else changes or that you want validated early. An error in the caller’s expression is then raised at the top of the function and not at some distant line, which also makes the traceback shorter.

The function-factory trap

A function factory is a function that returns a function. The returned closure looks up its variables in the environment of the call that created it, and those variables include the factory’s arguments. An argument that was never forced is still a promise there, and the promise reads the caller’s variable when it is finally forced, not when the factory was called. If the caller has changed that variable in between, the closure sees the new value. The first script shows it without a loop.

# A closure that captures an argument captures a promise, not a value.
make_greeter <- function(greeting) {
  function(name) paste(greeting, name)
}
greeting <- "Hello"
hello <- make_greeter(greeting)
greeting <- "Goodbye"
hello("Ana")
#> [1] "Goodbye Ana"

# force() evaluates the promise while "Hello" is still its value.
make_greeter_forced <- function(greeting) {
  force(greeting)
  function(name) paste(greeting, name)
}
greeting <- "Hello"
hello <- make_greeter_forced(greeting)
greeting <- "Goodbye"
hello("Ana")
#> [1] "Hello Ana"

The first closure printed "Goodbye Ana": its greeting is a promise for the expression greeting, evaluated in the global environment on the first call of hello(), by which time the global greeting had been reassigned. In the second version force(greeting) evaluated the promise inside the factory, so the closure holds the string "Hello" and the later assignment cannot reach it.

Closures made in a loop

The same effect is much harder to spot when the changing variable is a loop counter. The help page of force() has the canonical example: a for loop builds closures with a factory that does not force its argument, and every closure then sees the last value of the loop variable. The next blocks build the same closures in two ways, with the version that has the problem second.

# Since R 3.2.0 lapply() and the other higher-order functions force the
# arguments they pass on, so this works without force().
make_adder <- function(n) {
  function(x) x + n
}
adders <- lapply(1:3, make_adder)
vapply(adders, \(add) add(10), numeric(1))
#> [1] 11 12 13
adders_map <- Map(make_adder, 1:3)
vapply(adders_map, \(add) add(10), numeric(1))
#> [1] 11 12 13

# A for loop does not force anything: every closure sees the last i.
adders <- list()
for (i in 1:3) {
  adders[[i]] <- make_adder(i)
}
vapply(adders, \(add) add(10), numeric(1))
#> [1] 13 13 13

lapply(1:3, make_adder) is safe, and so is Map(make_adder, 1:3): each closure was created with the element it was meant to capture, and the results are 11, 12 and 13. The for loop version gives 13, 13 and 13. Every promise has the expression i and the environment of the loop, here the global environment; none of them is forced until the closures are called, after the loop has finished with i equal to 3. The R FAQ notes that, unlike S, the index variable of a for loop in R lives in the environment where the loop runs and is not local to the loop (R FAQ 3.3.3: Others), which is why i is still there to read afterwards.

The apply family is safe because of a change in R 3.2.0. The NEWS entry says that higher-order functions such as the apply functions and Reduce() now force arguments to the functions they apply, to eliminate undesirable interactions between lazy evaluation and variable capture in closures (R NEWS: Changes in R 3.2.0). The interaction it describes is the one the loop version above shows. A for loop is a language construct, not a function, so nothing forces the loop variable, and the trap is still there in R 4.6.1.

The two fixes

Both fixes make the value be taken at the right time. force() in the factory is the one to prefer, since it fixes every caller at once. local() evaluates a block in a new environment, so each iteration gets its own n, and the closure is built from that copy.

# The fix: force() in the factory.
make_adder_forced <- function(n) {
  force(n)
  function(x) x + n
}
adders <- list()
for (i in 1:3) {
  adders[[i]] <- make_adder_forced(i)
}
vapply(adders, \(add) add(10), numeric(1))
#> [1] 11 12 13

# Another fix that needs no change to the factory: local().
adders <- list()
for (i in 1:3) {
  adders[[i]] <- local({
    n <- i
    make_adder(n)
  })
}
vapply(adders, \(add) add(10), numeric(1))
#> [1] 11 12 13

Both versions print 11, 12 and 13. Writing force() on every argument of every function would be noise: it is needed when a closure keeps an argument that can change before the closure runs. A function that only uses its arguments while it executes does not need it. Replacing the loop with lapply(), vapply() or Map() is the other way out, and it is what most modern code does.

The same trap on the shelf

The loan checkers below test whether a loan lasted longer than a limit of 7, 14 or 21 days. The factory is called in a loop over the limits and the result is stored under the limit’s name.

# The same trap with the shelf: one checker per loan limit, built in a loop.
source("scenario.R")
make_checker <- function(limit) {
  function(days) days > limit
}
loan_length <- as.integer(loans$return_date - loans$loan_date)
limits <- c(7L, 14L, 21L)

checkers <- list()
for (limit in limits) {
  checkers[[as.character(limit)]] <- make_checker(limit)
}
vapply(checkers, \(chk) sum(chk(loan_length), na.rm = TRUE), integer(1))
#>  7 14 21
#> 25 25 25

# With force() each checker keeps its own limit.
make_checker <- function(limit) {
  force(limit)
  function(days) days > limit
}
checkers <- list()
for (limit in limits) {
  checkers[[as.character(limit)]] <- make_checker(limit)
}
vapply(checkers, \(chk) sum(chk(loan_length), na.rm = TRUE), integer(1))
#>  7 14 21
#> 47 38 25

# lapply() over the limits needs no force().
checkers <- lapply(limits, make_checker)
names(checkers) <- limits
vapply(checkers, \(chk) sum(chk(loan_length), na.rm = TRUE), integer(1))
#>  7 14 21
#> 47 38 25

With the unforced factory every checker uses the last limit, so the counts for 7, 14 and 21 days are all 25, the number of returned loans longer than 21 days. After force(limit) the counts differ (47, 38 and 25), and lapply(limits, make_checker) gives the same numbers without any forcing, as the 3.2.0 change promises. A wrong answer that looks plausible, 25 three times, is typical of this bug: nothing fails, and the code has to be read twice to see why.

Creating and inspecting promises

delayedAssign(x, value, eval.env = parent.frame(1), assign.env = parent.frame(1)) creates a promise to evaluate value when the variable x is first accessed. The help page describes it as direct access to the lazy evaluation mechanism used for interpreted functions. The expression is evaluated in eval.env, whose contents may have changed in the meantime, and the promise is stored in assign.env. After the first access the value is fixed and the expression is not evaluated again, though the promise keeps its expression (R documentation: delayedAssign). Both environments default to the currently active one, so inside a function the promise lives in the function’s frame.

# delayedAssign() binds a name to a promise that you build by hand.
delayedAssign("book_count", {
  cat("reading books.csv\n")
  nrow(read.csv("books.csv"))
})
cat("the promise exists, nothing has been read yet\n")
#> the promise exists, nothing has been read yet
exists("book_count", inherits = FALSE)
#> [1] TRUE
book_count
#> reading books.csv
#> [1] 16
book_count
#> [1] 16

# The expression is evaluated in eval.env and the promise is bound in
# assign.env; both default to the calling frame.
eval_env <- new.env()
assign_env <- new.env()
assign("bonus", 5, envir = eval_env)
delayedAssign("answer", bonus * 2, eval.env = eval_env, assign.env = assign_env)
exists("answer", envir = assign_env, inherits = FALSE)
#> [1] TRUE
exists("answer", inherits = FALSE)
#> [1] FALSE
get("answer", envir = assign_env)
#> [1] 10

# The expression may refer to something that does not exist yet.
delayedAssign("later", defined_after)
defined_after <- "now it exists"
later
#> [1] "now it exists"

# Inside a function the default is the function's own frame.
lazy_local <- function() {
  delayedAssign("value", {
    cat("evaluated on first use\n")
    "ready"
  })
  cat("before use\n")
  c(value, value)
}
lazy_local()
#> before use
#> evaluated on first use
#> [1] "ready" "ready"

The file books.csv was not read until book_count was first used, and the second access did not read it again. exists() is a way to ask whether the name is bound without evaluating the promise. The second block uses two environments: the expression bonus * 2 is evaluated in eval_env, where bonus is 5, and the promise is stored in assign_env, so exists("answer", inherits = FALSE) in the global environment is FALSE while get("answer", envir = assign_env) is 10. The third block shows that value is captured unevaluated: defined_after does not exist at the time of the delayedAssign() call and nothing fails, because nothing is evaluated until later is used.

Where promises are used on purpose

  • Lazy loading of packages. The code and, optionally, the data of an installed package are stored in a database, and loading the namespace populates it with promises that fetch the real object the first time it is used. R-ints says lazy loading is always used for the code of a package and optional for its datasets, which the package maintainer selects with LazyData in DESCRIPTION; it keeps loading a namespace cheap, because nothing is read until it is used (R-ints: Lazy loading). See Packages, Libraries and Namespaces and Creating Packages.

  • Expensive setup that may not be needed. A script can delayedAssign() a connection, a data load or a model, and the code that never touches the name pays nothing.

  • Not a replacement for an active binding. A promise is evaluated once. makeActiveBinding() (documented with lockBinding() in R documentation: bindenv) calls a function on every access, which is the right tool for a value that must be fresh each time. The block below shows the difference: the active binding prints its message on every access, the promise only on the first.

# A promise is evaluated once; an active binding runs on every access.
delayedAssign("once", {
  cat("promise evaluated\n")
  "first value"
})
makeActiveBinding("every_time", function() {
  cat("active binding called\n")
  "fresh value"
}, environment())
once
#> promise evaluated
#> [1] "first value"
once
#> [1] "first value"
every_time
#> active binding called
#> [1] "fresh value"
every_time
#> active binding called
#> [1] "fresh value"

Looking at promises without forcing them

substitute(x, env) returns the expression slot of the promise bound to x in env, without evaluating it. That is how the help page of delayedAssign() shows how to list the promises of an environment, and it works on the environment of a function call. Anything that evaluates the bindings instead, such as as.list(env), forces them.

# Look at the promises in a frame without forcing them: substitute() with an
# environment returns the expression slot.
source("scenario.R")
frame <- (function(x, y = 1, z) environment())(
  loan_days * 2L, "y", {
    cat("evaluated\n")
    pi + 2
  }
)
sort(names(frame))
#> [1] "x" "y" "z"
substitute(z, frame)
#> {
#>     cat("evaluated\n")
#>     pi + 2
#> }
substitute(y, frame)
#> [1] "y"
substitute(x, frame)
#> loan_days * 2L

# as.list() on the environment forces every promise.
values <- as.list(frame)
#> evaluated
values$z
#> [1] 5.141593
values$x
#> [1] 42

# The expression is still there after forcing.
substitute(z, frame)
#> {
#>     cat("evaluated\n")
#>     pi + 2
#> }

# delayedAssign() leaves the expression in the promise too (assign.env must
# not be the global environment for substitute() to show it).
msg <- "old"
env <- new.env()
delayedAssign("shown", msg, assign.env = env)
msg <- "new"
substitute(shown, env)
#> msg
get("shown", envir = env)
#> [1] "new"

substitute(z, frame) returned the braced block that the caller typed and did not print "evaluated"; as.list(frame) printed it, because it forced the three promises. After forcing, substitute(z, frame) still returns the same expression, since the value is stored next to the expression and does not replace it. A small caveat from the help page: substitute() leaves the symbol alone when env is the global environment, so a promise made by delayedAssign() in the global environment cannot be inspected this way, and the script binds the last one in a fresh environment. Packages such as rlang offer quosures, which turn an argument into an ordinary object holding the expression and the environment, and lobstr’s sxp() prints the internal structure of an object. Both are community packages, are not used here and are mentioned only because tutorials point to them.

missing() and promises

missing(x) is a special primitive that tells whether a formal argument was supplied by the caller. It must not evaluate its argument, and it can be used only in the immediate body of the function that defines the argument, not in a nested function or in a local() call (R documentation: missing). The internals manual explains how it works: the matching of actual to formal arguments sets a "missing" bit on arguments that were not given, and missing() also follows promises, because missingness can be passed on from function to function (using a formal as an actual argument does not count as evaluation) (R-ints: Missingness).

# missing() is TRUE for an argument the caller did not supply, even when it
# has a default.
f <- function(x = 5) {
  cat("missing:", missing(x), " value:", x, "\n")
}
f()
#> missing: TRUE  value: 5
f(5)
#> missing: FALSE  value: 5

# Assigning to the argument makes it not missing.
g <- function(x) {
  cat("before the assignment:", missing(x), "\n")
  x <- 1
  cat("after the assignment: ", missing(x), "\n")
}
g()
#> before the assignment: TRUE
#> after the assignment:  FALSE

# Missingness is passed from function to function through the promise.
inner <- function(y) missing(y)
outer <- function(x) inner(x)
outer()
#> [1] TRUE
outer(1)
#> [1] FALSE

# ... but using the value is an error, and the inner default is not used.
inner_default <- function(y = "inner default") y
outer_default <- function(x) inner_default(x)
tryCatch(
  outer_default(),
  error = function(e) conditionMessage(e)
)
#> [1] "argument \"x\" is missing, with no default"

# missing() works only on arguments of the running function.
tryCatch(
  missing(not_an_argument),
  error = function(e) conditionMessage(e)
)
#> [1] "'missing(not_an_argument)' did not find an argument"

The first block shows the rule that surprises people who treat a default as a value: with a default, missing(x) is TRUE when the caller did not supply x, even though x itself has a perfectly good value (5). missing() tells you how the argument got its value, not whether it has one. The second block shows the other end: the first assignment to the argument clears the missing bit, and after it missing(x) is FALSE. The help page says missing(x) is only reliable if x has not been altered since entering the function, and gives the example of x <- match.arg(x), after which missing(x) is always false.

The third block follows missingness through a call. outer() has no argument, yet inner(x) receives a promise whose expression is the symbol x, which is missing in outer(); missing(y) in inner() follows that promise and returns TRUE. Using the value is another matter: the error argument "x" is missing, with no default names the argument of the outermost function that lacks it, and the inner function’s default "inner default" is not used, since the promise exists and has to be evaluated. The last block shows the error that missing() raises when its argument is not a formal of the running function.

Using missing() in a function

# The shelf: a loan that has no return date is still out.
source("scenario.R")
loan_status <- function(loan_date,
                        return_date,
                        today = shelf_today) { # nolint: object_usage_linter.
  if (missing(return_date)) {
    paste("out for", as.integer(today - loan_date), "days")
  } else {
    paste("returned after", as.integer(return_date - loan_date), "days")
  }
}
loan_status(as.Date("2026-06-01"))
#> [1] "out for 29 days"
loan_status(as.Date("2026-06-01"), as.Date("2026-06-15"))
#> [1] "returned after 14 days"

# missing() is only reliable until the argument is altered: it is always
# FALSE after x <- match.arg(x).
pick <- function(order = c("title", "member")) {
  before <- missing(order)
  order <- match.arg(order)
  c(before = before, after = missing(order))
}
pick()
#> before  after
#>   TRUE  FALSE

loan_status() uses missing() for its optional return_date instead of a sentinel value. The last block checks the warning from the help page: pick() finds missing(order) true before match.arg() and false afterwards, so a function that needs both facts must record missing() first. Prefer a default of NULL and is.null() when callers are expected to pass the "absent" value along from variables, and keep missing() for the cases where the caller either writes the argument or does not. The Functions page compares the idioms.

Consequences for substitute()

substitute() is the other half of the promise: it returns the expression the caller typed, which is the basis of labels, deparse(substitute(x)) and non-standard evaluation. The help page says that when a symbol is bound to a promise in the environment, the expression slot of the promise replaces the symbol, whether the promise is a formal argument or was created by delayedAssign(); an ordinary variable is replaced by its value, unless the environment is the global one (R documentation: substitute).

# substitute() reads the expression slot of the promise.
label <- function(x) deparse(substitute(x))
label(shelf_today + 7)
#> [1] "shelf_today + 7"
label(1:3 * 2)
#> [1] "1:3 * 2"

# The expression survives forcing: the value is stored next to it.
forced <- function(x) {
  value <- x
  list(expression = substitute(x), value = value)
}
forced(6 * 7)
#> $expression
#> 6 * 7
#>
#> $value
#> [1] 42
#>

# For an argument that was not supplied, substitute() returns the default.
with_default <- function(x = 1 + 1) substitute(x)
with_default()
#> 1 + 1
with_default(2 + 2)
#> 2 + 2

# A promise can wrap another promise: x is passed on unevaluated.
relay <- function(y) label(y)
relay(a + b)
#> [1] "y"

# Typical use: label a result by what the caller typed.
describe_count <- function(x) {
  paste(deparse(substitute(x)), "has", length(x), "elements")
}
numbers <- c(4, 8, 15, 16, 23, 42)
describe_count(numbers)
#> [1] "numbers has 6 elements"
describe_count(numbers[numbers > 10])
#> [1] "numbers[numbers > 10] has 4 elements"

# quote() quotes what you type; it never sees the promise.
quote_it <- function(x) quote(x)
quote_it(1 + 1)
#> x

label(shelf_today + 7) printed the text of the expression, although shelf_today does not exist in that script: the expression was never evaluated. forced() shows that the expression is available after the value has been computed. For an argument that was not supplied, the promise holds the default expression, so with_default() returns 1 + 1. relay(a + b) returns y, not a + b: relay() calls label(y), so the promise for the argument of label() has the expression y, and that is what substitute() returns. Each function in a chain sees only the expression its own caller wrote. quote_it() returns the symbol x, which is what quote() always does: it never sees the promise, because it quotes what is written.

The last example of the page on substitute() is the one in the help page of substitute(), with different names.

# substitute() inside a function sees promises, not values; the result
# depends on whether the promise was already forced and on assignments.
as_value <- function(x, y = x) {
  x <- x + 1
  y
}
as_expression <- function(x, y = substitute(x)) {
  x <- x + 1
  y
}
as_symbol <- function(x, y) {
  if (missing(y)) {
    y <- substitute(x)
  }
  x <- x + 1
  y
}
count <- 10
as_value(count)
#> [1] 11
as_expression(count)
#> [1] 11
as_symbol(count)
#> count
typeof(as_symbol(count))
#> [1] "symbol"

All three functions try to hand back "what the caller wrote". as_value() returns 11: the default y = x is evaluated after x <- x + 1, so it sees the new value. as_expression() returns 11 too, because by the time the default substitute(x) runs, x is no longer a promise but an ordinary variable, and substitute() replaces it with its value. Only as_symbol(), which calls substitute(x) before the assignment, returns the symbol count. This is what the R FAQ means by "equivalent to S, until assignment takes place". Capture expressions at the top of the function, before touching the arguments. Language Objects and Quoting builds on this with quote(), bquote(), deparse() and match.call(), and Evaluation and Non-Standard Evaluation with eval() in a chosen environment; both are only previewed here.

Side effects and the order of evaluation

The manual’s advice is direct: do not use arguments to cause side effects. If a function never uses an argument, the argument is never evaluated and the side effect does not happen; if it uses it twice, the effect still happens once; and the order in which the arguments are evaluated is the order of use in the body, which can be different from the order in the call.

# Side effects in an argument happen when, and if, the argument is used.
noisy <- function(label) {
  message("evaluating ", label)
  label
}
use_second <- function(first, second) second
use_second(noisy("first"), noisy("second"))
#> evaluating second
#> [1] "second"

# Order of evaluation follows the use in the body, not the argument list.
combine <- function(a, b) {
  cat("body starts\n")
  b_value <- b
  a_value <- a
  paste(a_value, b_value)
}
combine(noisy("a"), noisy("b"))
#> body starts
#> evaluating b
#> evaluating a
#> [1] "a b"

In the first call noisy("first") never ran, so only "evaluating second" appears. In the second, combine() reads b before a, and the messages come in the order b, a, although a is written first. A reader who assumes that arguments are evaluated from left to right will be wrong for any closure that uses them in another order. For the same reason, argument expressions with side effects, such as reading from a connection or advancing a counter, make the program’s behaviour depend on the function’s body.

# An assignment in an argument runs in the caller's frame, when it is forced.
unused <- function(x) "not used"
used <- function(x) x
n <- 1
unused(n <- 99)
#> [1] "not used"
n
#> [1] 1
used(n <- 99)
n
#> [1] 99
print(used(n <- 100))
#> [1] 100
n
#> [1] 100

# R-lang: there is no guarantee that the argument is ever evaluated.
store_and_report <- function(value) {
  if (FALSE) value
  "done"
}
store_and_report(stored <- 1)
#> [1] "done"
exists("stored")
#> [1] FALSE

The manual points to the C idiom foo(x = y), which assigns as well as passes, and warns that the same style should not be used in R. The assignment n <- 99 as an argument ran in the caller’s frame when, and only when, the argument was forced: unused(n <- 99) left n at 1 and used(n <- 99) set it to 99. The script also shows a quirk worth knowing. used(n <- 99) printed nothing at the console, although used() ends with x: the value of an assignment is invisible, and, as observed on R 4.6.1, evaluating a promise whose expression is an assignment leaves that visibility flag off. print(used(n <- 100)) shows the value. If you see a function return its argument invisibly, an assignment in the call is the first place to look. The last call, store_and_report(stored <- 1), confirms that stored was never created.

Arguments in the dots

The arguments that match ... are promises too. ...length() counts them without evaluating any, ...elt(n) forces only the nth, list(...) forces all of them from left to right, and substitute() applied to the dots recovers every expression. The Language Definition notes that capturing the dots as a list forces the promises.

# The arguments in ... are promises as well; each one is forced only when it
# is used.
source("scenario.R")
noisy <- function(label) {
  message("evaluating ", label)
  label
}
second_only <- function(...) {
  cat("how many:", ...length(), "\n")
  ...elt(2L)
}
second_only(noisy("first"), noisy("second"), noisy("third"))
#> how many: 3
#> evaluating second
#> [1] "second"

# list(...) forces them all, left to right.
all_of_them <- function(...) {
  length(list(...))
}
all_of_them(noisy("a"), noisy("b"))
#> evaluating a
#> evaluating b
#> [1] 2

# substitute() on ... recovers every expression without forcing any.
expressions <- function(...) {
  vapply(substitute(list(...))[-1L], deparse, character(1L))
}
expressions(shelf_today + 7, nrow(books), "text")
#> [1] "shelf_today + 7" "nrow(books)"     "\"text\""

# Asking for an element that was not supplied is an error.
tryCatch(
  second_only(noisy("only one")),
  error = function(e) conditionMessage(e)
)
#> how many: 1
#> [1] "the ... list contains fewer than 2 elements"

second_only() printed "how many: 3" and evaluated only the second argument. all_of_them() evaluated both. The last function returns the unevaluated source text of each argument, which is the basis of functions that label their inputs, such as data.frame() using the argument expressions as column names. The last call asks for an element that was not supplied and shows the real error text; no argument was evaluated on the way.

Guards: stopifnot() and early validation

stopifnot(..., exprs, exprObject, local = TRUE) is a function whose arguments are evaluated lazily and in order. Its help page says the conceptual equivalent is a sequence of if (any(is.na(A)) || !all(A)) stop(...) statements: the expressions are evaluated sequentially, and evaluation stops as soon as one is not TRUE. That sequential evaluation has been the behaviour since R 3.5.0 and stopifnot(exprs = { ... }) has existed since the same version. Names given to the arguments are used in place of the default error message, which came in R 4.0.0 (R documentation: stopifnot and R NEWS: Changes in R 4.0.0).

# stopifnot() evaluates its arguments lazily, left to right, and stops at the
# first one that is not TRUE.
message_of <- function(expr) {
  tryCatch(expr, error = function(e) conditionMessage(e))
}
message_of(stopifnot(is.numeric("a")))
#> [1] "is.numeric(\"a\") is not TRUE"
message_of(stopifnot(1 == 1, 2 < 1, stop("never evaluated")))
#> [1] "2 < 1 is not TRUE"

# Named arguments become the error message (R 4.0.0).
message_of(stopifnot("the limit must be positive" = -1 > 0))
#> [1] "the limit must be positive"

# exprs = takes a braced block of conditions.
message_of(stopifnot(exprs = {
  is.numeric(21L)
  21L > 30L
  stop("never evaluated")
}))
#> [1] "21L > 30L is not TRUE"

The first call fails on is.numeric("a") with the expression text as the message. The second call never reaches stop("never evaluated"): the third argument is a promise that nobody forced, since evaluation stopped at the failing 2 < 1. A condition can therefore safely depend on an earlier one, as in stopifnot(is.function(f), f(1) > 0), where the second expression would itself fail if f were not a function. The names in the third call turn the message into "the limit must be positive". The block form exprs = { ... } takes the whole block unevaluated and evaluates it one expression at a time with the same stop-at-the-first-failure behaviour.

# A guard at the top of a function forces the arguments it checks, so an
# error in the caller's expression is reported where it is used.
checked_limit <- function(days) {
  stopifnot(is.numeric(days), length(days) == 1L, days > 0)
  days * 2
}
message_of(checked_limit(-3))
#> [1] "days > 0 is not TRUE"
message_of(checked_limit("21"))
#> [1] "is.numeric(days) is not TRUE"
checked_limit(21)
#> [1] 42

# The call recorded in the condition is the function that forced the promise.
caught <- tryCatch(checked_limit(stop("bad argument")), error = identity)
conditionMessage(caught)
#> [1] "bad argument"
conditionCall(caught)
#> stopifnot(is.numeric(days), length(days) == 1L, days > 0)

# A wrapper has to hand the unevaluated block to stopifnot(): the manual's
# assert() does it with substitute() and eval.parent().
assert <- function(exprs) {
  eval.parent(substitute(stopifnot(exprs = exprs)))
}
message_of(assert({
  is.numeric(21L)
  21L > 30L
  stop("never evaluated")
}))
#> [1] "21L > 30L is not TRUE"

A guard at the top of a function forces the arguments it mentions, in order, and reports the first condition that failed. The last two lines show where an error in the caller’s expression is reported. checked_limit(stop("bad argument")) fails while stopifnot() is forcing days, so the condition’s call is the stopifnot(...) call that was running at the time, not the checked_limit() call and not the stop() the caller wrote. A promise is evaluated in the caller’s environment, but the call stack that the error sees is that of the code that forced it. The help page warns against wrapping exprs = in a helper that forwards its argument as stopifnot(exprs = exprs), which would evaluate all parts and pass on only the last result; the assert() in the script uses eval.parent(substitute(...)), the form that the help page recommends, and keeps the one-at-a-time behaviour. Input validation as a design topic continues on Defensive Programming and Input Validation.

Defaults that depend on the clock, match.arg() and on.exit()

A default of Sys.time()

Sys.time() in a default is evaluated at the first use of the argument, not when the function is called. A function that does a long piece of work before it uses the default records a later time than the call time. The script compares the returned time with the time before the call, which makes the check independent of the wall clock; both comparisons print TRUE.

# Sys.time() in a default is evaluated at first use, not at the call.
started <- function(when = Sys.time()) {
  Sys.sleep(0.2)
  when
}
before <- Sys.time()
as.numeric(difftime(started(), before, units = "secs")) > 0.15
#> [1] TRUE

# force() it first to pin the time of the call.
called <- function(when = Sys.time()) {
  force(when)
  Sys.sleep(0.2)
  when
}
before <- Sys.time()
as.numeric(difftime(called(), before, units = "secs")) < 0.15
#> [1] TRUE

started() slept 0.2 seconds before using when, so the time it returned was at least 0.15 seconds after the call. called() forced when first and returned a time within 0.15 seconds of the call. The same applies to Sys.Date(): a default of Sys.Date() in a function that runs across midnight can return the next day. For results that must be reproducible, take the date as an argument and pass it on, as the shelf scenario does with shelf_today.

match.arg() with a default of several choices

The idiom type = c("a", "b") for a choice argument relies on laziness. The default is the vector of valid values. It is a promise like any other, and match.arg(order) finds the choices by looking at the formal arguments of the calling function (R documentation: match.arg). When the caller supplies nothing, the argument is identical to the full set and match.arg() returns the first element; when the caller supplies a value it is matched against the choices, with partial matching.

# match.arg() with a lazy default: the default is the vector of choices.
loan_report <- function(order = c("title", "member", "date")) {
  order <- match.arg(order)
  paste("sorted by", order)
}
loan_report()
#> [1] "sorted by title"
loan_report("mem")
#> [1] "sorted by member"
tryCatch(loan_report("year"), error = function(e) conditionMessage(e))
#> [1] "'arg' should be one of “title”, “member”, “date”"
formals(loan_report)$order
#> c("title", "member", "date")

loan_report() returned the first choice, matched "mem" to "member", and, for "year", failed with the message 'arg' should be one of “title”, “member”, “date”. The quotation marks in that message depend on the locale: a UTF-8 session prints typographic quotes, and other locales print plain ones. formals(loan_report)$order shows the default as the unevaluated call c("title", "member", "date"). As the missing() section showed, missing(order) is false after order <- match.arg(order). R 4.6.0 added a several.ok = "all" option to match.arg(); see its help page (R NEWS: Changes in R 4.6.0).

on.exit() and promises

on.exit(expr, add = FALSE, after = TRUE) is a special primitive that records its first argument without evaluating it and runs it in the function’s frame when the function exits, normally or through an error. Because it is evaluated at the end, it sees the state of the function at that moment, and if it mentions an argument that has not been forced it forces the promise at that point.

# on.exit() expressions run when the function ends: they may force a promise.
clean_up <- function(x) {
  on.exit(cat("on exit the value is", x, "\n"))
  cat("body\n")
  invisible(NULL)
}
clean_up({
  cat("argument evaluated\n")
  1
})
#> body
#> argument evaluated
#> on exit the value is 1

# The expression is recorded unevaluated and runs in the function's frame
# when the function exits, so it sees later changes to local variables.
leave <- function() {
  status <- "started"
  on.exit(cat("status at exit:", status, "\n"))
  status <- "finished"
  invisible(NULL)
}
leave()
#> status at exit: finished

In the first call the argument was evaluated only when the exit code ran: "body" is printed first, then "argument evaluated", then the exit message. If the exit code is the only place where a promise is used and the expression fails, the error is raised during the exit handling. The second function shows that the recorded expression sees "finished", the value at exit, not "started". The help page recommends substitute() when the value at the time of the call is wanted. With add = TRUE several expressions are run in order, and after = FALSE reverses it, which is useful for releasing resources in the opposite order to their acquisition. R 4.0.2 fixed the matching of named arguments in on.exit() calls (R NEWS: Changes in R 4.0.2).

Errors, tryCatch() and promises

An error in a promise is signalled when the promise is forced, by the function that forces it. A call that never forces the argument cannot fail because of it, and the function that does force it is the one that appears in the condition’s call. tryCatch(expr, error = ...) is an ordinary closure: expr is a promise that it forces inside its own handler setup, which is how it can catch an error raised by the code you wrote as the argument (R documentation: conditions).

# An error in a promise is signalled when the promise is forced, in the
# function that forces it.
lazy_use <- function(x) {
  "never touched"
}
tryCatch(lazy_use(stop("no error")), error = function(e) "caught")
#> [1] "never touched"

forces_late <- function(x) {
  cat("about to use x\n")
  x
}
res <- tryCatch(forces_late(stop("failure in the argument")),
  error = function(e) e
)
#> about to use x
conditionMessage(res)
#> [1] "failure in the argument"
deparse(conditionCall(res))
#> [1] "forces_late(stop(\"failure in the argument\"))"

# try() and tryCatch() are ordinary functions: their expr argument is a
# promise that they force inside the handler.
try_or <- function(expr, default) {
  tryCatch(expr, error = function(e) default)
}
try_or(sqrt("a"), NA_real_)
#> [1] NA
try_or(stop("bad"), "fallback")
#> [1] "fallback"

lazy_use(stop("no error")) did not fail. forces_late() printed "about to use x" before the error was raised, and the condition’s call is forces_late(stop("failure in the argument")): the function that forced the promise. try_or() shows the pattern in a few lines: any function that wants to run user code under a handler takes it as an argument and forces it where the handler is in place. Conditions in general are covered on Conditions and Error Handling.

Forcing a promise twice after an error

A promise whose evaluation is interrupted by an error is not marked as evaluated. If the same argument is used again, R evaluates it again and emits a warning, restarting interrupted promise evaluation, so that the second attempt is visible. The script catches that warning with withCallingHandlers() and muffles it so that the script exits cleanly.

# A promise that was interrupted by an error can be forced again, with a
# warning.
retry <- function(x) {
  first <- tryCatch(x, error = function(e) "first attempt failed")
  second <- withCallingHandlers(
    tryCatch(x, error = function(e) "second attempt failed"),
    warning = function(w) {
      cat("warning:", conditionMessage(w), "\n")
      invokeRestart("muffleWarning")
    }
  )
  c(first, second)
}
retry({
  cat("evaluating the argument\n")
  stop("always fails")
})
#> evaluating the argument
#> warning: restarting interrupted promise evaluation
#> evaluating the argument
#> [1] "first attempt failed"  "second attempt failed"

The argument ran twice, as the two "evaluating the argument" lines show, and the warning appeared between them. Without the handler, a script run with options(warn = 1) would print the warning as it happens. If you see this warning in a log, a promise has been used a second time after failing the first time, which usually means a try() or tryCatch() wraps a call whose argument is reused afterwards.

Functions that look like control flow

Promises make it possible to write functions that behave like if or for: they receive code, not values, and control when it runs. with_banner() below uses on.exit() to print a closing line even if the code fails; repeat_n() shows a mistake that is easy to make, assuming a promise can be run repeatedly; and repeat_each() shows the way to do it with substitute() and eval().

# Functions that look like control flow take a promise and decide when, how
# often and where to force it.
with_banner <- function(expr) {
  cat("[begin]\n")
  on.exit(cat("[end]\n"))
  expr
}
with_banner(cat("work happens here\n"))
#> [begin]
#> work happens here
#> [end]

repeat_n <- function(n, expr) {
  for (i in seq_len(n)) {
    expr
  }
  invisible(NULL)
}
repeat_n(3L, cat("tick\n"))
#> tick

# The loop above ran the body once: a promise is evaluated once. To run an
# expression several times, take its code with substitute() and eval() it.
repeat_each <- function(n, expr) {
  code <- substitute(expr)
  for (i in seq_len(n)) {
    eval(code, parent.frame())
  }
  invisible(NULL)
}
repeat_each(3L, cat("tock\n"))
#> tock
#> tock
#> tock

# Standard functions built the same way.
suppressWarnings(as.integer("x"))
#> [1] NA
system.time(NULL)[["elapsed"]] >= 0
#> [1] TRUE

with_banner() printed the work between the two banner lines, which means expr was evaluated at the point of its use inside the function. repeat_n(3L, cat("tick\n")) printed "tick" once: the promise was forced on the first iteration and the later ones used the stored value. repeat_each() captures the expression with substitute() and evaluates it with eval() in the caller’s frame (parent.frame()), which runs it every time, but is non-standard evaluation with the risks described on Evaluation and Non-Standard Evaluation. Standard functions such as suppressWarnings() and system.time() are written in the first style, and their arguments are promises that are forced inside the wrapper.

Common errors and warnings

Message When it appears What to do

promise already under evaluation: recursive default argument reference or earlier problems?

A default refers to its own argument (function(x = x)), two defaults refer to each other, or an earlier error left a promise in a bad state

Rename the variable the default refers to, or supply the argument; look at the first error in the session

restarting interrupted promise evaluation (warning)

An argument whose evaluation failed is used again, for example after being caught with try() or tryCatch() and then reused

Evaluate the argument once, catch the error where it is first used, and do not reuse the failed argument

argument "x" is missing, with no default

The body evaluates a formal argument that the caller did not supply and that has no default, directly or through a chain of calls

Test with missing(x) first, supply a default, or pass the argument from the caller

'missing(x)' did not find an argument

missing() is used on a name that is not a formal argument of the running function, or inside a nested function

Call missing() in the body of the function that defines the argument

the ... list contains fewer than 2 elements

...elt(n) asks for an element beyond the dots that were supplied (the exact wording changes with the number)

Check ...length() first

'arg' should be one of “title”, “member”, “date”

match.arg() received a value that is not among the choices

Use one of the listed values; the quotation marks depend on the locale

is.numeric(days) is not TRUE

A stopifnot() condition is false; the message is the text of the first one that failed

Fix the argument, or give the condition a name to get your own message

Every message in the table was produced by a script of this page.

Where older tutorials differ

Material about lazy evaluation is among the oldest in the R literature, and several well-known statements are now out of date or were never general.

Older tutorial Current practice Why Version

“lapply() closures all see the last value; wrap each in force() or local()”

lapply(), sapply(), vapply(), Map() and Reduce() can be used without forcing; a hand-written for loop still needs force() or local()

The apply functions and Reduce() force the arguments they pass on

R 3.2.0

“Put force() on every argument”

Force an argument that a closure keeps and that can change before the closure runs, or that must be evaluated early; do not decorate every function

A promise that is only used while the function runs does not need forcing

always

“Default arguments are evaluated when the function is defined” (the Python rule)

A default is evaluated in the function’s frame when first needed, at every call

R-lang: Argument evaluation

always

“missing(x) tells you whether x has a value”

It tells you whether the caller supplied x; it is TRUE for an argument that has a default, and it is unreliable once x has been assigned to

missing() help page, R-ints: Missingness

always

“Check arguments with if (!cond) stop("..."), one line per condition”

stopifnot(...) evaluates in order and stops at the first failure; stopifnot(exprs = { }) takes a block; named arguments give custom messages

stopifnot() evaluates sequentially (3.5.0) and exprs = exists (3.5.0); named arguments (4.0.0)

R 3.5.0, 4.0.0

Tutorials that inspect promises with the pryr package

substitute(x, env) returns the expression of a promise without forcing it; community packages (rlang, lobstr) offer richer views

pryr was archived on CRAN on 2026-01-30 at the maintainer’s request, so tutorials that install it no longer work as written

2026

Older code that avoids naming the arguments of on.exit()

Write add = TRUE and after = FALSE by name

named arguments of on.exit() are matched correctly since R 4.0.2

R 4.0.2

“R passes arguments by reference until they are modified”

R has call-by-value semantics with lazy evaluation: assigning to an argument never changes the caller’s variable

R-lang: Argument evaluation (the copy is avoided internally by reference counting, which replaced NAMED in R 4.0.0)

always

Community references on the same topic include the chapter on functions in the book Advanced R (Advanced R: Functions), which has a section on promises and says that inspecting a promise from R code forces it. It is community material and it is not used as a source for any claim above.

See also

References