Confession: I've been writing Rust for something like four years and I set up a real debugger for it exactly once. It was a Tuesday. I got CodeLLDB working in VS Code, hit a breakpoint inside a HashMap, saw a wall of raw pointers and bucket indices where I expected my keys and values, closed the panel, and typed dbg!(&map) instead. That was two years ago. I haven't opened the debug panel since.
So when the Rust compiler team published the results of their first debugging survey this week, I read it with the slightly uncomfortable feeling of being described. Over 2,300 people answered. More than half of them don't currently use a debugger for Rust. And the top reason, given by 81% of respondents, wasn't that debuggers are broken. It was that print debugging is faster.
I want to argue that's half true. Print debugging is faster for the first twenty minutes. It's slower for everything after that, and the survey accidentally explains why we never get to "everything after that". Short version: the debugger isn't losing on capability. It's losing on the first impression, and the first impression is a Vec rendered as three integers.
The numbers that actually matter
The headline stat is the 46% who currently use a debugger, but the breakdown by experience is the interesting part. Roughly half of self-described beginners have never used a debugger in Rust at all. Nearly half of advanced users do. That's not "experienced people learn to love the tool". Read it the other way: the people most likely to need step-through debugging are the ones least likely to have it working.
Where it hurts is stepping. Just over 51% of respondents said they hit problems stepping through code, and the top offender was async at 28%, followed by macro-heavy code at 23%. Only a quarter of debugger users debug async code at all. I don't think that's because nobody writes async. I think it's because the first time you try to step into a .await and land in a tokio state machine with forty frames of generated code, you learn not to try again.
Then there's the pain point list. 74% reported poor representation of values. 55% couldn't print a variable at all. The types people named most were enums, Vec, and HashMap, which is to say the three types that appear in every Rust program ever written. If the debugger can't show you a Vec<String> cleanly, it doesn't matter how good the stepping is.
Why print debugging keeps winning
Here's my honest accounting of what dbg! gives me that the debugger didn't, at least on that Tuesday.
#[derive(Debug, Clone)]
struct Order {
id: u64,
lines: Vec<(String, u32)>,
status: Status,
}
#[derive(Debug, Clone)]
enum Status {
Pending,
Shipped { tracking: String },
Cancelled,
}
fn reprice(orders: &mut [Order]) {
for o in orders.iter_mut() {
dbg!(&o.status, o.lines.len());
// ...
}
}
That dbg! line prints the file, the line number, the expression text, and a pretty-printed value, and it works identically on Linux, macOS, Windows, WASM, and whatever embedded board you're flashing. It costs one line and zero config. The Status::Shipped { tracking: "1Z..." } renders exactly the way I wrote it in source.
In lldb, out of the box, that same enum shows up as a discriminant and a union. A Vec<(String, u32)> is a pointer, a length, and a capacity. You can drill into it. You can also just not, and 81% of people chose not.
The survey's own authors wonder if the experience could be made convenient enough to "dethrone print debugging", and then admit it seems hard to beat something so intuitive. I'd go further: I don't think it should try to beat dbg! for the small stuff. The fight worth having is over the two situations where printing falls apart.
The two cases where I regret not having a debugger
The first is the crash I can't reproduce on demand. A service panics once every few hours under real load. Adding prints means redeploying, waiting, and hoping I guessed the right variable. Attaching to the hung process and grabbing a stack trace means I have the answer in a minute. About half of survey respondents use debuggers for exactly this, and that fraction goes up with experience. Advanced users learn this lesson the hard way, usually at 2am.
The second is the multi-language boundary. 44% of respondents debug Rust alongside another language, and 70% of those said C. If you're calling into a C library through FFI and something corrupts memory, println! on the Rust side tells you nothing about what happened on the other side of the extern "C" call. This is the case where I wish I'd kept my CodeLLDB config alive instead of deleting it in a fit of frustration.
Both of those are "I can't print my way out" situations. Neither is the beginner-stepping-through-a-loop case that most tutorials show. Which brings me to the part of the survey I think is the real story.
The attribute 62% of library authors have never heard of
Buried in the "Debugger Visualizers" section: nearly 62% of respondents who write libraries had never heard of the #[debugger_visualizer] attribute. Of the ones who had heard of it and didn't use it, half said they didn't have time and just under half said they didn't know how to write the scripts.
This is the mechanism that fixes the "my Vec looks like three integers" problem. It lets a crate embed a Natvis file (for Microsoft debuggers) or a GDB pretty-printer (a Python script) into its debug info, so the debugger knows how to display the crate's types. The standard library ships these for its own types, which is why String usually renders fine even when your own types don't. The Rust Reference entry on debugger attributes is short and worth ten minutes.
Here's the whole thing for a GDB pretty-printer. The attribute goes at your crate root:
#![debugger_visualizer(gdb_script_file = "../debug/order.py")]
And the script, which lives at debug/order.py, is a small amount of Python:
import gdb
class OrderPrinter:
def __init__(self, val):
self.val = val
def to_string(self):
return f"Order #{self.val['id']} ({self.val['lines']['len']} lines)"
def lookup(val):
if str(val.type.strip_typedefs()).endswith("::Order"):
return OrderPrinter(val)
return None
gdb.pretty_printers.append(lookup)
That's it. The next time anyone attaches GDB to a binary that uses your crate, an Order shows up as Order #4412 (3 lines) instead of a struct dump. I'm not going to pretend the Natvis XML for Windows is as pleasant, but it's the same idea with angle brackets.
What gets me is the gap between how cheap this is and how few people know it exists. Half the "debuggers show garbage" complaint could be fixed crate by crate, by the people who understand the types best, in under an hour each. Nobody's doing it because nobody told them the hook was there. If you maintain a crate with a non-trivial core type, this is the highest-leverage hour you'll spend this month. I wrote a bit about why Rust's ownership model makes these types look odd in a debugger in my post on the memory management mental model, and visualizers are the missing half of that story.
What I'm actually changing
I'm not going to become a step-through-everything person. I'm a dbg! person and the survey suggests I'm in good company. But I've made three concrete changes this week.
First, I restored a minimal CodeLLDB config to my main service repo so that "attach to hung process" is a keystroke rather than a project. It's four lines of launch.json and I'd deleted it out of spite.
{
"type": "lldb",
"request": "attach",
"name": "Attach to running api",
"program": "${workspaceFolder}/target/debug/api"
}
Second, I added a debugger_visualizer for the one custom collection type in the internal crate that every other service depends on. Took about forty minutes, most of which was figuring out that the script path is relative to the file containing the attribute, not the crate root.
Third, I stopped telling junior developers to "just use the debugger" for Rust. It's not helpful advice when the survey shows half of beginners have never gotten one working, and the reason is that the first thing they'll see is a HashMap rendered as a hash table's internals. Show them dbg!, show them RUST_BACKTRACE=1, and save the debugger for when they hit a crash they can't print their way out of. Client work I take on through my portfolio has taught me that onboarding advice that doesn't match the tooling reality just gets ignored anyway.
The thing to do this week
If you write Rust, and especially if you maintain a crate other people use, do one thing: open the survey report, scroll to the visualizer section, and check whether your crate's main type would look sane in a debugger. If it wouldn't, write the pretty-printer. It's a twenty-line Python file.
And if you're the 81% who print-debug because it's faster: fair enough, me too. Just keep an attach config around for the crash you can't reproduce. That's the one time the debugger earns its keep, and it's much less fun to set up at 2am than at 2pm.
Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.
Top comments (0)