When your Rust program doesn’t do what you expect, the first tool you should reach for is a debugger. Think of it as a pause button for your code, letting you examine everything at a specific moment. I always start with GDB or LLDB. You just compile your program with the standard cargo build—this keeps all the debug information the tools need—and then launch it inside the debugger. Suddenly, you can stop at any line, look at the value of any variable, and see the exact path of function calls that led you there. It turns a blank error message or a silent crash into a clear, inspectable moment in time.
# This builds your program with the necessary information for debugging.
cargo build
# This starts the GNU Debugger on your compiled binary.
gdb target/debug/my_app
# Inside gdb, you can control execution and ask questions.
# Stop the program at line 15 of main.rs:
(gdb) break main.rs:15
# Start running the program:
(gdb) run
# Execute the next line of code:
(gdb) next
# Show me what's stored in the variable `counter`:
(gdb) print counter
# Show me the list of functions that got me to this point:
(gdb) backtrace
Sometimes, the program works but it’s just too slow. You have a feeling something is taking too long, but you don’t know what. This is where a profiler like perf comes in. It doesn’t guess; it takes measurements. It samples your running program hundreds or thousands of times per second to see which functions are actively using the CPU. I use this when I need hard data before I start changing code. The visual output, especially a flame graph, shows you the entire landscape of your program’s time consumption in one picture. The widest bars are your hottest code paths.
# First, build an optimized version for realistic performance.
cargo build --release
# Then, record performance data while the program runs.
perf record ./target/release/my_app
# Read the statistical report from that recording.
perf report
# To get a visual flame graph, you can process the data further.
# This pipeline creates an interactive SVG file.
perf script | stackcollapse-perf.pl | flamegraph.pl > profile.svg
Rust is excellent at preventing memory errors, but code in unsafe blocks or complex interactions with external libraries can still cause problems. For these cases, I rely on Valgrind’s Memcheck tool. It runs your program in a special environment where every memory read and write is checked. It will catch things like trying to use memory after it has been freed, accessing arrays outside their bounds, and, very importantly, memory that you allocated but never freed. It’s a thorough, if sometimes slow, safety net.
# The basic command checks for a wide range of memory issues.
valgrind --leak-check=full ./target/debug/my_app
# Valgrind can get confused by Rust's internal memory allocations.
# Using a suppression file cleans up the report to show only your issues.
valgrind --suppressions=./rust.supp --leak-check=full ./target/debug/my_app
For understanding the flow of a complex application, especially one with async operations or many components, simple println! statements become messy. This is why I use the tracing crate. It allows you to define “spans”—contexts that have a beginning and an end, like a function call or a request handler. Within these spans, you can log structured events with key-value data. It transforms a linear log file into a story you can follow, showing how work flows through your system and how long each part takes.
use tracing::{info, instrument};
use tracing_subscriber;
// The `instrument` macro automatically creates a span for this function.
// It records the arguments and the time the function takes.
#[instrument]
async fn process_payment(user_id: u64, amount_cents: i64) -> Result<(), String> {
// This is an event inside the span. It includes structured data.
info!(%amount_cents, "Starting payment processing");
// ... your logic here ...
Ok(())
}
fn main() {
// Initialize a subscriber to format and print tracing data.
tracing_subscriber::fmt::init();
info!("Payment service booted");
// When `process_payment` runs, its span and events are emitted.
}
Have you ever had a bug that only happens sometimes? One that’s impossible to reproduce on demand, especially with threads? The rr debugger is a game-changer for this. It records everything about a single run of your program: every instruction, every system call. Once recorded, you can replay that exact execution, perfectly, as many times as you want. You can debug it forward and backward. The non-deterministic bug is now frozen in time, completely open to inspection.
# First, record a session where the bug occurs.
rr record ./target/debug/my_flaky_app
# Later, replay that exact recording. It will be identical every time.
rr replay
# Now you are in a debugger (like gdb) attached to the replay.
# You can set breakpoints, step, and analyze, knowing the bug will manifest.
If your program uses more memory than you think it should, you need an allocation profiler. Tools like heaptrack and dhat track every single heap allocation your program makes. They show you not just how much memory is used, but who asked for it—which function, and from which call path. I reach for this when I see memory growing over time or when I want to reduce allocation overhead for performance. It often reveals surprising sources of temporary memory use.
# Using heaptrack on a Linux system is straightforward.
cargo build --release
heaptrack ./target/release/my_app
# This generates a data file. Launch the GUI to explore it.
heaptrack_gui heaptrack.my_app.<pid>.gz
# The GUI shows allocation hotspots, timelines, and leak candidates.
While you can use perf manually, the cargo-flamegraph tool wraps it into a simple, one-command workflow. It handles the details of profiling and immediately generates the interactive SVG flame graph. I use this for quick, routine performance checks. It’s so convenient that it encourages regular profiling, which is the best way to keep performance regressions in check.
# Install the helper tool once.
cargo install flamegraph
# In your project directory, just run this.
# It builds a release binary, profiles it, and creates the graph.
sudo cargo flamegraph
# Your default browser will open with the interactive flamegraph.svg.
Finally, you don’t have to leave your editor to use powerful debugging tools. Setting up your IDE correctly brings the debugger to your code. In VS Code with the Rust Analyzer and CodeLLDB extensions, you can click in the gutter to set a breakpoint, press F5 to start debugging, and hover over variables to see their current values. It integrates the raw power of LLDB with the convenience of your familiar editing environment. This is where I do most of my interactive debugging because it reduces friction.
// This is a typical launch configuration for VS Code (.vscode/launch.json)
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb", // Uses the LLDB debugger backend
"request": "launch",
"name": "Debug Program",
"program": "${workspaceFolder}/target/debug/${workspaceFolderBasename}",
"args": [], // Command-line arguments go here
"cwd": "${workspaceFolder}",
"sourceLanguages": ["rust"]
}
]
}
Each of these tools gives you a different lens. A debugger shows you the precise state, a profiler shows you where time goes, a memory tracker shows you where resources go, and a tracer shows you the narrative of execution. Starting with a debugger for logic errors and a profiler for speed issues will handle most situations. For the tough problems—the intermittent ones or the mysterious memory leaks—you have specialized tools like rr and Valgrind. The goal isn’t to use them all at once, but to know which one to pick up when you hit a wall. Over time, they become part of your development rhythm, helping you write code that is not only correct but also efficient and understandable.