Debugging
|
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 (the base, utils, methods and stats packages that ship with R), so no add-on package version applies; the lintr 3.4.0 default linters were used only to check the scripts. Debugging is the part of programming where you stop guessing: you ask R which calls were active when something failed, you stop the program at a chosen point and look at the variables, or you step through a function one statement at a time. R’s tools for this are small and old, they work the same way in a terminal, in RStudio, in Positron and in a batch job, and the graphical debuggers are front ends to them. This page describes each tool, shows what it prints, and says which one to reach for when. The error-handling side, catching and signalling conditions, is in Conditions and Error Handling; checking inputs early so that the failure is close to its cause is in Defensive Programming and Input Validation.
There are two kinds of example. The first kind is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)); the lines that start with #> are what it printed, in the order R printed them, so a message that R writes to the error stream appears where it was written. The scripts pass the default lintr linters; a # nolint: comment marks a line where the code is what a default linter dislikes. The second kind is a transcript of an interactive session, shown as a text block with the prompts exactly as R printed them: > for a new command, + for a continued one, Browse[1]> inside the browser and Selection: in the menus of recover() and debugger(). Debugging is conversational, and the prompts are the point, so these blocks are not scripts and carry no #> lines. They were captured by feeding the commands to R --interactive --no-save --quiet, which makes R echo the commands it reads, and a final empty prompt is left out. An interactive session keeps the source text of the functions you type (options(keep.source = TRUE) is the default there), which is why the transcripts show source references such as at #3 and debug at #2: that Rscript output does not have. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale.
The tools at a glance
The R Language Definition puts it in one paragraph: most debugging takes place through calls to browser() or debug(), which rely on the same internal mechanism and give the user a special prompt at which any command can be typed, evaluated in the currently active environment (R-lang: Debugging). Everything else on this page is a way of arriving at that prompt, or of finding out where to arrive. The debugger only sees interpreted R code: if a function calls compiled code, execution halts at the next statement that is evaluated in R, and a symbolic debugger such as gdb is needed for the compiled part (a pointer to that is in the section on compiled code below).
| Tool | What it is for | Needs a prompt | Section |
|---|---|---|---|
|
Which calls were active when the last uncaught error happened. |
No |
|
|
Stop at a line you added to the code and look around. |
Yes |
|
|
Step through a function without editing it. |
Yes |
Stepping through a function: |
|
Insert code (a message, a condition, a |
Only if it inserts a browser |
|
|
After an error, pick any frame of the stack and look at its variables, without running the code again. |
Yes |
|
|
Save the frames of an error, to a variable or a file, and look at them later or elsewhere. |
Only |
|
|
Turn warnings into errors, so that they stop at the offending call and leave a stack. |
No |
|
|
See the stack at the moment a condition is signalled, without unwinding it. |
No |
A debugging session has a rhythm that does not depend on the tool: find where the failure happens, form a guess about the state that causes it, look at that state, change something, and run again. The tools differ in how much they make you re-run. traceback() and options(error = recover) work on an error that has already happened. browser() and trace() ask you to run the code again with a stop in it. debug() asks for a new run with the stop at the start of a function. The cheapest tool that answers the question is the right one, and the sections below follow that order.
The error message is the first tool
R reports an uncaught error as Error in <call> : <message>. The call is the function call that was being evaluated when the error was signalled, and the message is the text given to stop() or produced by R itself. When the pair does not fit on one line the message moves to the next line. Both parts are available as data when the error is caught: a caught error is a condition object, a list with the fields message and call, and conditionMessage() and conditionCall() read them (R documentation: conditions).
The running example for this page is a small piece of the shelf scenario. member_report() counts a member’s loans and how many were overdue; it calls count_overdue(), which calls is_overdue() once per loan through vapply(). The scenario data has a loan whose date is the impossible 2025-02-30, which as.Date() turned into NA, and the member is Ana Ruiz, so the first report that touches that loan fails. The script defines the three functions, runs the report for another member (which works), and then catches the failing one as a condition object.
source("scenario.R")
# A chain of three functions with a bug: a loan with an impossible date.
is_overdue <- function(loan_date, return_date,
today = shelf_today) { # nolint: object_usage_linter.
end <- if (is.na(return_date)) today else return_date
if (as.integer(end - loan_date) > loan_days) TRUE else FALSE # nolint: object_usage_linter, line_length_linter.
}
count_overdue <- function(rows) {
flags <- vapply(seq_len(nrow(rows)), \(i) {
is_overdue(rows$loan_date[i], rows$return_date[i])
}, logical(1L))
sum(flags)
}
member_report <- function(loans, who) {
rows <- loans[loans$member == who, ]
c(loans = nrow(rows), overdue = count_overdue(rows))
}
member_report(loans, "Ben Okafor")
#> loans overdue
#> 5 3
# Catch the error as a condition object: it records the message and the call.
err <- tryCatch(member_report(loans, "Ana Ruiz"), error = \(e) e)
class(err)
#> [1] "simpleError" "error" "condition"
conditionMessage(err)
#> [1] "missing value where TRUE/FALSE needed"
conditionCall(err)
#> if (as.integer(end - loan_date) > loan_days) TRUE else FALSE
# An error raised with call. = FALSE, like the ones validate_book() signals,
# names no call at all: the stack is then the only clue to where it came from.
bad <- tryCatch(
book("Orchard Logic", isbn = "978-0-306-40615-8"),
error = \(e) e
)
bad
#> <simpleError: invalid ISBN-13: 9780306406158>
conditionCall(bad)
#> NULL
# An error that was caught leaves no traceback behind.
traceback()
#> No traceback available
The message, missing value where TRUE/FALSE needed, is what R says when an if condition is NA, and the call is the if expression itself, not the function around it: for errors that R detects in a language construct the "call" is that construct, which is why the call shown does not look like a function call. Nothing in the message says which member, which loan or which function called is_overdue(). That is what the next tools are for. Two details in the output matter. The error raised with call. = FALSE has no call at all: conditionCall() is NULL and the printed condition is <simpleError: ...> with no in part. validate_book() and book() in the scenario signal their errors that way, so for them the stack is the only record of where the error came from. And the final traceback() says No traceback available, because both errors were caught by tryCatch() and only an error that reaches the top level leaves a stack behind.
What the same error looks like when nobody catches it depends on how R was started. In an interactive session R prints the message and returns to the prompt, ready for traceback(). In a non-interactive session, such as Rscript or R -f, R prints the message, adds a Calls: line, prints Execution halted and exits with status 1. The next block is a script that fails on purpose and the complete output of running it with Rscript; it is a fragment, not a runnable example, because it ends in the error.
source("scenario.R")
is_overdue <- function(loan_date, return_date,
today = shelf_today) { # nolint: object_usage_linter.
end <- if (is.na(return_date)) today else return_date
if (as.integer(end - loan_date) > loan_days) TRUE else FALSE # nolint: object_usage_linter, line_length_linter.
}
count_overdue <- function(rows) {
flags <- vapply(seq_len(nrow(rows)), \(i) {
is_overdue(rows$loan_date[i], rows$return_date[i])
}, logical(1L))
sum(flags)
}
member_report <- function(loans, who) {
rows <- loans[loans$member == who, ]
c(loans = nrow(rows), overdue = count_overdue(rows))
}
member_report(loans, "Ben Okafor")
member_report(loans, "Ana Ruiz")
cat("not reached\n")
The output of that script:
loans overdue
5 3
Error in if (as.integer(end - loan_date) > loan_days) TRUE else FALSE :
missing value where TRUE/FALSE needed
Calls: member_report -> count_overdue -> vapply -> FUN -> is_overdue
Execution halted
The first report printed its answer, and the second stopped the script: not reached never appears. The Calls: line is a compact traceback, outermost call first and joined with ->, and it is there because the showErrorCalls option is on by default in non-interactive sessions (the options help page says that the call stacks are shown by default for errors there). The anonymous function that vapply() calls appears as FUN. The line is shortened when the sequence is long: showNCalls, by default 50 bytes, sets the length at which ellipses are used, and options(showErrorCalls = FALSE) removes the line altogether, as a later script shows. It is a summary, not a traceback: it has no arguments and no source locations, and it says nothing about which iteration of vapply() failed.
Reading the stack with traceback()
traceback() prints the call stack of the last uncaught error: the sequence of calls that led to it. The help page calls it useful when an error occurs with an unidentifiable error message, and Writing R Extensions recommends running it whenever the cause of an error is not immediately obvious, because errors are often reported as being in some package when the traceback shows that they were raised by another package or by base R (Writing R Extensions: Debugging R code). The calls are listed in reverse order, innermost first, each with the number of its frame, so frame 1 is the call you typed. The help page adds that calls to builtins are included in the stack but calls to specials are not.
traceback() at the prompt
This is the same failing report in an interactive session. Because the session keeps source references, an entry for a call that sits inside a function typed at the prompt ends in at #n, the line of that function’s definition on which the call is written.
> source("scenario.R")
> is_overdue <- function(loan_date, return_date, today = shelf_today) {
+ end <- if (is.na(return_date)) today else return_date
+ if (as.integer(end - loan_date) > loan_days) TRUE else FALSE
+ }
> count_overdue <- function(rows) {
+ flags <- vapply(seq_len(nrow(rows)), \(i) {
+ is_overdue(rows$loan_date[i], rows$return_date[i])
+ }, logical(1L))
+ sum(flags)
+ }
> member_report <- function(loans, who) {
+ rows <- loans[loans$member == who, ]
+ c(loans = nrow(rows), overdue = count_overdue(rows))
+ }
> member_report(loans, "Ana Ruiz")
Error in if (as.integer(end - loan_date) > loan_days) TRUE else FALSE :
missing value where TRUE/FALSE needed
> traceback()
5: is_overdue(rows$loan_date[i], rows$return_date[i]) at #3
4: FUN(X[[i]], ...)
3: vapply(seq_len(nrow(rows)), function(i) {
is_overdue(rows$loan_date[i], rows$return_date[i])
}, logical(1L)) at #2
2: count_overdue(rows) at #3
1: member_report(loans, "Ana Ruiz")
> .traceback()[[1]]
[1] "is_overdue(rows$loan_date[i], rows$return_date[i])"
Source: R documentation: traceback
Reading the numbers from the bottom, member_report() called count_overdue() from line 3 of its definition, which called vapply() from line 2 of its own definition, which called the anonymous function, shown as FUN(X[[i]], ...), which called is_overdue() from line 3 of the definition of count_overdue() (the anonymous function is part of that definition, so its lines are counted there); and the error was detected in is_overdue(). The anonymous function \(i) ... is shown as function(i) {...}: the backslash form is only a different way of typing the same call. The last command, .traceback()[[1]], shows that the stack is data: .traceback() returns the stack as a list or pairlist of calls, deepest first, with each call as a character vector of deparsed lines, and traceback() formats and prints that list. .traceback() was added in R 3.3.0, and since R 4.0.0 the stack that R records when the error is signalled (.Traceback) holds the calls themselves and deparses them only when traceback() or .traceback() is called; the help page warns that where .Traceback is stored is undocumented, so use the functions, not the variable.
traceback() in a script
An Rscript run ends at the first uncaught error, so there is no prompt at which to type traceback(). The error option fixes that: it holds a function, or an expression, that R runs when an error reaches the top level, after printing the message and before it halts. The next script prints the traceback from such a handler and then leaves with q(status = 0). The argument 3 of traceback() means "skip three entries at the top of the stack": the entries for traceback(), .traceback() and the handler that called them, which the help page recommends for exactly this use.
source("scenario.R")
is_overdue <- function(loan_date, return_date,
today = shelf_today) { # nolint: object_usage_linter.
end <- if (is.na(return_date)) today else return_date
if (as.integer(end - loan_date) > loan_days) TRUE else FALSE # nolint: object_usage_linter, line_length_linter.
}
count_overdue <- function(rows) {
flags <- vapply(seq_len(nrow(rows)), \(i) {
is_overdue(rows$loan_date[i], rows$return_date[i])
}, logical(1L))
sum(flags)
}
member_report <- function(loans, who) {
rows <- loans[loans$member == who, ]
c(loans = nrow(rows), overdue = count_overdue(rows))
}
# Print the stack when an error reaches the top level, then leave with status 0.
# traceback(3) skips three entries at the top of the stack: traceback(),
# .traceback() and the error handler that called them.
options(error = function() {
traceback(3)
q(status = 0)
})
member_report(loans, "Ana Ruiz")
#> Error in if (as.integer(end - loan_date) > loan_days) TRUE else FALSE :
#> missing value where TRUE/FALSE needed
#> Calls: member_report -> count_overdue -> vapply -> FUN -> is_overdue
#> 5: is_overdue(rows$loan_date[i], rows$return_date[i])
#> 4: FUN(X[[i]], ...)
#> 3: vapply(seq_len(nrow(rows)), function(i) {
#> is_overdue(rows$loan_date[i], rows$return_date[i])
#> }, logical(1L))
#> 2: count_overdue(rows)
#> 1: member_report(loans, "Ana Ruiz")
Source: R documentation: traceback
The first three lines are R’s own message and Calls: summary; everything from 5: down is traceback(3). With traceback(2) the handler itself would show up as the top entry, and for an error raised by stop() the stop() call is an entry too, as the next script shows with the smallest possible chain. The handler in this script also reads the stack through .traceback(3). Called directly from the handler, .traceback(3) skips three entries too, but they are .traceback() itself, the handler and the stop() call, so what it returns is the three frames of f(), g() and h().
f <- function(x) g(x)
g <- function(x) h(x)
h <- function(x) stop("boom in h")
# traceback(2) skips traceback() and .traceback() only, so the handler shows up.
options(error = function() {
traceback(2)
# .traceback() returns the same stack as a list, deepest call first.
stack <- .traceback(3)
cat("class:", class(stack), "\n")
cat("length:", length(stack), "\n")
cat("innermost:", stack[[1L]], "\n")
q(status = 0)
})
f(1)
#> Error in h(x) : boom in h
#> Calls: f -> g -> h
#> 5: (function ()
#> {
#> traceback(2)
#> stack <- .traceback(3)
#> cat("class:", class(stack), "\n")
#> cat("length:", length(stack), "\n")
#> cat("innermost:", stack[[1L]], "\n")
#> q(status = 0)
#> })()
#> 4: stop("boom in h")
#> 3: h(x)
#> 2: g(x)
#> 1: f(1)
#> class: pairlist
#> length: 3
#> innermost: h(x)
The handler appears in the traceback as an anonymous function call, (function () {...})(), because R evaluates the error option as a call to the function. The stop("boom in h") entry is frame 4, above h(x), since stop() is itself a function call and the error is signalled from inside it. The class: pairlist line is a detail worth knowing when you process the result: the help page says .traceback() returns a list or a pairlist, and for the stack of an error it is a pairlist, so wrap it in as.list() before using lengths() or vapply() on it (the script below does).
The error option takes any of three forms. A function is called with no arguments (options(error = function() ...), or the name of one such as recover). An expression is evaluated: options(error = quote(...)) or an expression() vector, which is the form that fits the one-liners in the manuals. And NULL, the default, restores the standard behaviour: return to the prompt in an interactive session, halt in a non-interactive one. In start-up files the functions of the utils package have to be written with the namespace, as options(error = utils::recover), because the package is not yet attached when the file runs (the options help page says so). A traceback() that is very long can be shortened: max.lines limits the number of lines printed per call, and the options traceback.max.lines and deparse.max.lines set the limit for the whole session. A long call is what do.call() produces when the function is given the objects themselves: the call that R records holds the values, not the names, so a data frame argument is pasted into it. The next script shows how long such an entry gets, and what max.lines = 2 does to it.
source("scenario.R")
first_overdue <- function(df) {
stopifnot(nrow(df) > 100L)
days <- df$return_date - df$loan_date
which(days > loan_days)[1L] # nolint: object_usage_linter.
}
# do.call() puts the argument values, not their names, into the call. With a
# data frame the call that traceback() has to deparse is long: lengths() counts
# the lines of each entry, deepest call first.
options(error = function() {
stack <- as.list(.traceback(3))
cat("lines per entry:", lengths(stack), "\n")
short <- as.list(.traceback(3, max.lines = 2L))
cat("with max.lines = 2:", lengths(short), "\n")
traceback(3, max.lines = 2L)
q(status = 0)
})
do.call("first_overdue", list(loans))
#> Error in first_overdue(list(loan_id = c("L001", "L002", "L003", "L004", :
#> nrow(df) > 100L is not TRUE
#> Calls: do.call -> first_overdue -> stopifnot
#> lines per entry: 1 1 50 1
#> with max.lines = 2: 1 1 2 1
#> 4: stop(simpleError(msg, call = if (p <- sys.parent(1L)) sys.call(p)))
#> 3: stopifnot(nrow(df) > 100L)
#> 2: first_overdue(list(loan_id = c("L001", "L002", "L003", "L004",
#> "L005", "L006", "L007", "L008", "L009", "L010", "L011", "L012",
#> ...
#> 1: do.call("first_overdue", list(loans))
The failing function was reached through do.call("first_overdue", list(loans)), so its entry in the stack, number 2, contains the whole data frame as an argument: lengths() counts 50 lines for it against 1 for the others, and max.lines = 2L keeps the first two lines of it and marks the cut with .... With a function object instead of its name, do.call(first_overdue, list(loans)), the entry would begin with the deparsed function as well. Either is a reason to pass names of objects, not objects, through do.call() when the call may appear in a traceback; see Evaluation and Non-Standard Evaluation for do.call() itself.
When traceback() does not help
It reports the stack of the last uncaught error, and only that. An error caught by try() or tryCatch() generates no traceback, and neither does a warning; the help page says that what traceback() prints is the call sequence for the last uncaught error and not necessarily for the last error. The stack ends where the error was signalled, so it cannot tell what the values were. Writing R Extensions gives three more reasons it may not point at the cause. If the innermost call is .C, .Fortran, .Call, .External or .Internal, the error came from compiled code, and as such code can evaluate R expressions the compiled call need not be the innermost one. When S4 method dispatch is involved the traceback can stop at a call such as initialize(value, ...) while the error message names a function that is not in the stack. And for infinite recursion the message evaluation nested too deeply: infinite recursion / options(expressions=)? can be hard to read at the default limit of 5000 nested expressions, so the manual suggests lowering it with options(expressions = 500) and running the example again (the script below does this). In all three cases the tools of the next sections, which stop the program while it is still running, give more.
The live stack: sys.calls() in a calling handler
A calling handler runs on top of the stack of the condition it handles, before anything is unwound, so it can read the live stack with sys.calls() (R documentation: sys.parent and its relatives). That is the one way to get a stack for a condition that a tryCatch() further out would catch, and the way to log the stack of an error without stopping the program. tryCatch(), by contrast, unwinds first, so its handler sees only the frames outside it. The script below logs the stack of the failing report and then lets the error continue to try().
source("scenario.R")
is_overdue <- function(loan_date, return_date,
today = shelf_today) { # nolint: object_usage_linter.
end <- if (is.na(return_date)) today else return_date
if (as.integer(end - loan_date) > loan_days) TRUE else FALSE # nolint: object_usage_linter, line_length_linter.
}
count_overdue <- function(rows) {
flags <- vapply(seq_len(nrow(rows)), \(i) {
is_overdue(rows$loan_date[i], rows$return_date[i])
}, logical(1L))
sum(flags)
}
# A calling handler runs on top of the stack of the error, before it unwinds, so
# sys.calls() still shows the frames that led to it. tryCatch() would have
# unwound them first.
show_stack <- function(e) {
calls <- sys.calls()
cat("frames on the stack:", length(calls), "\n")
for (i in seq_along(calls)) {
cat(i, ": ", deparse(calls[[i]])[1L], "\n", sep = "")
}
}
result <- try(
withCallingHandlers(
count_overdue(loans[loans$member == "Ana Ruiz", ]),
error = show_stack
),
silent = TRUE
)
#> frames on the stack: 12
#> 1: try(withCallingHandlers(count_overdue(loans[loans$member == "Ana Ruiz",
#> 2: tryCatch(expr, error = function(e) {
#> 3: tryCatchList(expr, classes, parentenv, handlers)
#> 4: tryCatchOne(expr, names, parentenv, handlers[[1L]])
#> 5: doTryCatch(return(expr), name, parentenv, handler)
#> 6: withCallingHandlers(count_overdue(loans[loans$member == "Ana Ruiz",
#> 7: count_overdue(loans[loans$member == "Ana Ruiz", ])
#> 8: vapply(seq_len(nrow(rows)), function(i) {
#> 9: FUN(X[[i]], ...)
#> 10: is_overdue(rows$loan_date[i], rows$return_date[i])
#> 11: .handleSimpleError(function (e)
#> 12: h(simpleError(msg, call))
class(result)
#> [1] "try-error"
Frames 1 to 6 are the machinery of try() and tryCatch() that this particular script wraps around the call, 7 to 10 are the code under test, in the same order as in the traceback, and 11 and 12 are R’s own signalling code, which a handler always sits on top of: .handleSimpleError() and the call of the handler h(). The handler then returned, so the error continued to try(), which returned an object of class try-error because of silent = TRUE. The same calls list could be written to a log file instead of printed, or passed to traceback(), which accepts a list of calls as well as a number.
Stopping inside the code: browser()
A call to browser() in the body of a function stops the evaluation at that point and gives you a prompt, Browse[1]>, at which any R expression is evaluated in the environment where browser() was called. It is the lowest-level tool: debug() and the breakpoints of the IDEs use the same browser, and the RStudio user guide calls it the lowest-level debugging tool (R-lang: browser; RStudio user guide: Debugging). Its signature is browser(text = "", condition = NULL, expr = TRUE, skipCalls = 0L). The first two arguments carry information for helper programs and can be read back at the prompt with browserText() and browserCondition(); expr makes the stop conditional; skipCalls is for wrappers, and tells the browser to report a location further up the stack when the call to browser() is itself nested in another debugging function (R documentation: browser).
The prompt accepts ordinary R, plus a handful of one-word commands that R interprets itself. The R Language Definition lists five of them, the help page lists them all:
| Command | What it does |
|---|---|
|
Evaluate the next statement, stepping over function calls. In step-through mode (the browser entered by |
|
Evaluate the next statement, stepping into function calls. |
|
Finish the current loop or function, then stop again at the first statement after it. |
|
Leave the browser and continue; in step-through mode, continue to the end of the current context, such as the loop or the function. |
|
Leave the browser and the current evaluation, and return to the top level. |
|
Print the stack of active function calls. |
|
Print this list. |
|
Invoke a |
Source: R documentation: browser
Leading and trailing blanks are ignored except on an empty line. What an empty line does depends on the option browserNLdisabled: if it is TRUE, empty lines are ignored; if not, an empty line is the same as n, or as s if that was used most recently. This is R 3.1.0 behaviour: the NEWS of that release made a call to browser() consistent with the browser entered by debug(), so that Enter means n rather than c, and added the s and f commands (R NEWS: Changes in R 3.1.0). Older tutorials that say Enter continues from a browser() call describe R before 3.1.0.
The transcript below puts a browser() call between the two statements of a small function from the shelf scenario. days_out() counts the days between a loan and its return, or between the loan and shelf_today when it is still out; late_days() subtracts the loan period. Loan L012 is the one that was returned three days before it was lent, and the function quietly returns 0 for it. The browser shows why.
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ browser()
+ max(days - loan_days, 0L)
+ }
> late_days(loans$loan_date[12], loans$return_date[12])
Called from: late_days(loans$loan_date[12], loans$return_date[12])
Browse[1]> ls()
[1] "days" "loan_date" "return_date"
Browse[1]> days
[1] -3
Browse[1]> help
n next
s step into
f finish
c or cont continue
Q quit
where show stack
help show help
<expr> evaluate expression
Browse[1]> where
where 1: late_days(loans$loan_date[12], loans$return_date[12])
Browse[1]> n
debug at #4: max(days - loan_days, 0L)
Browse[1]> days - loan_days
[1] -24
Browse[1]> c
[1] 0
Source: Writing R Extensions: Browsing
Line by line: Called from: ... names the call whose frame the browser is in. ls() lists that frame, which holds the arguments and the days already computed; days prints -3, the day count of a loan returned before it was lent, which the arithmetic of late_days() turned into 0 without complaint. help prints the table above. where prints the stack, here only the call to late_days(), with a blank line after it. n enters step-through mode: R prints the statement it is about to run, debug at #4: ..., without having run it, and an expression typed at the prompt, days - loan_days, evaluates to -24. c leaves the browser and the function returns its value, 0.
Anything entered at the prompt is evaluated in the frame, so a variable can be changed as well as read. Writing R Extensions states the consequence plainly: objects altered by assignment keep their changed values when the browser is exited, errors in the code typed at the prompt return control to the prompt, and if something really must be assigned in the workspace, that can be done with <<- when the name is not already in scope. The help page for trace() says the opposite about changes disappearing; what happened in the session below, where the variable changed at the prompt changed the result, matches the manual. If a local variable has the name of a command, such as n, c, s, f or Q, typing the name runs the command; print the variable with print(n), with (n) or, as the R Language Definition suggests, with get("n").
Conditional breakpoints
Stopping on every iteration of a loop is useless when the interesting one is the 512th. browser(expr = <condition>) evaluates the condition and enters the prompt only when it is true. The help page says that it is an illusion of conditional debugging, because execution is always paused at the call and the condition only decides whether control passes to the evaluator, and that an if statement around a bare browser() is usually more efficient; the argument is simply shorter to write in a place where an if is awkward. The text argument labels the stop, so that browserText() can tell several breakpoints apart.
The function in the transcript counts the loans in a data frame that were returned before they were lent. The browser stops only for the loan that is wrong, the loop variable i and the data say which one, and the edit to days at the prompt keeps the loan out of the count.
> source("scenario.R")
> count_bad <- function(loans) {
+ bad <- 0L
+ for (i in seq_len(nrow(loans))) {
+ days <- as.integer(loans$return_date[i] - loans$loan_date[i])
+ browser(text = "return before loan", expr = !is.na(days) && days < 0L)
+ if (!is.na(days) && days < 0L) bad <- bad + 1L
+ }
+ bad
+ }
> count_bad(loans)
Called from: count_bad(loans)
Browse[1]> i
[1] 12
Browse[1]> loans$loan_id[i]
[1] "L012"
Browse[1]> browserText()
[1] "return before loan"
Browse[1]> days <- 0L
Browse[1]> bad
[1] 0
Browse[1]> c
[1] 0
The first loan whose period is negative is the twelfth, L012; the label passed as text comes back from browserText(); the variable days was set to 0 at the prompt, so bad is still 0 when the loop continues, and the function returns 0 where it would have returned 1.
Browser levels
The number in Browse[1]> is the browser level. A browser entered from the prompt of another browser is one level deeper: typing a call to a function that itself contains browser() gives Browse[2]>, where shows the nested call on top of the stack, and c returns to level 1. debug() replaces its browser at every step rather than nesting a new one, which is why its prompt stays at Browse[1]>.
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ browser()
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ browser()
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> late_days(loans$loan_date[12], loans$return_date[12])
Called from: late_days(loans$loan_date[12], loans$return_date[12])
Browse[1]> days_out(loan_date, return_date)
Called from: days_out(loan_date, return_date)
Browse[2]> where
where 1 at #2: days_out(loan_date, return_date)
where 2: late_days(loans$loan_date[12], loans$return_date[12])
Browse[2]> c
[1] -3
Browse[1]> c
Called from: days_out(loan_date, return_date)
Browse[1]> Q
After the first c returns to level 1, the second c lets late_days() carry on to its call of days_out(), whose own browser() stops again at level 1; Q quits everything. The help page adds a point about handlers: the browser prompt is implemented with the restart and condition-handling mechanism, so it prevents error handlers set up before the breakpoint from being invoked while you are at the prompt, and an error you type goes back to the prompt. Handlers for other classes, such as "warning", are still called and can transfer control out of the debugger.
browser() in a script
The prompt reads its commands from the console. When a script is run with Rscript there is no person at the console, and what the browser reads is the rest of the script file: the lines that follow the call become the browser’s commands. The script below calls a function with a browser() in it and then has three more lines of its own; the output after it, from Rscript on R 4.6.1, shows that those lines were run at the browser prompt.
f <- function(x) {
browser()
x + 1
}
f(1)
cat("after the call\n")
y <- 2
y * 10
Source: R documentation: browser
Called from: f(1)
after the call
[1] 20
debug: x + 1
[1] 2
The output is a record of what was observed, not a documented behaviour: Called from: f(1) is the browser announcing itself; after the call, [1] 20 are the output of the lines cat("after the call\n") and y * 10, run in the frame of f(), and y <- 2 ran there too, as a local variable of f(); at the end of the file the browser got no more input, stepped on (debug: x + 1) and f(1) returned 2. The lesson is to remove browser() calls before running code in a batch job. debug() and trace() with a browser tracer behave the same way. Since R 4.3.0 the environment variable _R_CHECK_BROWSER_NONINTERACTIVE_ makes non-interactive debugger invocations trappable, and R CMD check --as-cran enables it to detect leftover browser() statements in a package (R NEWS: Changes in R 4.3.0). Section Scripts and the Command Line covers running R non-interactively.
Byte-compiled code
R compiles functions to byte code after a couple of calls (the just-in-time compiler, enabled by default since R 3.4.0, which NEWS says will not compile code that contains explicit calls to browser(), to support single stepping from that call). The browser help page adds that for byte-compiled functions interrupted by browser(), n and s are equivalent to c, and the debug() help page that when debugging is enabled on a byte-compiled function the interpreted version is used until debugging is disabled.
Stepping through a function: debug(), debugonce() and undebug()
browser() needs an edit. debug(fun) sets a flag on a function so that every time it is called, the body is executed one statement at a time with the browser active before each statement; no source change, so it works for functions in packages and for code you did not write (R-lang: debug/undebug). undebug(fun) removes the flag, debugonce(fun) sets a flag that the next call clears, and isdebugged(fun) asks whether the flag is set. The same commands work at the prompt as in browser(); the prompt appears before each statement, which is printed as debug at #n: ... (or debug: ... when the function has no source reference).
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> debug(late_days)
> isdebugged(late_days)
[1] TRUE
> late_days(loans$loan_date[12], loans$return_date[12])
debugging in: late_days(loans$loan_date[12], loans$return_date[12])
debug at #1: {
days <- days_out(loan_date, return_date)
max(days - loan_days, 0L)
}
Browse[1]> n
debug at #2: days <- days_out(loan_date, return_date)
Browse[1]> s
debugging in: days_out(loan_date, return_date)
debug at #1: {
end <- if (is.na(return_date))
shelf_today
else return_date
as.integer(end - loan_date)
}
Browse[1]> where
where 1 at #2: days_out(loan_date, return_date)
where 2: late_days(loans$loan_date[12], loans$return_date[12])
Browse[1]> f
exiting from: days_out(loan_date, return_date)
debug at #3: max(days - loan_days, 0L)
Browse[1]> days
[1] -3
Browse[1]> n
exiting from: late_days(loans$loan_date[12], loans$return_date[12])
[1] 0
> late_days(loans$loan_date[1], loans$return_date[1])
debugging in: late_days(loans$loan_date[1], loans$return_date[1])
debug at #1: {
days <- days_out(loan_date, return_date)
max(days - loan_days, 0L)
}
Browse[1]> Q
> isdebugged(late_days)
[1] TRUE
> undebug(late_days)
> isdebugged(late_days)
[1] FALSE
> late_days(loans$loan_date[1], loans$return_date[1])
[1] 0
Source: R documentation: debug
What the transcript shows, in order. debug(late_days) sets the flag and isdebugged() confirms it. The call prints debugging in: with the call, then the whole body once as debug at #1: {...}, and waits. n runs the { and stops before the first statement. s steps into days_out(), which was not flagged: stepping into a call opens a second debugging in: header with its own body. where shows two frames, days_out() on top, with its location in the caller (where 1 at #2:). f finishes days_out(): the browser prints exiting from: and stops at the next statement of the caller, whose variable days is now -3. The final n runs the last statement and R prints exiting from: late_days(...) and the value. The second call is abandoned with Q, which leaves the debugger but not the flag: isdebugged() is still TRUE until undebug() clears it, and after that the call runs normally without a stop. The persistent flag is the main thing to keep in mind with debug(). It stays for the rest of the session, or until the function is edited or replaced (replacing a function does not preserve the flag), and it applies to every call, including calls from loops and from other functions; the RStudio guide says it does not recommend the pattern for that reason, since it can trap you in the debugger for some special functions.
debugonce()
debugonce(fun) clears itself on the first call. It is the version to use when the function is called many times and only one call matters, or to avoid forgetting undebug(). Its flag is not the one isdebugged() reports, and undebug() does not clear it:
> source("scenario.R")
> late_days <- function(loan_date, return_date) {
+ days <- as.integer(return_date - loan_date)
+ max(days - loan_days, 0L)
+ }
> debugonce(late_days)
> isdebugged(late_days)
[1] FALSE
> undebug(late_days)
Warning in undebug(late_days) : argument is not being debugged
> late_days(loans$loan_date[1], loans$return_date[1])
debugging in: late_days(loans$loan_date[1], loans$return_date[1])
debug at #1: {
days <- as.integer(return_date - loan_date)
max(days - loan_days, 0L)
}
Browse[1]> c
exiting from: late_days(loans$loan_date[1], loans$return_date[1])
[1] 0
> late_days(loans$loan_date[1], loans$return_date[1])
[1] 0
After debugonce(late_days), isdebugged(late_days) is FALSE, and undebug(late_days) warns that the argument is not being debugged and does nothing to the pending one-shot flag. The next call stops, c runs the function to its end and the flag is gone: the second call is not debugged. If a pending debugonce() is no longer wanted, the only way to clear it is to make the call it was waiting for. The text and condition arguments of debug() and debugonce() are the same as those of browser().
Functions you did not write
debug() accepts a function or the name of one in a character string, any interpreted R function. For a function in a package the usual forms are debugonce(pkg::fun) for an exported one and debug(pkg:::fun) for one that is not exported or is a registered S3 method, as in the manual’s debug(stats:::predict.Arima). The next session debugs stats::sd(), which is a plain R function:
> source("scenario.R")
> debugonce(sd)
> sd(books$pages, na.rm = TRUE)
debugging in: sd(books$pages, na.rm = TRUE)
debug: sqrt(var(if (is.vector(x) || is.factor(x)) x else as.double(x),
na.rm = na.rm))
Browse[1]> ls()
[1] "na.rm" "x"
Browse[1]> head(x, 3)
[1] 387 432 214
Browse[1]> na.rm
[1] TRUE
Browse[1]> c
exiting from: sd(books$pages, na.rm = TRUE)
[1] 107.9866
> sd(books$pages, na.rm = TRUE)
[1] 107.9866
Source: R documentation: debug
A function without a source reference prints debug: rather than debug at #n:; the browser shows its arguments (ls() lists na.rm and x) and the statement that is about to run, here the whole body of sd(), deparsed over two lines. Source references of installed packages are absent unless the package was installed with options(keep.source.pkgs = TRUE) (or the environment variable R_KEEP_PKG_SOURCE=yes), which is why the RStudio guide advises building a package under development with source information kept.
For a function defined inside another function, the R manuals give the procedure: set debug() on the outer function, step through it until the inner function has been defined, call debug() on the inner function’s name, and use c to get out of step-through mode in the outer one. If you want to debug a function not starting at the very beginning, the help page points to trace(..., at = *) and to setBreakpoint(), described next.
Two more functions complete the family. debuggingState() is the global switch: debuggingState(FALSE) turns every debug flag off without removing them, debuggingState(TRUE) turns them on again, and debuggingState() reports the state; explicit browser() calls keep working either way. The next script shows the flags and the switch without entering the browser, which an unattended script cannot use sensibly:
source("scenario.R")
bk <- book("The quiet streets", "Iris Fenwick", "978-0-306-40615-7", 1999L)
# debug() sets a persistent flag on a function; isdebugged() reads it.
isdebugged(format.book)
#> [1] FALSE
debug(format.book)
isdebugged(format.book)
#> [1] TRUE
# debuggingState(FALSE) switches every debug flag off without removing them, so
# the call below runs straight through instead of stopping in the browser.
debuggingState()
#> [1] TRUE
debuggingState(FALSE)
#> [1] TRUE
format(bk)
#> [1] "The quiet streets (Iris Fenwick, 1999)"
debuggingState(TRUE)
#> [1] FALSE
isdebugged(format.book)
#> [1] TRUE
# undebug() removes the flag.
undebug(format.book)
isdebugged(format.book)
#> [1] FALSE
The values printed by debuggingState(FALSE) and debuggingState(TRUE) are the previous states, as for options().
Inserting code without editing: trace()
trace() goes further than debug(). It builds a modified version of a function, with extra code inserted at the start, before chosen statements or on exit, and assigns it back where the original was found; untrace() restores the original. The help page calls it a generalization of debug() implemented differently. The inserted code, the tracer, can be a call to browser() or recover(), but also any expression: a cat() that prints a variable, a condition that stops only when something is wrong, an assignment that saves a value in the global environment. The R Language Definition describes the plainest use: with only a function name, trace() prints the call every time the function is evaluated (R-lang: trace/untrace). The arguments are:
| Argument | Meaning |
|---|---|
|
The function to trace, as a name or a string, possibly quoted with |
|
A function, whose name is used and which is called with no arguments, or an unevaluated expression. |
|
The same, evaluated when the function exits, through the |
|
A numeric vector, or a list of vectors, of steps of the function body; the tracer runs just before those steps instead of on entry. It works only if the body is a |
|
If |
|
A method signature: trace the S4 method and not the generic. |
|
Where to look for the function; needed for functions that are not visible from the top level, such as methods in a namespace. |
|
|
Source: R documentation: trace
The numbering of at is a frequent stumbling block. The steps are the elements of the body as a list, so [[1]] is the brace itself and the first statement is step 2. The help page says not to count in the printed function and to look at as.list(body(f)) instead, which the first block of the script does. The rest of the script shows a plain trace, an expression tracer on entry, an expression tracer at step 3, and an exit tracer that prints the returned value.
source("scenario.R")
loan_status <- function(loan_date, return_date,
today = shelf_today) { # nolint: object_usage_linter.
end <- if (is.na(return_date)) today else return_date
days <- as.integer(end - loan_date)
if (days > loan_days) "overdue" else "ok" # nolint: object_usage_linter.
}
# Step numbers are positions in the body: [[1]] is the brace, the statements
# start at [[2]]. Count them with as.list(body(f)), not by eye.
as.list(body(loan_status))[1:3]
#> [[1]]
#> `{`
#>
#> [[2]]
#> end <- if (is.na(return_date)) today else return_date
#>
#> [[3]]
#> days <- as.integer(end - loan_date)
#>
# With only a name, trace() prints one line per call.
trace(loan_status)
loan_status(as.Date("2026-06-01"), as.Date("2026-06-10"))
#> trace: loan_status(as.Date("2026-06-01"), as.Date("2026-06-10"))
#> [1] "ok"
untrace(loan_status)
# A tracer is code inserted at the start of the function; print = FALSE hides
# the descriptive line. The tracer runs in the frame of the traced call.
trace(
loan_status,
tracer = quote(
cat("today =", format(today), "loan_date =", format(loan_date), "\n")
),
print = FALSE
)
#> [1] "loan_status"
loan_status(as.Date("2026-06-01"), NA)
#> today = 2026-06-30 loan_date = 2026-06-01
#> [1] "overdue"
untrace(loan_status)
# at = 3 inserts the tracer just before the third element of the body.
trace(
loan_status,
tracer = quote(cat("about to compute days from end =", format(end), "\n")),
at = 3,
print = FALSE
)
#> [1] "loan_status"
loan_status(as.Date("2026-06-01"), NA)
#> about to compute days from end = 2026-06-30
#> [1] "overdue"
untrace(loan_status)
# exit runs when the function returns; returnValue() is the value about to be
# returned.
trace(
loan_status,
exit = quote(cat("returning", returnValue(), "\n")),
print = FALSE
)
#> [1] "loan_status"
loan_status(as.Date("2026-05-01"), NA)
#> returning overdue
#> [1] "overdue"
untrace(loan_status)
Source: R documentation: trace
A trace() call that has more than the function name returns the name of the function visibly, so the console prints it ([1] "loan_status") unless the call is wrapped in invisible(); untrace() returns its argument invisibly. With print = FALSE only the expression’s own output appears; the plain trace(loan_status) prints trace: loan_status(...), the first form the manual shows with trace("[<-"). In the at = 3 run the tracer ran before the statement that computes days, in the frame of the call, so it could read end, which the first statement had just assigned. The exit tracer ran as the function returned, and returnValue() gave overdue; the help page marks returnValue() as experimental and says that it is meaningful only during the on.exit processing that runs the exit tracer.
The at argument can also address a step nested in another step: at = list(c(3, 4)) is the fourth step of the third step of the body. The script below takes a function with an if block, shows how to reach the second assignment inside it, and then shows two more arguments: tracingState() switches all tracing off and on without untracing, and where finds a function that the top level cannot see, here ar.yw.default, an S3 method used inside the stats namespace. The help page says the namespace mechanism imports the functions a package calls, so the object seen from the top level need not be the one that the package code uses, and names a function in that namespace as where.
source("scenario.R")
# A nested step is addressed by a list of index vectors: at = list(c(2, 3, 3))
# means "the third element of the third element of the second step of the body".
loan_label <- function(days) {
if (days > loan_days) { # nolint: object_usage_linter.
label <- "overdue"
label <- toupper(label)
} else {
label <- "ok"
}
label
}
body(loan_label)[[2]][[3]]
#> {
#> label <- "overdue"
#> label <- toupper(label)
#> }
body(loan_label)[[2]][[3]][[3]]
#> label <- toupper(label)
invisible(trace(
loan_label,
tracer = quote(cat("before the second assignment, days =", days, "\n")),
at = list(c(2, 3, 3)),
print = FALSE
))
loan_label(30L)
#> before the second assignment, days = 30
#> [1] "OVERDUE"
loan_label(3L)
#> [1] "ok"
untrace(loan_label)
# Tracing can be switched off and on globally without untracing.
invisible(trace(loan_label, quote(cat("tracer\n")), print = FALSE))
tracingState()
#> [1] TRUE
tracingState(FALSE)
#> [1] TRUE
loan_label(30L)
#> [1] "OVERDUE"
tracingState(TRUE)
#> [1] FALSE
loan_label(30L)
#> tracer
#> [1] "OVERDUE"
untrace(loan_label)
# A function that the namespace of a package calls internally is not the one the
# top level sees: where = names a function whose environment is the namespace.
invisible(trace(
quote(ar.yw.default),
tracer = quote(cat("ar.yw.default was called\n")),
print = FALSE,
where = ar
))
#> Tracing function "ar.yw.default" as seen from package "stats"
fit <- ar(lh, order.max = 3L, aic = FALSE)
#> ar.yw.default was called
fit$order
#> [1] 3
untrace(quote(ar.yw.default), where = ar)
#> Untracing function "ar.yw.default" as seen from package "stats"
fit <- ar(lh, order.max = 3L, aic = FALSE)
fit$order
#> [1] 3
Notice the two printed forms of the same function: body(loan_label)[[2]][[3]] is the { block of the first branch, and its third element is the second assignment, which is why the path is c(2, 3, 3). The first loan_label(30L) ran the tracer; loan_label(3L) took the other branch and printed nothing, since the tracer is inside the first branch. With the global state off, the traced function ran without the tracer, and tracingState(FALSE) printed the previous state. The two messages Tracing function "ar.yw.default" as seen from package "stats" and Untracing ... come from trace() and untrace() themselves.
Using a browser as the tracer
With browser as the tracer, trace() is a breakpoint at a chosen statement: the manual’s own example is trace(glm.fit, browser, at = 22) after printing as.list(body(glm.fit)) to find the number. The next transcript sets one before the statement that computes the result in late_days(), steps once, continues, removes the trace and checks that the function is back to normal.
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> as.list(body(late_days))
[[1]]
`{`
[[2]]
days <- days_out(loan_date, return_date)
[[3]]
max(days - loan_days, 0L)
> trace(late_days, browser, at = 3)
[1] "late_days"
> late_days(loans$loan_date[12], loans$return_date[12])
Tracing late_days(loans$loan_date[12], loans$return_date[12]) step 3
Called from: eval(expr, p)
Browse[1]> days
[1] -3
Browse[1]> where
where 1: eval(expr, p)
where 2: eval(expr, p)
where 3: eval.parent(exprObj)
where 4: .doTrace(browser(), "step 3")
where 5: late_days(loans$loan_date[12], loans$return_date[12])
Browse[1]> n
debug: max(days - loan_days, 0L)
Browse[1]> c
[1] 0
> untrace(late_days)
> late_days(loans$loan_date[12], loans$return_date[12])
[1] 0
trace() prints the name of the function and the call that was made, with the step: Tracing late_days(...) step 3. The prompt says Called from: eval(expr, p), and where shows why: the browser was started from inside the code that trace() inserted, which is evaluated by .doTrace() and a couple of eval() calls. Those frames are not part of your program; the environment the prompt works in is that of late_days() (days printed -3), and the first statement shown after n is the one the trace was set before. The manual’s transcript of this example shows Called from: eval(expr, envir, enclos), a different spelling of the same label. The examples in the help page of trace() pass browser(skipCalls = 4) as the tracer so that the reported location skips such frames. untrace(late_days) restores the function.
An exit tracer works at the other end of the call: it can look at the value being returned. The session below traces days_out() with an expression tracer that opens the browser at the start and prints the return value at the exit.
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> trace(days_out, quote(browser()),
+ exit = quote(print(returnValue())), print = FALSE)
[1] "days_out"
> late_days(loans$loan_date[12], loans$return_date[12])
Called from: eval(expr, p)
Browse[1]> return_date
[1] "2025-04-20"
Browse[1]> c
[1] -3
[1] 0
> untrace(days_out)
> late_days(loans$loan_date[12], loans$return_date[12])
[1] 0
The browser stops on entry, where return_date can be read, and c lets the function run to its exit, where print(returnValue()) prints [1] -3 before the caller prints its own result, [1] 0.
Breakpoints from a file: setBreakpoint() and findLineNum()
findLineNum() finds the function and the step that contain a given line of a source file, and setBreakpoint() is a wrapper that calls trace() for it, with browser as the default tracer. Both read the source references that R stores when code is parsed with keep.source = TRUE, so the code must have been read with source(file, keep.source = TRUE) (the Rscript default is FALSE) or typed at an interactive prompt. The file name is matched by its base name unless nameonly = FALSE, and "file.R#nn" gives the line (R documentation: findLineNum and setBreakpoint). By default they search the global environment; for code inside a package namespace or for S4 methods the envir and lastenv arguments widen the search. The RStudio user guide describes its editor breakpoints in similar terms (see below).
source("scenario.R")
# setBreakpoint() works from a file name and a line number, so the code must
# have been read with source references. The file is written to a temporary
# directory here to keep the example self-contained.
path <- file.path(tempdir(), "loan_tools.R")
writeLines(c(
"days_out <- function(loan_date, return_date, today = shelf_today) {",
" end <- if (is.na(return_date)) today else return_date",
" days <- as.integer(end - loan_date)",
" days",
"}"
), path)
source(path, keep.source = TRUE)
# findLineNum() reports which function contains a line and which step of its
# body that line is. (Printing the result shows the full path of the file, which
# is why the fields are read one by one.)
found <- findLineNum("loan_tools.R", 3L)
class(found)
#> [1] "findLineNumResult"
found[[1L]]$name
#> [1] "days_out"
found[[1L]]$at
#> [1] 3
found[[1L]]$line
#> [1] 3
# setBreakpoint() is trace() with the location worked out for you. The default
# tracer is browser(); a different tracer function or expression can be given.
setBreakpoint(
"loan_tools.R#4",
tracer = quote(cat("breakpoint: days =", days, "\n")),
verbose = FALSE
)
days_out(as.Date("2026-06-01"), NA)
#> breakpoint: days = 29
#> [1] 29
# clear = TRUE calls untrace() for the same location.
setBreakpoint("loan_tools.R#4", clear = TRUE, verbose = FALSE)
days_out(as.Date("2026-06-01"), NA)
#> [1] 29
unlink(path)
The script writes the file itself, in the temporary directory, so that it is self-contained, and removes it at the end. Line 3 of the file holds the second statement of days_out(), which is step 3 of its body (the first statement is step 2, as before). The breakpoint at line 4 ran the tracer, here a cat() expression instead of the default browser so that the script can run unattended, before the line that returns days; the call returned 29 days, and after clear = TRUE the same call printed nothing extra.
What trace() cannot do
The help page lists the limits. Tracing does not nest: a call to trace() replaces any earlier tracing of the same function, except for edited versions, and untrace() always restores the untraced function. The exit argument will not work if the function itself uses on.exit() without add = TRUE, because the existing call overrides the one trace() adds. Tracing a primitive works only by a special mechanism, replaces the primitive by a function of ..., gives little information, and issues a warning. The traced object is saved back where the function came from, so tracing of a function in the global environment survives in a saved workspace image and goes on in the next session, where untrace() removes it, while functions in packages are not saved with the image, so tracing of them ends with the session. The browser reached through trace() is the ordinary one. Fixes in recent releases show where the edges are: trace() of coerce works correctly since R 4.5.0, isS3stdGeneric() (used by debugcall()) has recognised an S3 generic that is being traced since R 4.0.3, and R-devel, the development version that will become R 4.7.0, makes trace() work for functions whose class attribute has more than one string, such as S7 generics and methods, and fixes a bad call that trace() could insert when tracing rematched S4 methods (R-devel NEWS; upcoming, not run here).
After the error: post-mortem debugging
browser(), debug() and trace() all need a second run of the program. When the failure is expensive to reproduce, the other approach is to arrange for the error itself to leave the frames behind and to look at them afterwards. Writing R Extensions puts the sequence in order: locate the error with traceback(), then look at a post-mortem dump with options(error = dump.frames) and debugger(), or browse the live frames with options(error = recover); and when the cause is still unclear, look at what was happening just before the error with debug() (Writing R Extensions: Debugging R code). Post-mortem debugging is good for finding out exactly what went wrong, in the words of that chapter, "but not necessarily why".
options(error = recover)
recover() is a function meant to be the error option. When an error reaches the top level, R prints the message and recover() prints the active calls as a numbered menu. Choose a number, and the browser opens in the frame of that call: the variables there are the ones the call had when the error struck. c leaves the browser and returns to the menu, another number browses another frame, and 0 exits. Nothing is re-run and nothing is copied, which the help page names as its advantage over dump.frames() when the computation involves large data. The session below uses a deliberately wrong call, with dates given as strings, so that end - loan_date fails inside days_out().
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> options(error = recover)
> late_days("2026-05-01", "2026-06-01")
Error in end - loan_date : non-numeric argument to binary operator
Enter a frame number, or 0 to exit
1: late_days("2026-05-01", "2026-06-01")
2: #2: days_out(loan_date, return_date)
Selection: 2
Called from: top level
Browse[1]> ls()
[1] "end" "loan_date" "return_date"
Browse[1]> end
[1] "2026-06-01"
Browse[1]> c
Enter a frame number, or 0 to exit
1: late_days("2026-05-01", "2026-06-01")
2: #2: days_out(loan_date, return_date)
Selection: 1
Called from: top level
Browse[1]> ls()
[1] "loan_date" "return_date"
Browse[1]> c
Enter a frame number, or 0 to exit
1: late_days("2026-05-01", "2026-06-01")
2: #2: days_out(loan_date, return_date)
Selection: 0
> options(error = NULL)
> traceback()
2: days_out(loan_date, return_date) at #2
1: late_days("2026-05-01", "2026-06-01")
Source: R documentation: recover
Frame 2 is the call that failed: its ls() shows end, loan_date and return_date, and end holds the string "2026-06-01", which is why the subtraction fails. Frame 1 is the caller: ls() there shows only the arguments, because late_days() was still waiting for days_out() to return, so days does not exist yet. The label 2: #2: days_out(...) carries the source reference of the call. Called from: top level is cosmetic: the browser is evaluating in the chosen frame, as the variables show, though the label does not name it. After the session options(error = NULL) restores the standard behaviour, and the traceback() that follows shows that the stack of the error is still available.
In a non-interactive session recover() has no one to ask, so it behaves like dump.frames(), as the help page says. The script below shows that in Rscript: the message says so, the frames are dumped to last.dump and the script goes on with the next top-level call.
f <- function(x) g(x)
g <- function(x) stop("boom in g")
# recover() asks questions only in an interactive session. Rscript is not
# interactive, so it behaves like dump.frames and the script goes on.
options(error = utils::recover)
interactive()
#> [1] FALSE
f(1)
#> Error in g(x) : boom in g
#> Calls: f -> g
#> recover called non-interactively; frames dumped, use debugger() to view
cat("the script goes on\n")
#> the script goes on
exists("last.dump")
#> [1] TRUE
head(names(last.dump), 3L)
#> [1] "f(1)" "g(x)" "stop(\"boom in g\")"
options(error = NULL)
Source: R documentation: recover
The names of last.dump that were not printed are the frames of recover() itself and of the machinery around it; the first three are the stack of the error.
dump.frames() and debugger()
dump.frames() saves the stack: the calls and the environments of the active frames, plus the last error message. Used as the error option, options(error = dump.frames), it stores them in an object named last.dump in the workspace, and debugger() browses it. The signature is dump.frames(dumpto = "last.dump", to.file = FALSE, include.GlobalEnv = FALSE); to.file = TRUE writes the dump to the file <dumpto>.rda instead of the workspace, and include.GlobalEnv = TRUE (added in R 3.4.0) also stores a copy of the global environment, which the help page calls particularly useful in a batch job. The result is a list of environments named by the calls, with class dump.frames. Writing R Extensions warns that such objects can be large, so remove last.dump when it is no longer needed. The first script shows the dump in an Rscript run, where installing dump.frames as the error option keeps execution going, as the help page notes.
f <- function(x) g(x)
g <- function(x) h(x)
h <- function(x) stop("boom in h")
# In a non-interactive session, dump.frames as the error option saves the
# frames to last.dump and execution continues with the next top-level call.
options(error = dump.frames)
f(1)
#> Error in h(x) : boom in h
#> Calls: f -> g -> h
cat("the script goes on\n")
#> the script goes on
class(last.dump)
#> [1] "dump.frames"
names(last.dump)
#> [1] "f(1)" "g(x)" "h(x)"
#> [4] "stop(\"boom in h\")"
attr(last.dump, "error.message")
#> [1] "Error in h(x) : boom in h\nCalls: f -> g -> h\n"
# Each element is the environment of one frame; the names are the calls.
class(last.dump[[1L]])
#> [1] "environment"
ls(last.dump[[1L]])
#> [1] "x"
get("x", envir = last.dump[[3L]])
#> [1] 1
options(error = NULL)
The error.message attribute holds what geterrmessage() held when the error happened, including the Calls: line that a non-interactive session adds; there are four frames, because stop() is a frame too; and each element is an ordinary environment, so ls() and get() work on it. debugger() is the interactive way to look at them. In a live session:
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> options(error = dump.frames)
> late_days("2026-05-01", "2026-06-01")
Error in end - loan_date : non-numeric argument to binary operator
> options(error = NULL)
> debugger()
Message: Error in end - loan_date : non-numeric argument to binary operator
Available environments had calls:
1: late_days("2026-05-01", "2026-06-01")
2: #2: days_out(loan_date, return_date)
Enter an environment number, or 0 to exit
Selection: 2
Browsing in the environment with call:
#2: days_out(loan_date, return_date)
Called from: debugger.look(ind)
Browse[1]> ls()
[1] "end" "loan_date" "return_date"
Browse[1]> loan_date
[1] "2026-05-01"
Browse[1]> c
Available environments had calls:
1: late_days("2026-05-01", "2026-06-01")
2: #2: days_out(loan_date, return_date)
Enter an environment number, or 0 to exit
Selection: 0
Source: R documentation: debugger
debugger() with no argument uses last.dump; it prints the message and the calls in outer-first order, the opposite of traceback(), then asks for an environment number. Choosing a frame opens the browser, c returns to the list, 0 leaves. There is a difference from recover() that the help page spells out: the selected environment is copied and the browser is called from the copy, so not everything in the original frame is available, such as promises that have not been evaluated and the contents of .... The page’s note adds that sys.parent() and environment() applied to closures do not work correctly inside debugger(), and that an error raised while computing a default argument is reported as "recursive default argument reference" when that environment is examined. For anything that depends on lazy arguments, use recover() or browser().
Batch jobs: dump to a file
A batch run has nobody to ask, but the dump can be saved for later: options(error = quote({dump.frames(to.file = TRUE); q()})) is the manual’s setting (Writing R Extensions: Debugging R code), and the help page’s setting for non-interactive sessions ends in q(status = 1), so that the exit status of the job reports the failure. The expression is wrapped in quote() so that it is stored in the option instead of being evaluated at once, and the function is written utils::dump.frames so that it also works in a start-up file, where utils may not be attached yet. This script is meant to fail: it writes last.dump.rda into the working directory, prints the error and exits with status 1. That is not a defect of the example; the exit status is the point.
f <- function(x) g(x)
g <- function(x) h(x)
h <- function(x) stop("boom in h")
# A batch job cannot be debugged on the spot. Save the frames to a file and
# quit; the exit status is 1 on purpose, so a scheduler sees the failure.
options(error = quote({
utils::dump.frames(to.file = TRUE, include.GlobalEnv = FALSE)
q(status = 1)
}))
f(1)
#> Error in h(x) : boom in h
#> Calls: f -> g -> h
Later, in another session on the same or on another machine, the dump is loaded and examined. The loading script prints what it contains and then deletes the file, so it leaves nothing behind:
# Run after the previous script, in the same directory: a later R session
# (or another machine) loads the file and looks at the frames.
load("last.dump.rda")
class(last.dump)
#> [1] "dump.frames"
names(last.dump)
#> [1] "f(1)" "g(x)" "h(x)"
#> [4] "stop(\"boom in h\")"
attr(last.dump, "error.message")
#> [1] "Error in h(x) : boom in h\nCalls: f -> g -> h\n"
ls(last.dump[[3L]])
#> [1] "x"
unlink("last.dump.rda")
file.exists("last.dump.rda")
#> [1] FALSE
Source: R documentation: load
and the interactive look at the same file, with debugger(last.dump) on the object that load() created, is:
> load("last.dump.rda")
> debugger(last.dump)
Message: Error in h(x) : boom in h
Calls: f -> g -> h
Available environments had calls:
1: f(1)
2: g(x)
3: h(x)
4: stop("boom in h")
Enter an environment number, or 0 to exit
Selection: 3
Browsing in the environment with call:
h(x)
Called from: debugger.look(ind)
Browse[1]> ls()
[1] "x"
Browse[1]> x
[1] 1
Browse[1]> c
Available environments had calls:
1: f(1)
2: g(x)
3: h(x)
4: stop("boom in h")
Enter an environment number, or 0 to exit
Selection: 0
> unlink("last.dump.rda")
Source: R documentation: debugger
Since last.dump holds environments, it can be debugged in a later session even though the original process is gone, which is what makes post-mortem debugging possible for batch use, according to the manual; the alternative it mentions is saving the workspace at the end of the run with the --save command-line flag.
options(error = browser) and the parentheses
Setting browser itself as the error handler works, but not the way recover does. The handler is called from the top level, so the browser opens in the global environment and not in the frame of the failing call: the label says Called from: days_out(...), and where shows the stack, but the variables in scope are the global ones.
> source("scenario.R")
> days_out <- function(loan_date, return_date) {
+ end <- if (is.na(return_date)) shelf_today else return_date
+ as.integer(end - loan_date)
+ }
> late_days <- function(loan_date, return_date) {
+ days <- days_out(loan_date, return_date)
+ max(days - loan_days, 0L)
+ }
> options(error = browser)
> late_days("2026-05-01", "2026-06-01")
Error in end - loan_date : non-numeric argument to binary operator
Called from: days_out(loan_date, return_date)
Browse[1]> where
where 1 at #2: days_out(loan_date, return_date)
where 2: late_days("2026-05-01", "2026-06-01")
Browse[1]> environment()
<environment: R_GlobalEnv>
Browse[1]> ls(pattern = "^(end|loan_date)$")
character(0)
Browse[1]> Q
> options(error = NULL)
Source: R documentation: options (error)
The RStudio user guide writes the always-debug setting as options(error = browser()). In plain R, with the parentheses, browser() is evaluated when options() is called, not when an error happens, so the browser opens at once and the option is left unset:
> f <- function(x) stop("boom")
> options(error = browser())
Called from: options(error = browser())
Browse[1]> c
> getOption("error")
NULL
> f(1)
Error in f(1) : boom
Source: R documentation: options (error)
The transcript shows that getOption("error") is NULL after the call and the later error just returns to the prompt. The working forms are a function name, options(error = recover), or an expression such as options(error = quote(browser())). How RStudio treats the guide’s form was not tested here; the guide itself says the setting overrides RStudio’s error-handler settings and advises changing it back (with options(error = NULL)) when troubleshooting is over, since trapping every error becomes annoying.
Letting a script continue: catch.script.errors
R 4.4.0 added a documented option for the other common batch need, to carry on after an error: with options(catch.script.errors = TRUE), an error in a non-interactive script is reported and evaluation continues with the next top-level expression, instead of ending the run. The options help page says to use it with care; later steps may depend on the one that failed. Its help text also says that traceback() provides information about the error. The script shows the continuation, the traceback, and showErrorCalls = FALSE, which removes the Calls: line.
f <- function(x) g(x)
g <- function(x) stop("boom in g")
# Since R 4.4.0, catch.script.errors = TRUE lets a non-interactive script go on
# after an error: the error is reported and evaluation continues with the next
# top-level expression. Use it with care: later steps may depend on the failed
# one.
options(catch.script.errors = TRUE)
f(1)
#> Error in g(x) : boom in g
#> Calls: f -> g
cat("the script goes on\n")
#> the script goes on
# The stack of that error is available to traceback() as usual.
traceback()
#> 3: stop("boom in g")
#> 2: g(x)
#> 1: f(1)
# showErrorCalls = FALSE removes the "Calls:" summary that non-interactive
# sessions add to the error message.
options(showErrorCalls = FALSE)
f(2)
#> Error in g(x) : boom in g
cat("and on\n")
#> and on
The traceback has three entries because stop() is a call too, and the second error is reported without the Calls: line. The option is a switch for the whole script. For a long simulation in which a few iterations may fail, R FAQ 7.32 recommends try(), which returns an object of class "try-error", or preferably tryCatch(), where the value to use for a failed step can be chosen (R FAQ 7.32); that keeps the result of the successful steps and says exactly which step is replaced.
Finding a warning
A warning has no stack: R signals it, shows or stores a message, and the program goes on. The message names a call, but not always the useful one: NAs introduced by coercion below is reported as coming from FUN(X[[i]], ...), the anonymous call inside vapply(), which says nothing about which caller was responsible. Writing R Extensions suggests turning a warning that is a clear precursor of a later error into an error, so that the usual tools work: options(warn = 2) does that. The warn option has four documented levels: negative ignores warnings, 0 is the default and stores them until the top-level call returns, 1 prints them as they occur and 2 or more turns every warning into an error; the options help page notes that, while sometimes useful for debugging, converting warnings into errors may trigger bugs and resource leaks that would not have been triggered otherwise, which is a reason to set it for a run and not for good. The first script has a warning inside vapply() and shows both the immediate warning and the converted one with its traceback.
# A warning whose call is not much help: it names the inner FUN, not the caller.
pages_of <- function(txt) as.integer(sub("p\\.", "", txt))
total_pages <- function(txts) {
sum(vapply(txts, pages_of, integer(1L)), na.rm = TRUE)
}
labels <- c("320", "288 p.", "n/a")
total_pages(labels)
#> Warning in FUN(X[[i]], ...) : NAs introduced by coercion
#> [1] 608
# With warn = 2 the warning becomes an error, which stops at the offending call
# and can be inspected like any other error: here with a traceback.
options(warn = 2, error = function() {
traceback(3)
q(status = 0)
})
total_pages(labels)
#> Error in FUN(X[[i]], ...) :
#> (converted from warning) NAs introduced by coercion
#> Calls: total_pages -> vapply -> FUN
#> 7: doWithOneRestart(return(expr), restart)
#> 6: withOneRestart(expr, restarts[[1L]])
#> 5: withRestarts({
#> .Internal(.signalCondition(simpleWarning(msg, call), msg,
#> call))
#> .Internal(.dfltWarn(msg, call))
#> }, muffleWarning = function() NULL)
#> 4: .signalSimpleWarning("NAs introduced by coercion", base::quote(FUN(X[[i]],
#> ...)))
#> 3: FUN(X[[i]], ...)
#> 2: vapply(txts, pages_of, integer(1L))
#> 1: total_pages(labels)
Source: R documentation: options (warn)
Under warn = 1 R prints Warning in FUN(X[[i]], ...) : at once and the call completes with [1] 608; the label n/a, which is not a number, became NA and was dropped by na.rm = TRUE. Under warn = 2 the same condition is an error, (converted from warning) is added to its message, and the traceback now shows the callers: total_pages(labels) is frame 1, vapply() is frame 2. Frames 4 to 7 are R’s warning machinery, the withRestarts() that offers the muffleWarning restart; ignore them. Without a converted warning, two other tools find the same place. tryCatch(warning = ) stops at the first warning and returns the condition, whose conditionCall() is the call that signalled it. A calling handler sees the stack and can then muffle the warning with the muffleWarning restart so the computation finishes, which is the way to inspect many warnings from one run:
pages_of <- function(txt) as.integer(sub("p\\.", "", txt))
total_pages <- function(txts) {
sum(vapply(txts, pages_of, integer(1L)), na.rm = TRUE)
}
labels <- c("320", "288 p.", "n/a")
# tryCatch() unwinds first and reports where the warning was signalled.
w <- tryCatch(total_pages(labels), warning = \(w) w)
class(w)
#> [1] "simpleWarning" "warning" "condition"
conditionMessage(w)
#> [1] "NAs introduced by coercion"
conditionCall(w)
#> FUN(X[[i]], ...)
# A calling handler sees the stack that led to the warning. Then it muffles the
# warning, so the computation continues and returns its value.
total <- withCallingHandlers(
total_pages(labels),
warning = \(w) {
calls <- sys.calls()
cat("stack when the warning was signalled:\n")
for (cl in calls[-length(calls)]) {
cat(" ", deparse(cl)[1L], "\n")
}
invokeRestart("muffleWarning")
}
)
#> stack when the warning was signalled:
#> withCallingHandlers(total_pages(labels), warning = function(w) {
#> total_pages(labels)
#> vapply(txts, pages_of, integer(1L))
#> FUN(X[[i]], ...)
#> .signalSimpleWarning("NAs introduced by coercion", base::quote(FUN(X[[i]],
#> withRestarts({
#> withOneRestart(expr, restarts[[1L]])
#> doWithOneRestart(return(expr), restart)
total
#> [1] 608
In the calling handler the stack printed starts at the withCallingHandlers() call and goes down through total_pages(), vapply() and FUN() to R’s own signalling code, so the first entries identify the caller of the failing coercion. Deferred warnings, the default (warn = 0), are kept in last.warning and shown by warnings(). Their number is limited by the option nwarnings (default 50) and the length of each message by warning.length (default 1000 bytes). The Rscript runs on this page use warn = 1 for readability, so the following script restores the default on purpose, to show what a deferred warning looks like:
# The profile used for the other scripts sets warn = 1, which prints each
# warning as it happens. This script restores the default, warn = 0, on
# purpose: warnings are then collected and printed after the top-level call
# returns.
options(warn = 0)
check_pages <- function(x) {
warning("first problem")
warning("second problem")
invisible(x)
}
check_pages(1)
#> Warning messages:
#> 1: In check_pages(1) : first problem
#> 2: In check_pages(1) : second problem
w <- warnings()
class(w)
#> [1] "warnings"
length(w)
#> [1] 2
names(w)
#> [1] "first problem" "second problem"
print(w)
#> Warning messages:
#> 1: In check_pages(1) : first problem
#> 2: In check_pages(1) : second problem
Source: R documentation: warnings
The two warnings were stored, printed together as Warning messages: after the top-level call returned, and warnings() returned an object of class warnings, a named list whose names are the messages and whose values are the calls. Printing it repeated the block. Two options make warnings easier to locate without changing them: options(showWarnCalls = TRUE) adds a summary of the call stack to warning messages, as showErrorCalls does for errors, and options(warning.expression = ...) replaces the standard message by an expression that you choose, which the options help page says is called whatever the value of warn. The three options warnPartialMatchArgs, warnPartialMatchAttr and warnPartialMatchDollar turn silent partial matching into warnings and are described in Defensive Programming and Input Validation. Signalling and handling warnings is the subject of Conditions and Error Handling.
Special cases
Infinite recursion
The error evaluation nested too deeply: infinite recursion / options(expressions=)? is raised when the number of nested expressions reaches the limit set by options(expressions =), 5000 by default, and the manual’s advice is to lower it, for example to 500, and run again so that the traceback is short. R 4.2.0 made stack-overflow errors conditions that inherit from stackOverflowError. The help page lists the specific classes: CStackOverflowError for the C stack, protectStackOverflowError for the pointer protection stack, nodeStackOverflowError for the node stack of the byte-code engine and expressionStackOverflowError for the limit of nested expressions, so a handler can tell them apart (R documentation: StackOverflows). The help page also says that such errors can be caught by exiting handlers established with tryCatch(), but that calling handlers may fail because there may not be enough stack to run them, which is why the script catches the error with tryCatch().
# A function that calls itself without a base case.
runaway <- function(n) runaway(n + 1L)
# The default limit, 5000 nested expressions, takes a while to reach and fills
# the traceback; a small limit finds the problem faster. Restore it afterwards.
options(expressions = 500)
err <- tryCatch(runaway(1L), error = \(e) e)
options(expressions = 5000)
conditionMessage(err)
#> [1] "evaluation nested too deeply: infinite recursion / options(expressions=)?"
class(err)
#> [1] "expressionStackOverflowError" "stackOverflowError"
#> [3] "error" "condition"
# R 4.2.0 and later signal a classed condition, so the stack overflow can be
# told apart from other errors in a handler.
inherits(err, "stackOverflowError")
#> [1] TRUE
Source: R NEWS: Changes in R 4.2.0
The valid values of expressions are 25 to 500000 according to the options help page, which also warns that increasing it may cause a segfault from overflow of the C stack. The script restores the default after catching the error, and the error is of class expressionStackOverflowError first, then stackOverflowError, error and condition.
S3 methods
For an S3 generic, debug(fun) flags the generic, which does little except dispatch; the method that runs is the thing to flag. When the method is visible, debug(format.book) works. For a method that is not exported, use the triple colon (debug(stats:::predict.Arima) in the manual) or getS3method(). utils::debugcall() does the lookup for you: given a call, it sets the flag on the non-generic function, S3 method or S4 method that evaluating the call would run, based on the classes of the arguments, and undebugcall() clears it. It was added in R 3.4.0 together with the signature argument of debug(), and R 4.4.1 made debugcall(S3Generic()) work when a corresponding S4 generic version is in the methods cache. The help page notes that it uses non-standard evaluation to get the call, so a variable holding a call does not work, and that for non-standard S3 generics the generic itself is debugged. It does not run the call; the idiom eval(debugcall(summary(f))) evaluates it afterwards.
source("scenario.R")
bk <- book("The quiet streets", "Iris Fenwick", "978-0-306-40615-7", 1999L)
# format() is an S3 generic: the function that runs for a book is format.book.
# debug(format) would flag the generic, which only dispatches. debugcall() works
# out the method from the classes of the arguments and flags that one. It does
# not run the call; it returns it.
isdebugged(format.book)
#> [1] FALSE
flagged <- debugcall(format(bk))
flagged
#> format(bk)
isdebugged(format.book)
#> [1] TRUE
isdebugged(format)
#> [1] FALSE
# undebugcall() clears the flag in the same way.
undebugcall(format(bk))
isdebugged(format.book)
#> [1] FALSE
Source: R documentation: debugcall
The session that follows uses the flag for real. The browser is in the method format.book(), which the scenario defines in scenario.R, and its source references name the file and line, scenario.R#80, because source() kept them:
> source("scenario.R")
> bk <- book("The quiet streets", "Iris Fenwick", "978-0-306-40615-7", 1999L)
> debugcall(format(bk))
> format(bk)
debugging in: format.book(bk)
debug at scenario.R#80: {
year <- if (is.na(x$year))
"n.d."
else x$year
sprintf("%s (%s, %s)", x$title, x$author, year)
}
Browse[1]> n
debug at scenario.R#81: year <- if (is.na(x$year)) "n.d." else x$year
Browse[1]> x$title
[1] "The quiet streets"
Browse[1]> c
exiting from: format.book(bk)
[1] "The quiet streets (Iris Fenwick, 1999)"
> undebugcall(format(bk))
> isdebugged(format.book)
[1] FALSE
Source: R documentation: debugcall
S4 methods
R FAQ 8.5, "How can I debug S4 methods?", answers in one sentence: use trace() with the argument signature= to add calls to the browser, or any other code, to the method that will be dispatched for the corresponding signature (R FAQ 8.5). Writing R Extensions says that debug() "cannot be used for S4 methods"; that text predates R 3.4.0, which added the signature argument to debug(), debugonce(), undebug() and isdebugged(). Since then debug(fun, signature = ) works, implemented with the trace machinery; later releases extended it: R 3.5.0 supports methods on any object of class genericFunction, including group generics, and R 4.5.0 accepts a string that names an S4 generic. The script defines a tiny S4 class (the methods package is attached by Rscript), traces a method and the show() method, and uses isdebugged() with a signature.
# Rscript attaches the methods package, which tracing by signature needs.
setClass("Book", slots = c(title = "character", year = "integer"))
setGeneric("age_in", function(x, ...) standardGeneric("age_in"))
#> [1] "age_in"
setMethod("age_in", "Book", function(x, ...) 2026L - x@year)
setMethod("show", "Book", function(object) {
cat("<Book>", object@title, "\n")
})
bk <- new("Book", title = "The quiet streets", year = 1999L)
age_in(bk)
#> [1] 27
# The method that runs for a Book, not the generic, gets the tracer (R FAQ 8.5).
invisible(trace(
"age_in",
signature = "Book",
tracer = quote(cat("age_in method for", class(x), "called\n")),
print = FALSE
))
age_in(bk)
#> age_in method for Book called
#> [1] 27
untrace("age_in", signature = "Book")
age_in(bk)
#> [1] 27
# show() is an S4 generic too; autoprinting a Book calls its method.
invisible(trace(
"show",
signature = "Book",
tracer = quote(cat("show method for Book\n")),
print = FALSE
))
bk
#> show method for Book
#> <Book> The quiet streets
untrace("show", signature = "Book")
# debug() with a signature uses the trace machinery, and isdebugged() asks about
# one method rather than the generic. Pass the generic function itself, not its
# name, to undebug() when the generic lives in the global environment.
isdebugged("age_in", signature = "Book")
#> [1] FALSE
debug(age_in, signature = "Book")
isdebugged("age_in", signature = "Book")
#> [1] TRUE
isdebugged("age_in")
#> [1] FALSE
undebug(age_in, signature = "Book")
isdebugged("age_in", signature = "Book")
#> [1] FALSE
The method that was traced is the one for Book; the tracer printed before the result of age_in(bk), and not after untrace(). Autoprinting a Book called the show() method, so tracing it with signature = "Book" printed the tracer line once. isdebugged("age_in", signature = "Book") is FALSE before debug(age_in, signature = "Book") and TRUE after, and isdebugged("age_in") for the generic stays FALSE, since the generic was not flagged. The comment in the script points at a quirk of R 4.6.1: when the generic lives in the global environment, undebug("age_in", signature = "Book"), with the generic’s name as a string, fails with no function ‘age_in’ found, because the lookup runs in the methods namespace; passing the generic function itself, undebug(age_in, signature = "Book"), or using untrace("age_in", signature = "Book"), works. The interactive version of the same session:
> setClass("Book", slots = c(title = "character", year = "integer"))
> setGeneric("age_in", function(x, ...) standardGeneric("age_in"))
[1] "age_in"
> setMethod("age_in", "Book", function(x, ...) {
+ 2026L - x@year
+ })
> bk <- new("Book", title = "The quiet streets", year = 1999L)
> debug(age_in, signature = "Book")
> age_in(bk)
Tracing age_in(bk) step 2
Called from: eval(expr, p)
Browse[1]> x@year
[1] 1999
Browse[1]> n
debug: 2026L - x@year
Browse[1]> c
[1] 27
> isdebugged(age_in, signature = "Book")
[1] TRUE
> undebug(age_in, signature = "Book")
> age_in(bk)
[1] 27
debug() by signature inserts a browser tracer at step 2 of the method, so it announces Tracing age_in(bk) step 2 and the label is again Called from: eval(expr, p). After undebug() the same call runs without stopping. If a method adds arguments that the generic does not have, R rewrites it so that the body is a local function called .local, and what has to be traced is that function. debug() handles it for you (the manual’s phrase is "rematched"), and isRematched() tells which methods are of this kind:
setClass("Book", slots = c(title = "character", year = "integer"))
setGeneric("label_of", function(x, ...) standardGeneric("label_of"))
#> [1] "label_of"
# A method may add arguments that the generic does not have. R then wraps the
# body in a local function called .local, and that is what has to be traced.
setMethod("label_of", "Book", function(x, upper = FALSE, ...) {
out <- paste0(x@title, " (", x@year, ")")
if (upper) toupper(out) else out
})
bk <- new("Book", title = "The quiet streets", year = 1999L)
body(getMethod("label_of", "Book"))
#> {
#> .local <- function (x, upper = FALSE, ...)
#> {
#> out <- paste0(x@title, " (", x@year, ")")
#> if (upper)
#> toupper(out)
#> else out
#> }
#> .local(x, ...)
#> }
isRematched(getMethod("label_of", "Book"))
#> [1] TRUE
invisible(trace(
"label_of",
signature = "Book",
tracer = quote(cat("upper =", upper, "\n")),
print = FALSE
))
label_of(bk, upper = TRUE)
#> Tracing function ".local" in package ".GlobalEnv"
#> upper = TRUE
#> [1] "THE QUIET STREETS (1999)"
untrace("label_of", signature = "Book")
label_of(bk)
#> [1] "The quiet streets (1999)"
body(getMethod("label_of", "Book")) shows the wrapper: .local is defined in the method and called with x and the dots. Tracing with signature found the inner function by itself; the message Tracing function ".local" in package ".GlobalEnv" appears at the first dispatch after tracing, before the tracer’s own output, and upper was visible to the tracer because .local is where upper is an argument. R-devel’s NEWS mentions a fix for an improper call that trace() could insert for rematched methods; see the section on trace(). S4 classes and methods themselves are in S4 Classes and Methods.
Printing and checking, the cheap tools
Not every bug needs a debugger. Output that is cheap to leave in is worth having: message() writes to the error stream, so it does not mix with a function’s printed result and can be silenced with suppressMessages(); str() and dput() show what an object really is, which is often the bug, such as a Date that is a number with a class; and on.exit() reports how a function was left, including by an error. The following script uses all of them on the shelf’s loan with the impossible dates.
source("scenario.R")
# Print debugging that costs little to leave in: message() writes to standard
# error, so it does not mix with the function's own printed output, and it can
# be silenced with suppressMessages().
late_days <- function(loan_date, return_date, verbose = FALSE) {
days <- as.integer(return_date - loan_date)
if (verbose) {
message(sprintf("late_days: %s -> %s = %d days", loan_date, return_date,
days))
}
max(days - loan_days, 0L) # nolint: object_usage_linter.
}
late_days(loans$loan_date[12], loans$return_date[12], verbose = TRUE)
#> late_days: 2025-04-23 -> 2025-04-20 = -3 days
#> [1] 0
suppressMessages(
late_days(loans$loan_date[12], loans$return_date[12], verbose = TRUE)
)
#> [1] 0
# str() and dput() show what an object really is, which is often the bug.
str(loans[12, ])
#> 'data.frame': 1 obs. of 5 variables:
#> $ loan_id : chr "L012"
#> $ isbn : chr "9780013001331"
#> $ member : chr "Chloe Martin"
#> $ loan_date : Date, format: "2025-04-23"
#> $ return_date: Date, format: "2025-04-20"
dput(loans$return_date[12])
#> structure(20198, class = "Date")
dput(unclass(loans$return_date[12]))
#> 20198
# on.exit() reports how a function left, including by an error.
checked <- function(x) {
on.exit(cat("left checked() with x =", x, "\n"), add = TRUE)
if (x < 0) stop("negative input")
sqrt(x)
}
checked(4)
#> left checked() with x = 4
#> [1] 2
try(checked(-1), silent = TRUE)
#> left checked() with x = -1
Source: R documentation: message
dput(loans$return_date[12]) shows structure(20198, class = "Date"), and dput(unclass(...)) the bare number of days since 1970-01-01. The on.exit() line is printed in both runs, also in the one that ends in an error; Functions describes on.exit() and its add argument. A stopifnot() placed where a value must satisfy a condition turns a silent wrong result into an error at the earliest line that can detect it, and the stack then points there; that technique is the subject of Defensive Programming and Input Validation.
Debugging in an IDE
RStudio and Positron do not have a debugger of their own; they are front ends to the tools above, and everything they do can be reproduced at a plain console. This section is documented, not run: it is based on the RStudio user guide (the version 2026.10.0 Debugging chapter) and on the Ark README for Positron, both read on 2026-10-20, and the screenshots are not reproduced. The wording of buttons and menus changes between releases, so check the documentation of the release you have.
RStudio
The user guide organises debugging around the ways to enter debug mode (RStudio user guide: Debugging).
-
An editor breakpoint is set by clicking to the left of a line number, or with
Shift+F9on the line. It takes effect immediately and does not change the code: it works by injecting tracing code into the function object, which is thesetBreakpoint()mechanism described above, and a function that holds a breakpoint is marked with a red dot in the Environment pane. If the function object does not exist yet, or no longer matches the editor because the file changed sincesource()was last called, the breakpoint is deferred and shown as a hollow red circle; in most casessource()on the file resolves it. -
A
browser()call is the other kind of breakpoint. It is part of the code, so it takes effect only when the code is sourced, rebuilt or reloaded, but it works where editor breakpoints cannot, and with anifaround it gives a conditional breakpoint, such as stopping at iteration 512 of a loop. -
debugonce()anddebug()flag a function, for code with no local source file, such asdebugonce(devtools::install); the guide does not recommend leavingdebug()set. -
On error: the Debug menu has an On Error setting whose values include the Error Inspector and Break in Code. RStudio does not start the debugger if none of your own code is on the stack, so that errors deep inside other packages do not open it; a Global Options check box (the guide names "Use debug error handler only when my code contains errors") turns that filter off. As discussed above, the guide’s
options(error = browser())overrides the Debug menu setting. Older articles describe two buttons of the Error Inspector, Show Traceback and Rerun with Debug; the current user guide does not document them, so they are not described here.
When code is stopped, the console prompt becomes Browse[1]>, the Environment pane shows the environment of the running function instead of the global one, with arguments that have not been evaluated shown in gray as promises, and a drop-down list shows the chain of enclosing environments. The traceback shares the space of the Environment pane; the guide says it shows the call stack and is called the traceback for symmetry with traceback(), that clicking a frame shows its variables and code without changing the active environment of the console (use recover() for that), and that internal functions are hidden unless View Internals is ticked. The code window highlights the line about to run. The console toolbar sends the same commands as the table in the browser() section:
| Command | Shortcut | Description |
|---|---|---|
|
|
Execute the next statement. |
|
|
Step into the function. |
|
|
Finish the function or loop. |
|
|
Continue running. |
|
|
Stop debugging. |
The guide has a section on special circumstances, and they follow from how the mechanisms work. Breakpoints that are outside any function cannot be injected into a function object, so RStudio provides debugSource(), a source() that stops at those breakpoints; it is not recursive, so a file that is itself sourced by the debugged file is not stopped in. Package code needs an up-to-date build, and the package should be compiled with source information (--with-keep.source) for the breakpoints to be placed. In Shiny applications breakpoints work only inside the shinyServer function, not in ui.R or global.R, and a lot of Shiny infrastructure shows in the call stack before control reaches your code. In R Markdown breakpoints do not currently work in chunks, so use browser(); the Knit button renders in a separate R process, where the debugger cannot reach, so call rmarkdown::render() from the console to render in the primary process; the debugger’s output is not shown while knitr is capturing output, so call sink() at the Browse[1]> prompt, or set options(error = function() { sink(); recover() }) in the setup chunk (which ends with a harmless "no sink to remove" warning). For Quarto documents with the knitr engine, rmarkdown::render() is likewise recommended, and quarto::quarto_render() is documented as currently not compatible with these strategies. These two use the knitr and rmarkdown packages; they were not run here.
Positron
Positron’s R support is provided by Ark, an R kernel. Its README describes three roles: a Jupyter kernel, a Language Server Protocol server for completions and diagnostics, and a Debug Adapter Protocol server for "sophisticated step-debugging of R functions" that manages source references, creating them on the spot if needed, and integrates with the kernel to step through R code and inspect local variables (Ark README). The same README, as read on 2026-10-20, says that support for breakpoints is currently missing and that debug(), debugonce() or browser() can be used to drop into the debugger. That is a statement with a date: the navigation of the Positron documentation pages that were read lists a "Debugging R code" guide, which was not among the pages checked, and the release notes should be consulted for the version in use before relying on either. The Positron features page presents the Variables pane as the place to examine variables, objects and environments while debugging. The R functions on this page are ordinary R and are available in the Positron console as in any other front end; debug(), debugonce() and browser() are the ones the README names. See IDEs and the R Console for the other front ends, including the community debugger extension for Visual Studio Code, and for the console-level view of the same tools.
Emacs and compiled code
R FAQ 6.3, "Debugging R from within Emacs", is about running the R binary under gdb, the GNU debugger for compiled code, from Emacs, either with the GUD library or by attaching to an inferior R process started from ESS (R FAQ 6.3).
Compiled code
The R debugger sees interpreted code only. When the failing code is in C, C++ or Fortran, Writing R Extensions covers the tools in its chapter on debugging compiled code: a crash prints "caught segfault" and a traceback of the R calls active, a core dump can be examined with gdb, R can be run under the debugger with R -d gdb --vanilla, an interrupt (Ctrl-C) while a long computation runs drops into the debugger so that where shows where the time goes, and a debugger can be attached to a running process with gdb -p pid. Compiled code loaded dynamically can have breakpoints set in it only after it is loaded, so the procedure is to start R under the debugger, load the shared object with dyn.load() or library(), send an interrupt, set the breakpoints and continue. options(CBoundsCheck = TRUE) makes .C and .Fortran check whether compiled code writes beyond the arguments it is passed (Writing R Extensions: Debugging compiled code). R FAQ 8.2 gives the short version (R FAQ 8.2). Writing compiled extensions is covered in Interfacing C, C++ and Fortran.
A debugging workflow
-
Read the message, and note the call and the text. If the call is
NULLor unhelpful, the stack is what you need. -
Get the stack:
traceback()at the prompt,options(error = function() { traceback(3); q(status = 0) })in a script, oroptions(catch.script.errors = TRUE)to see several failures in one run. -
If the stack names the function but not the cause, stop the program there while it is running:
options(error = recover)to look at the frames after the error, ordebugonce()on the function and run again to step through it. -
If the bad value is produced in a loop or in many calls, use a conditional stop,
browser(expr = ), or atrace()with an expression tracer that prints only the bad case. -
If the failure is a warning, set
options(warn = 2)for the run and treat it as an error. -
If the code runs unattended, use
dump.frames(to.file = TRUE)andq(status = 1)in theerroroption, and debug the dump later. -
Fix the cause, then add the check that would have caught it earlier, with
stopifnot()or an error with a class, and remove everybrowser(),debug()andtrace()before committing:undebug(),untrace()andoptions(error = NULL)are the clean-ups.
Messages seen while debugging
| Message | Meaning | Where it appears |
|---|---|---|
|
There is no uncaught error to report: the last error was caught, or there was none. |
|
|
An |
A data problem (here an |
|
Arithmetic on a string, such as dates that were never converted. |
Transcripts of |
|
|
|
|
A warning became an error because |
The |
|
|
A warning from |
|
The limit of nested expressions was reached. |
An error of class |
|
|
|
Source: R documentation: debug
R-devel (the version that will become R 4.7.0, so upcoming) adds condition classes for two of the errors that you meet most while debugging: the NEWS says that object-not-found errors now signal error conditions of class objectNotFoundError, that function-not-found errors signal the subclass functionNotFoundError, and that the condition classes used internally for signalling some errors are now documented in ?conditions (R-devel NEWS). Handlers for object 'x' not found can then select the class instead of matching the message text. This is not available in R 4.6.1.
Version notes
| Version | Change | Source |
|---|---|---|
R 3.1.0 |
A call to |
NEWS 3.1.0 |
R 3.3.0 |
|
NEWS 3.3.0 |
R 3.4.0 |
The JIT compiler is enabled by default and does not compile code with explicit |
NEWS 3.4.0 |
R 3.5.0 |
|
NEWS 3.5.0 |
R 4.0.0 |
The internal traceback ( |
NEWS 4.0.0 |
R 4.0.3 |
|
NEWS 4.0.3 |
R 4.0.4 |
|
NEWS 4.0.4 |
R 4.2.0 |
Stack-overflow errors inherit from class |
NEWS 4.2.0 |
R 4.3.0 |
|
NEWS 4.3.0 |
R 4.4.0 |
The option |
NEWS 4.4.0 |
R 4.4.1 |
|
NEWS 4.4.1 |
R 4.5.0 |
|
NEWS 4.5.0 |
R 4.6.0 |
While |
NEWS 4.6.0 |
R-devel (4.7.0, under development) |
|
R-devel NEWS |
The R 3.x entries are in the NEWS of R 3.x; the R-devel entries are in the development NEWS and may change before 4.7.0 is released.
Where older tutorials differ
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
"Press Enter to continue" at a |
Enter is |
The browser called by |
R 3.1.0 |
“ |
|
The |
R 3.4.0 |
Edit the function to add |
|
The function is not edited, so nothing is left to remove; |
always |
|
|
With the parentheses the browser opens at once and the option stays unset; as a plain |
always |
|
|
|
always |
Look at |
|
The script has ended before anyone can type |
always; the option is new in R 4.4.0 |
|
|
The |
always |
Raise |
Find the recursion with a low limit and fix it; catch the |
A high limit can overflow the C stack and crash R |
R 4.2.0 for the classes |
Guess which call gave a warning |
|
A warning has no stack unless it is converted or the handler looks at the live one |
always |
|
|
Messages go to the error stream and can be silenced; a conditional stop prints only the interesting case |
always |
“Call |
|
The dump copies every frame, which can be large |
always |
Where to go next
-
Conditions and Error Handling for
tryCatch(),withCallingHandlers(), restarts, custom conditions andglobalCallingHandlers(), the machinery underoptions(warn = 2)and the calling-handler stack above. -
Defensive Programming and Input Validation for failing early and clearly, so that the stack is short and the message tells what is wrong.
-
Functions for
on.exit(),sys.call(),sys.function()andmatch.call(). -
Scoping, Environments and Closures and Lazy Evaluation and Promises for the frames and promises that the browser shows.
-
Evaluation and Non-Standard Evaluation for
sys.calls(),parent.frame()anddo.call(). -
S3 Classes and Generics and S4 Classes and Methods for the dispatch that
debugcall()andtrace(, signature = )follow. -
IDEs and the R Console for the front ends, and Testing with testthat for turning a debugged failure into a test that stays.
-
Performance, Profiling and Benchmarking for finding where the time goes rather than where the error is.
References
-
The R Language Definition: Debugging (browser, debug/undebug, trace/untrace, traceback)
-
R FAQ 8.5: How can I debug S4 methods?
-
R FAQ 8.2: How can I debug dynamically loaded code?
-
R FAQ 6.3: Debugging R from within Emacs
-
R FAQ 7.32: How can I capture or ignore errors in a long simulation?
-
R NEWS (Changes in R 4.0.0 to 4.6.0), NEWS of R 3.x and R-devel NEWS
-
options (error, warn, warning.expression, showErrorCalls, catch.script.errors, expressions)
-
conditions and warnings