Skip to content

Debugging & Reading Errors

An error message is not an obstacle between you and the result. It is the most specific information you will ever get about what your code actually did, and most people throw it away by reading only the last line.

Every error quoted below is real, produced by a script that raises it on purpose and catches it. Nothing here is written from memory, which matters on this page more than most: an error message typed from recall is wrong often enough to mislead.

R prints two things, and people read one of them. Here a count column arrives as text, which is what read.csv gives you when a cell says n/a:

#' Normalise.
#'
#' @param counts Input value.
#' @param total Input value.
#' @return Computed result.
Normalise <- function(counts, total) {
counts / total
}
#' CountsPerMillion.
#'
#' @param counts Input value.
#' @return Computed result.
CountsPerMillion <- function(counts) {
Normalise(counts, sum(counts)) * 1e6
}
counts <- c("120", "455", "89") # character, not numeric
CountsPerMillion(counts)
MESSAGE: invalid 'type' (character) of argument
CALL: sum(counts)

The call is the part that gets skipped. It says the failure happened inside sum(), not on the line you typed. That narrows the search immediately.

traceback() prints the calls behind the last error. In a script you can capture the same stack at the point of failure:

call stack at the point of failure, innermost last:
1: tryCatch(withCallingHandlers(cpm(counts), error = function(...
2: tryCatchList(expr, classes, parentenv, handlers)
3: tryCatchOne(expr, names, parentenv, handlers[[1L]])
4: doTryCatch(return(expr), name, parentenv, handler)
5: withCallingHandlers(cpm(counts), error = function(e) { ...
6: cpm(counts) <- your code
7: normalise(counts, sum(counts)) <- your code
8: .handleSimpleError(function (e) { stack <- sys.calls()...
9: h(simpleError(msg, call))

Nine frames. Two are yours. Everything else is tryCatch machinery and condition handling, and learning to skip it is the actual skill. Scan for your own function names, ignore the rest, and start at the innermost one of yours.

Then work upward. normalise() is where R gave up, and nothing in normalise is wrong: it was handed a bad value. cpm() passed it along. The bug is on the line that built counts, which appears in no frame at all. The deepest frame is rarely the culprit.

The error above was the lucky case, because it raised. str() tells you what an object is rather than what you assumed:

str(counts)
chr [1:3] "120" "455" "89"

Three characters, not three numbers. Reach for str() before you reach for anything else; on a list or an S4 object it is the difference between guessing and knowing.

An error stops. A warning lets the wrong number keep moving:

as.numeric(c("120", "455", "not_measured"))
WARNING: NAs introduced by coercion
result: 120, 455, NA
mean(result) = NA

The script carried on and produced NA. In a longer pipeline that NA propagates silently into a summary, a model, or a figure.

While debugging, promote warnings to errors so they stop the script where they happen:

options(warn = 2)

Move the failure from two frames down to the front door of the function, with a message that names the broken assumption:

#' CountsPerMillion.
#'
#' @param counts Input value.
#' @return Computed result.
CountsPerMillion <- function(counts) {
stopifnot(
"counts must be numeric" = is.numeric(counts),
"counts must not be negative" = all(counts >= 0, na.rm = TRUE)
)
counts / sum(counts) * 1e6
}
MESSAGE: counts must be numeric

Named conditions became the message. Without names you get the expression back, which is less useful when someone else hits it.

Worth looking for in any code you are reviewing, including your own, because a broad catch is a tempting way to make a script stop complaining:

result <- tryCatch(run_analysis(df), error = function(e) NULL)

That swallows every error, including the typo three lines in, and leaves you a NULL you cannot explain. Catch the specific failure you know how to recover from and let the rest crash. A crash is information.

#' ReadOne.
#'
#' @param path Input value.
#' @return Computed result.
ReadOne <- function(path) {
tryCatch(
read.csv(path),
warning = function(w) {
cat("could not read ", path, ": ", conditionMessage(w), "\n", sep = "")
NULL
}
)
}

These need a human at the console, so the script behind this page cannot run them. They are how you inspect a live frame instead of reasoning about it.

Tool What it does
browser() drop into a prompt at that line
debug(fn) / debugonce(fn) enter the debugger on every call to fn, or once
trace(fn, browser) inject a breakpoint without editing the source
options(error = recover) pick a frame to inspect after any error

At a browser() prompt: n next, s step in, c continue, Q quit, and any R expression evaluates inside the paused frame.

Task Call
What is this object really str(x), class(x), typeof(x)
Where did the error come from traceback()
Stop at a line browser()
Debug a function debugonce(fn)
Inspect after any error options(error = recover)
Turn warnings into errors options(warn = 2)
Guard an input stopifnot("message" = condition)
Handle one condition tryCatch(expr, error = function(e) ...)
  • Testing & Assertions, the other half of this: a guard catches a bad input, a test catches a bad implementation.
  • Functions, including the scope rules behind “it worked in my session”.
  • Fundamentals You Still Need, if you work with a coding agent: what to check in what it produces, and why this page is on that list.

The runnable script and the container are in the companion code repo under guides/programming/r/debugging/.