Lesson 3 of 3

Debugging Tools in R

In Lesson 2, Priya's lending library learned to catch failures it expected: a bad record became an NA instead of a crash, and stopifnot() turned away impossible input at the door. But tonight a different kind of trouble arrives. She runs her new nightly report and R stops dead with a message she has never seen, pointing at a line that looks perfectly fine. Nothing she validated against. She does not know where it broke, or why.

That is what debugging tools are for: not handling failures you predicted, but hunting down the ones you did not. The job is always the same four moves, shown below.

By the end of this lesson you will be able to:

  • Read a traceback() to find the exact call where an error occurred
  • Freeze a running function with browser() and inspect its real values
  • Arm the debugger without editing the source (debug()), and drive the RStudio / Positron debugger
  • Pick the right tool for a crash versus a silent wrong answer

Prerequisites: you can write a function and use [if and for](Control-Flow-in-R.html), you met [stop(), warning() and message()](Errors-Warnings-and-Messages.html) in Lesson 1 and [tryCatch() and stopifnot()](tryCatch-and-Input-Validation.html) in Lesson 2, and you know a vector can be numeric or character.

The problem

A crash that does not name the culprit

Priya's report is three small functions stacked on top of each other. night_report() walks the borrowers; for each one borrower_balance() adds up the fees; and the old friend late_fee() prices a single overdue book. Defined and run on a clean night, they work exactly as you would expect:

RInteractive R
late_fee <- function(days_late, rate = 0.25) { days_late * rate # cents per overdue day } borrower_balance <- function(days_vec) { # total one borrower owes tonight total <- 0 for (d in days_vec) total <- total + late_fee(d) total } night_report <- function(returns) { # one balance per borrower out <- numeric(length(returns)) for (i in seq_along(returns)) out[i] <- borrower_balance(returns[[i]]) names(out) <- names(returns) out } clean <- list(amir = c(5, 12), beth = c(0, 3, 7)) # a tidy night: all numbers night_report(clean) #> amir beth #> 4.25 2.50

  

Now the real night. One borrower's day counts came in from a spreadsheet column that imported as text rather than numbers. Watch what happens:

# beth's days arrived as the strings "0", "3", "7", not the numbers 0, 3, 7
tonight <- list(amir = c(5, 12), beth = c("0", "3", "7"))
night_report(tonight)
#> Error in days_late * rate : non-numeric argument to binary operator

The message is technically true but practically useless: it names an operation, not a borrower, and not which of the dozens of calls to late_fee() blew up. With one borrower you might guess. With four hundred you cannot. You need to find the exact call that broke.

Locate

traceback(): which call actually broke

The instant an error halts your code, R quietly records the chain of calls that led to it. Type traceback() immediately afterward and it prints that chain back to you:

traceback()
#> 3: late_fee(d) at #4
#> 2: borrower_balance(returns[[i]]) at #3
#> 1: night_report(tonight)

Read it like a stack of plates. Each line is a function call, and each call sits inside the one below it: you called night_report() (line 1), which called borrower_balance() (line 2), which called late_fee() (line 3). The crash happened in the call printed at the top, the deepest one: late_fee(d). That is your crime scene. The diagram below is the same chain, in the order the calls actually happened.

Key Insight
traceback() answers the single hardest question first: where. Base R lists the most recent call at the top, so the top line is the scene of the crash. (The RStudio Traceback pane shows the same calls; just check which end is flagged as the error, since the order can be drawn either way.)
Check yourself

Read the traceback

A colleague's script crashes and they send you only this traceback():

3: parse_date(x)
2: clean_row(row)
1: load_file("sales.csv")

Which function should you open first to find the bug?

Right. Base R prints the most recent call at the top, so line 3, parse_date(), is the deepest call and the place the error was thrown. Start there.
The middle call is just a link in the chain. It passed the work down to parse_date(), which is where things actually broke.
Inspect

browser(): freeze the code and look around

traceback() told Priya the crash is inside late_fee(). Now she needs to know why: what value did late_fee() actually receive? The most powerful tool for that is browser(). Drop it into a function and R pauses there mid-run and hands you an interactive prompt, sitting inside that exact call, with every local variable alive and inspectable.

late_fee <- function(days_late, rate = 0.25) {
  if (!is.numeric(days_late)) browser()   # pause only when the day count is not a number
  days_late * rate
}
night_report(tonight)
#> Called from: late_fee(d)
#> Browse[1]>               # R stops and hands you a prompt INSIDE late_fee

At that Browse[1]> prompt you are standing inside the paused function. Type a variable name to see its value, and the mystery solves itself:

Browse[1]> days_late
#> [1] "0"
Browse[1]> class(days_late)     # the smoking gun: it is text, not a number
#> [1] "character"

There it is. days_late is the string "0", and "0" * 0.25 is meaningless, hence "non-numeric argument to binary operator". From the prompt you also drive execution one piece at a time:

At the Browse[1]> prompt What it does
n run the next line, then pause again
s step into the function called on this line
c continue running until the next pause or the end
where print the call stack you are currently sitting in
Q Quit the debugger and stop the call entirely
Key Insight
traceback() is a photograph taken after the crash; browser() is standing in the room while it happens. One tells you where, the other lets you ask what is every value right now. Together they turn "it broke somewhere" into "it broke here, on this value."
Your turn

Fix the bug you found

browser() revealed the cause: the import handed late_fee() text like "7" instead of the number 7. The smallest honest fix is to coerce the day count to a number before doing arithmetic on it.

Replace the ____ with the function that turns text into a number, then check your answer. If it is right, the whole report totals cleanly with no crash.

That is the fix: as.numeric() coerces "7" to 7 before the multiplication, so every book prices correctly and beth totals 2.50. In real code you would also guard the input up front with the stopifnot() from Lesson 2.Use as.numeric() to convert the text to a number, e.g. days_late <- as.numeric(days_late).
Show answer
late_fee <- function(days_late, rate = 0.25) {
  days_late <- as.numeric(days_late)        # coerce the imported text to a number
  days_late * rate
}
borrower_balance <- function(days_vec) {
  total <- 0
  for (d in days_vec) total <- total + late_fee(d)
  total
}
night_report <- function(returns) {
  out <- numeric(length(returns))
  for (i in seq_along(returns)) out[i] <- borrower_balance(returns[[i]])
  names(out) <- names(returns)
  out
}

tonight <- list(amir = c(5, 12), beth = c("0", "3", "7"))
night_report(tonight)
#>  amir  beth
#>  4.25  2.50
Inspect, without editing

debug() and the IDE debugger

Editing a function to insert browser() is fine for your own code, but you often want to inspect a function you should not (or cannot) edit, like one from a package. debug() solves that: it arms a function so the next call drops you into the browser, with no change to the source. debugonce() does the same for a single call and then disarms itself.

debug(late_fee)        # arm it: every call now opens the browser
night_report(tonight)  # pauses inside late_fee, exactly like an inserted browser()
undebug(late_fee)      # disarm it again when you are done

debugonce(late_fee)    # same, but auto-clears after one call

options(error = recover)   # after ANY error, offer to step into any frame
options(error = NULL)      # restore the default when finished

Every one of these is what the RStudio and Positron debuggers wrap in buttons. You set a breakpoint by clicking the margin instead of typing browser(), and a toolbar replaces the one-letter commands:

In the console In RStudio / Positron
a browser() line or debug(f) click the gutter to set a breakpoint (Shift+F9)
the Browse[1]> prompt the debug toolbar appears over your source
n (next) / s (step into) / c (continue) the Next / Step Into / Continue buttons
Q (quit) the Stop button
typing a name / ls() the Environment pane lists every live variable
traceback() the Traceback pane, click a call to jump to it
Note
After a crash, "Rerun with Debug" in the IDE reruns the failing call with the debugger already armed, so you land at the error with the whole call stack and every variable in front of you, without re-typing anything.
Check yourself

No error, but a wrong answer

The report runs to the end with no error at all, but one borrower's balance comes back as 40.00 when it should be about 4.00. Nothing crashed, so traceback() shows nothing useful. What is the right move?

Right. A wrong-but-not-crashing result is a logic bug, not a thrown error, so there is no traceback to read. You have to watch the real values flow through, which is exactly what browser and debug let you do.
tryCatch() recovers from errors, but there is no error to catch. The code is running happily and producing the wrong number; you need to inspect it, not catch it.
Know the limits

When these tools do not help

browser(), debug() and recover() all do one thing: pause and wait for you to type. That makes them powerful at the console and useless anywhere that cannot answer a prompt.

  • Interactive only. In a script run with Rscript, a scheduled job, a knitted report, or this in-browser session, there is no one to type n or c. A pause there just hangs.
  • **Never ship a stray browser().** Left in code that runs unattended, it silently freezes the whole job. Always remove it once the bug is found.
  • **traceback() is error-only.** It reports the last crash and nothing else, so it cannot help with a wrong-but-silent result.
  • Reproduce first. Half of debugging is shrinking the input to the smallest case that still fails. A two-row example beats a million-row one every time.

For the non-interactive cases, fall back to tools that leave a record instead of pausing: a few print() or message() lines to log values, options(error = ...) to dump diagnostics on failure, and unit tests (the testthat package) so a bug you fixed cannot quietly return.

Warning
A browser() accidentally left in a function will hang any automated run, a CI build, a nightly cron job, a report render, because it sits forever waiting for input that never comes.
Go deeper

References

A few authoritative, free places to take this further:

Lesson 3 complete

You can now chase down a bug you did not see coming. traceback() reads the call stack after a crash and points you at the exact call that broke, with the deepest call printed at the top. browser() freezes a running function so you can read its real variables and step through it line by line; debug() and debugonce() do the same without editing the source, and the RStudio / Positron debugger wraps all of it in breakpoints, a toolbar and an Environment pane. And you know the edges: these are interactive tools, never to be left in unattended code, and useless for a wrong answer that never raises an error, where logging and tests take over.

That completes R Foundations: Debugging. You can now make your code speak clearly (Lesson 1), survive bad input (Lesson 2), and surrender its secrets when something still goes wrong (Lesson 3). The next section moves from fixing code to keeping a whole project reproducible, with projects, renv and the modern R toolchain.