rguides

on.exit

on.exit() registers an expression that R evaluates when the enclosing function exits, regardless of whether it returns normally, hits an early return(), or throws an error. This makes it the standard way to handle cleanup in R functions, you register what needs to happen once, and R handles it on the way out.

Signature

on.exit(expr, add = FALSE, which = NULL)

Parameters

ParameterTypeDefaultDescription
exprexpression,An expression evaluated when the current function exits
addlogicalFALSEIf FALSE, replaces any existing exit expression; if TRUE, appends to the list
whichintegerNULLR ≥ 3.5.0 only. Specifies which existing exit handler to remove

The return value is an integer representing the exit handler, returned invisibly. You rarely need to use it.

Basic usage

A basic pattern is to capture and then restore some temporary state:

quiet_mode <- function(expr) {
  old_scipen <- options("scipen")$scipen
  on.exit(options(scipen = old_scipen), add = TRUE)
  options(scipen = 999)
  eval(substitute(expr))
}

quiet_mode(print(1e10))
# [1] 10000000000
options("scipen")$scipen
# [1] 0

The default behaviour of on.exit() is to replace any previously registered exit expression. Each call overwrites the one before it, so only the last registration survives. This is useful when you want a single, definitive cleanup action — but it can surprise you if you expect handlers to accumulate.

f <- function() {
  on.exit(cat("first\n"))
  on.exit(cat("second\n"))
  cat("body\n")
}
f()
# body
# second

The output confirms that only the second handler executes: the first is silently discarded. To keep multiple handlers, you need to change the default behaviour. Use add = TRUE to append each new handler to the existing list instead of replacing it:

f <- function() {
  on.exit(cat("first\n"), add = TRUE)
  on.exit(cat("second\n"), add = TRUE)
  cat("body\n")
}
f()
# body
# second
# first

Exit handlers run in reverse order of registration, last in, first out (LIFO). That is why the second handler runs before the first in the output above. This reverse order matches how you would naturally think about unwinding setup steps: the last thing you set up gets torn down first. You will see this LIFO pattern in every part of R where resources are stacked, from graphics parameters to open file connections.

Lazy evaluation

The registered expression is stored as a promise. Variables are looked up at exit time, not registration time:

x <- 10
f <- function() {
  on.exit(print(x))
  x <- 20
  cat("body, x =", x, "\n")
}
f()
# body, x = 20
# [1] 20

If you need to capture a value at registration time rather than exit time, force evaluation explicitly by assigning it to a local variable inside the function body. This freezes the value at the moment you register the handler, so later changes to the original variable do not affect what the handler sees when it runs.

f <- function() {
  snapshot <- x      # force evaluation now
  on.exit(print(snapshot))
  x <- 20
}
f()
# [1] 10

Common use cases

Reset options()

The most common on.exit() pattern preserves global state that your function temporarily changes. Here we widen the digit display, run a computation, and let the handler restore the original setting regardless of how the function exits. This approach avoids the common bug of forgetting to manually reset options after an error:

wider_print <- function(df) {
  old_digits <- options("digits")$digits
  on.exit(options(digits = old_digits), add = TRUE)
  options(digits = 15)
  print(df)
}

wider_print(head(mtcars))
#                    mpg cyl disp  hp drat    wt  qsec vs am gear carb
# Mazda RX4         21.0   6  160.0 110 3.90 2.620 16.46  0  1    4    4
# Mazda RX4 Datsun  21.0   6  160.0 110 3.90 2.875 17.02  0  1    4    4
options("digits")$digits
# [1] 7

Close connections

Opening files, sockets, or database connections inside a function requires matching cleanup. on.exit() guarantees the connection closes even if readLines() or another downstream call throws an error. This pattern prevents file descriptor leaks that can accumulate and eventually exhaust the operating system’s open file limit:

count_lines <- function(path) {
  conn <- file(path, "r")
  on.exit(close(conn), add = TRUE)
  length(readLines(conn))
}

count_lines("/etc/hosts")
# [1] 10
# connection is closed automatically

Restore graphical parameters

Base R graphics rely on global par() settings. A function that temporarily adjusts margins, colours, or layout must restore the original values, otherwise every subsequent plot inherits the changed parameters. With on.exit(), the restoration happens automatically when the plotting function returns:

plot_wide <- function() {
  old_mar <- par("mar")
  on.exit(par(mar = old_mar), add = TRUE)
  par(mar = c(2, 2, 2, 2))
  plot(1:10)
  # margins restored on exit
}

Clean up temp files

When a function creates temporary files for intermediate storage, on.exit() ensures they are deleted no matter what happens. Without this pattern, failed downloads or parsing errors leave orphaned files accumulating in the temp directory. The unlink() call in the handler runs even if read.csv() throws a parsing error:

process_file <- function(url) {
  tmp <- tempfile()
  on.exit(unlink(tmp), add = TRUE)
  download.file(url, tmp, quiet = TRUE)
  read.csv(tmp)
  # temp file deleted regardless of success or failure
}

Interaction with tryCatch

on.exit() fires inside a tryCatch() block regardless of whether the body succeeds or throws an error. The exit handler runs after the error condition is caught but before execution continues to any code that follows the tryCatch() call. This ordering is important: cleanup always happens after error handling, not before. Here is a function that demonstrates the interaction:

f <- function() {
  on.exit(cat("cleanup\n"))
  tryCatch({
    cat("trying\n")
    stop("oops")
  }, error = function(e) {
    cat("caught:", conditionMessage(e), "\n")
  })
  cat("after tryCatch\n")
}
f()
# trying
# caught: oops
# cleanup
# after tryCatch

R 4.0.0 added a finally argument to tryCatch() that serves a similar purpose. Unlike on.exit(), which is scoped to the entire function, the finally block runs immediately as tryCatch() completes — it is a block-level cleanup tool rather than a function-level one:

f <- function() {
  tryCatch({
    cat("body\n")
  }, finally = {
    cat("cleanup via finally\n")
  })
}
f()
# body
# cleanup via finally

Both on.exit() and finally handle cleanup, but on.exit() is idiomatic for per-function state management while finally is useful for block-level operations.

which argument (R ≥ 3.5.0)

The which argument lets you remove specific exit handlers by their index. This is useful when a function registers multiple handlers conditionally and needs to cancel a particular one before the function returns. Each on.exit() call returns an integer identifier that you can pass to a later on.exit(which = ...) call to selectively remove that handler from the cleanup list:

f <- function() {
  h1 <- on.exit(cat("first\n"), add = TRUE)
  h2 <- on.exit(cat("second\n"), add = TRUE)
  on.exit(which = h1)
  cat("body\n")
}
f()
# body
# second

on.exit() expressions run even when a function exits abnormally due to an error. This makes it the correct tool for cleanup that must always happen, not just on success. When you set add = TRUE, multiple on.exit() calls stack rather than replace each other, which lets different parts of a function each register their own cleanup.

on.exit() expressions run in the order they were registered when add = TRUE is set (FIFO, not LIFO). This matches the intuition that setup steps undo in the order they were added. However, when add = FALSE (the default), each on.exit() call replaces any previously registered expression entirely. For functions that register multiple cleanup steps, always use on.exit(..., add = TRUE), omitting add = TRUE on the second call silently discards the first cleanup, which is a common source of resource leak bugs.

See also

  • tryCatch(), R’s full error handling mechanism
  • cat() — output objects to the console (often used in exit handlers)