Stop Restarting R For Debugging
Use options(error = recover), options(warn = 2), and dump.frames()
There is one debugging habit that wastes more time than almost anything else in R.
The moment something fails, we change the code and press Run again.
It feels productive. But it isn't.
That rerun destroys the very thing that could have explained the bug: the exact state of the failed computation. The local variables disappear, the call stack is rebuilt, intermediate objects change, and if randomness or external data are involved, you may never see exactly the same failure again.
Common Lisp programmers learned a different instinct decades ago.
When a program crashes, don't restart it yet. Interrogate it!
R as a Lisp dialect, inherited one of Lisp's best ideas: the condition system. Together with recover(), browser(), and a few debugging options, it lets you inspect a computation while the evidence is still fresh. Like a detective can investigate a fresh crime scene.
Once you start debugging this way, you stop treating errors as interruptions and start treating them as information.
Everything in this article follows this simple algorithm:
Bug -> freeze the evidence -> inspect the live state -> find the broken assumption -> only then change the code!
The commands are easy. Learning when and how to use them is the real skill.
Step 0: Teach R to Help You
Before we touch a single line of code, let's change R's default behaviour.
During development I almost always start with:
options(
error = recover,
warn = 2
)These two lines change the behavior of R.
error = recover tells R:
Don't simply terminate. Show me the call stack and let me enter the function that failed.
warn = 2 tells R:
Treat the first warning as a stopping point instead of letting it quietly propagate until something breaks later.
For long-running scripts that execute overnight, I switch to:
options(error = dump.frames)Instead of throwing everything away, R saves the call frames into the global variable last.dump. Tomorrow morning I can interactively inspect them with:
debugger(last.dump)The first lesson is already surprisingly useful.
Configure your debugger before you need it.
Our Example
Suppose somebody gives you this function.
prepare_sales <- function(data) {
data$revenue <- as.numeric(data$revenue)
data$cost <- as.numeric(data$cost)
data$profit <- data$revenue - data$cost
data$margin <- data$profit / data$revenue
data
}And this data.
sales <- data.frame(
customer = 1:6,
revenue = c(
"100",
"200",
"150",
"unknown",
"300",
"0"
),
cost = c(
"50",
"90",
"80",
"70",
"180",
"20"
)
)Looks harmless.
Now imagine this function is only one small part of a larger report that takes twenty minutes to run.
You execute it. It fails.
Most programmers now think:
What should I change?
That is the wrong first question.
Instead ask:
What does the failed computation can tell me about itself that I need to know?
Step 1: Recover Before You Rerun
With options(error = recover) enabled, R shows something like
1 prepare_report()
2 prepare_sales()
3 ...Choose the frame that belongs to your code.
You are no longer looking at an error message.
You are standing inside the function that failed.
That is a very different way of debugging.
Now resist another temptation.
Do not print everything.
Instead ask small questions.
class(data)
dim(data)
names(data)
str(data)Most R bugs are not value bugs.
They are
- shape bugs
- a wrong class
- a wrong dimension
- a wrong column
- a rong type.
Only afterwards inspect the values.
summary(data$revenue)
unique(data$revenue)
which(is.na(
suppressWarnings(
as.numeric(data$revenue)
)
))One suspicious row immediately appears. The bug was always there.
You simply stopped long enough to see it.
The rule is easy to remember:
Inspect the shape before the values.
Step 2: Replay the Bug
Now we know which function deserves attention.
We no longer need recover().
We need to watch the bug develop.
If you're using RStudio, simply click next to the line number.
A breakpoint appears.
No source code changes.
No forgotten browser() calls committed to Git.
Run the program again.
Execution now pauses before the interesting line executes.
Watch the variables change.
Step one line.
Watch them again.
A useful mental model is this:
recover() finds the crime scene.A breakpoint lets you watch the crime happen.
Only when a bug depends on a rare condition do I reach for browser().
if(anyNA(revenue))
browser()or
if(any(revenue == 0))
browser()These are conditional breakpoints.
They stop when reality violates one of your assumptions.
That is usually much more useful than stopping on every execution.
The rule is simple.
Break where assumptions fail.
Step 2½: Debug Without Touching Your Source Code
Sometimes you don't even need a breakpoint.
You know which function is suspicious, but you don't want to edit it.
R can attach the debugger directly to the function.
debugonce(prepare_sales)Now call your program normally.
run_report(sales)Execution stops automatically when prepare_sales() is entered.
No browser().
No temporary code.
No forgotten debugging statements committed to Git.
When you're done, nothing remains.
For longer investigations, use
debug(prepare_sales)Every call now enters the debugger.
Later remove it again.
undebug(prepare_sales)This is one of those small R features that can quietly save you a lot of time.
It also reflects a very Lisp-like way of thinking.
The debugger is attached to the running program, not hard-coded into the source.
Choose the least invasive tool that solves your problem.
| Situation | Best tool |
|---|---|
| I know the suspicious line. | RStudio breakpoint |
| I know the suspicious function. | debugonce() |
| I want to debug every call. | debug() |
| I only want to stop for a rare condition. | browser() |
| I don't know where the bug is. | options(error = recover) |
Notice the progression - each tool becomes more general.
Most debugging sessions should start at the top of the table, not the bottom.
The rule is simple.
Debug the running program. Don't rewrite it just to observe it.
Step 3: Don't Hide Errors with tryCatch()
One of the most common mistakes in R looks like this.
tryCatch(
risky_operation(),
error=function(e)
NULL
)The error disappeared.
The bug didn't.
tryCatch() is not a debugging tool.
It is a recovery tool.
Use it only when your code genuinely knows what should happen next.
For example, suppose you're processing one thousand files.
If one file is corrupt, you probably don't want to lose the other 999 results.
Now tryCatch() makes sense.
safe_read <- function(file){
tryCatch(
read.csv(file),
error=function(e){
data.frame()
}
)
}Notice the difference.
We are not pretending the error never happened.
We are making an explicit recovery decision.
That decision belongs near the boundary of the application, not inside a small helper function.
The rule is one every Lisp programmer learns early.
Signal errors low. Recover high.
Step 4: Conditions Are More Interesting Than Errors
Many languages have only exceptions.
R inherited something richer from Lisp.
Everything interesting that happens during evaluation is a condition.
Messages, warnings, errors, all are conditions.
That sounds like a small distinction until you realise what it allows.
Suppose a warning appears because one value could not be converted.
You could suppress it.
Or you could observe it, record it, and continue.
warnings <- character()
result <-
withCallingHandlers(
mean(x, na.rm=TRUE),
warning=function(w){
warnings <<-
c(
warnings,
conditionMessage(w)
)
invokeRestart(
"muffleWarning"
)
}
)Now you have two things:
the result
and
every warning that occurred while producing it.
That is far more useful than calling suppressWarnings() and hoping nothing important happened.
The rule is worth remembering.
Observe first. Suppress later.
Step 5: Save Expensive Bugs
Suppose building your object took two hours.
Don't rebuild it.
Save it.
saveRDS(data, "failing_data.rds")Tomorrow.
data <- readRDS("failing_data.rds")Your twenty-minute bug now reproduces in one second.
This habit sounds trivial, but it isn't.
Most debugging time is not spent fixing bugs, but by reproducing them.
The rule is almost embarrassingly simple.
Save expensive bugs.
R Is Not an Exception Language
If you remember only one idea from this article, let it be this.
Most programmers think like this (Exception Model):
Something went wrong.
-> Throw an exception.
-> Catch it.
-> Abort or recover.But R (as a Lisp) shares with nearly all Lisp languages something much richer:
Something went wrong.
-> Signal a condition.
-> Offer possible recoveries.
-> Let higher-level code choose.
-> Continue.That is the condition model.
It is the same philosophy that made Common Lisp famous.
Most R programmers use only this part:
tryCatch(...)But tryCatch() is only one small piece of the system.
The full toolkit looks like this.
| Purpose | R |
|---|---|
| Signal something happened | signalCondition() |
| Signal an error | stop() |
| Signal a warning | warning() |
| Observe conditions | withCallingHandlers() |
| Recover after failure | tryCatch() |
| Offer recovery strategies | withRestarts() |
| Choose one | invokeRestart() |
| Discover available ones | computeRestarts() |
This is not an accident, but a Lisp-style condition system built into base R.
Most R programmers never learn it.
Stop Thinking About Errors
Suppose you are writing a parser.
Most code looks like this.
parse_number <- function(x) {
value <- as.numeric(x)
if (is.na(value))
stop("Invalid number.")
value
}The parser already decided what should happen.
Abort. But should it?
Imagine four different callers.
The first wants to replace invalid values with NA, the second wants to use 0, the third wants to skip the record, and the fourth wants to ask the user.
Why should the parser decide? It shouldn't.
The parser only knows what happened. The caller knows what should happen next. Those are different responsibilities.
This is where the condition system becomes interesting.
Instead of deciding, the parser can simply report the situation and offer several possible recoveries, and the caller chooses.
That is the key shift.
Signal the problem where you detect it. Decide how to recover where you have enough context.
Once you start thinking this way, your APIs become smaller, cleaner and easier to reuse.
A Different Way to Think About Bugs
Common Lisp programmers often describe the debugger as a conversation and helper.
Your program reaches a state it cannot handle. Instead of disappearing, it pauses.
You inspect the current state, call stack, and the local variables.
Perhaps you invoke a restart, or choose another recovery strategy.
Then the computation continues.
The bug has not interrupted your work, but it has become part of it.
R supports much more of this workflow than most people realise.
recover() lets you enter the failing computation.
RStudio breakpoints let you replay it without modifying your source code.
The condition system separates detection from recovery.
The restart system separates recovery mechanisms from recovery policy.
Together they encourage a very different debugging style.
The Debugging Algorithm
After years of writing R code, this is the workflow I come back to again and again.
Bug
-> Freeze the evidence
-> Enter the failing frame
-> Inspect structure before values
-> Find the broken assumption
-> Replay with a breakpoint
-> Fix the cause
-> Add a regression testNotice what is missing:
There is no
Edit.
or
Run again.
Those come later: First understand, then change.
The Five Rules
If you remember nothing else, remember these.
1. Recover before rerunning.
Don't destroy evidence.
2. Inspect structure before values.
str() solves more bugs than print().
3. Break where assumptions fail.
Use RStudio breakpoints first.
Use browser() for conditional breakpoints and in-code breakpoint setting.
4. Signal low. Decide high.
Low-level functions detect problems.
High-level code decides how to recover.
5. A bug is evidence.
Treat it like a detective treats a crime scene.
Look carefully before touching anything.
That may be the most valuable debugging lesson Common Lisp ever taught.
And it has been sitting inside base R all along.