Debugging Rust in VS Code With Codelldb Step by Step
Debug Rust in VS Code with the CodeLLDB extension: install, a launch.json, breakpoints, conditional breakpoints, watch expressions, debugging tests and common problems.

Stepping through Rust code in VS Code takes two extensions and, for most sessions, no configuration at all. When you need arguments, environment variables, a test, or a specific binary, a short launch.json with a cargo block does the rest. This guide builds that setup around a small crate with a library, a binary and two unit tests, and explains what each field does and why it is written that way. Before the details, one honest limit: VS Code is not installed on the machine this guide was prepared on, and the macOS debugger there is switched off (Developer Mode is disabled, and lldb stalled the moment it was asked to run a program). So I did not drive the editor or capture a debugging session. What I did run is everything the extension asks cargo to do: the build, the JSON that tells CodeLLDB which executable to launch, the test binary and its filters, and the program and tests themselves. Where a statement depends on the editor's behaviour, it follows the extension's documented configuration and is marked as such. Versions: rustc 1.99.0, rust-analyzer and CodeLLDB as current in VS Code, on an Apple M1 Max.
What you are installing
Two extensions do different jobs, and confusing them is the root of most setup trouble. rust-analyzer is the language server: it understands your code, shows errors as you type, and adds a Run | Debug link above fn main and above each #[test]. CodeLLDB (published by vadimcn) is the debug adapter: it bundles the LLDB debugger, translates VS Code's debugging requests into LLDB commands, and renders Rust types in the variables panel. rust-analyzer builds the thing to run and CodeLLDB runs it under a debugger. On Windows with the MSVC toolchain the C/C++ extension's cppvsdbg debugger is the alternative, because MSVC produces PDB files that LLDB reads less well; with the GNU toolchain use gdb.
The crate we will debug
The subject is a small signed-integer parser in a library, with a command-line binary that sums its arguments. It is deliberately small enough to hold in your head and long enough to need a breakpoint inside a loop:
/// Parses a signed decimal integer, allowing a leading + or -.
pub fn parse_signed(input: &str) -> Result<i64, String> {
let trimmed = input.trim();
let (sign, digits) = match trimmed.strip_prefix('-') {
Some(rest) => (-1, rest),
None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)),
};
if digits.is_empty() {
return Err("no digits".to_string());
}
let mut value: i64 = 0;
for ch in digits.chars() {
let d = ch.to_digit(10).ok_or_else(|| format!("bad digit {ch:?}"))? as i64;
value = value * 10 + d;
}
Ok(sign * value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_negative() {
assert_eq!(parse_signed("-42"), Ok(-42));
}
#[test]
fn rejects_garbage() {
assert!(parse_signed("4x").is_err());
}
}use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let mut total = 0;
for arg in &args {
match app::parse_signed(arg) {
Ok(n) => total += n,
Err(e) => eprintln!("skipping {arg:?}: {e}"),
}
}
println!("total = {total}");
}Running it with four arguments, one of them bad, is the reference behaviour that every debugging session below should agree with:
$ cargo run -q -- 10 -3 +5 x7
skipping "x7": bad digit 'x'
total = 12That is 10 minus 3 plus 5, with x7 skipped. The test suite is equally quick: cargo test reports two tests in the library passing. Always confirm this baseline in a terminal before you open a debugger. If the program is wrong from the command line, a debugger is not yet the question.
The zero-configuration path
Open src/main.rs. Above fn main rust-analyzer draws Run | Debug, and above each #[test] it draws the same pair. Click a gutter to set a breakpoint on a line, then click Debug. rust-analyzer builds with the dev profile, which keeps debug information, and hands the executable to CodeLLDB. For a surprising number of sessions that is enough: stop in parse_signed at the line inside the loop, look at value and d in the Variables panel, and step. The lens passes no arguments and no environment, so it runs the binary with an empty command line; the program above would print total = 0. As soon as you want 10 -3 +5 x7 as input, you need a launch configuration.
A launch.json that does not hard-code a path
Create .vscode/launch.json. The two configurations below debug the binary with arguments and debug one unit test. I checked that the file is valid JSON by parsing it, and I ran the cargo commands inside each cargo block, but I did not load it into VS Code:
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug app",
"cargo": {
"args": ["build", "--bin=app", "--package=app"],
"filter": { "name": "app", "kind": "bin" }
},
"args": ["10", "-3", "+5", "x7"],
"env": { "RUST_BACKTRACE": "1" },
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests",
"cargo": {
"args": ["test", "--no-run", "--lib", "--package=app"],
"filter": { "name": "app", "kind": "lib" }
},
"args": ["tests::parses_negative", "--exact", "--nocapture"],
"cwd": "${workspaceFolder}"
}
]
}The shape of a configuration is the same for any program. "type": "lldb" selects CodeLLDB, "request": "launch" starts a new process (the alternative, attach, connects to one that is already running), and name is the label in the Run and Debug dropdown. The interesting field is cargo.
Why the cargo block exists
You could write "program": "${workspaceFolder}/target/debug/app" and it would work for the binary, until the day you add a feature flag, switch profile or move to a workspace. For tests it does not work at all, as the next check shows. A cargo block tells CodeLLDB to run cargo itself, read the compiler's machine-readable messages, and pick the executable that matches a filter. You can reproduce exactly what it sees by asking cargo for JSON and extracting the executable. For the binary:
cargo build --message-format=json | python3 -c "
import sys, json
for line in sys.stdin:
m = json.loads(line)
if m.get('reason') == 'compiler-artifact' and m.get('executable'):
print(m['target']['name'], m['target']['kind'], m['executable'])"app ['bin'] target/debug/appThat is one line: a target named app, of kind bin, built to target/debug/app. The filter in the launch configuration, { "name": "app", "kind": "bin" }, is the instruction to choose that line. A package with several binaries, or a binary plus a library plus an example, produces several artifact lines, and the filter is how you say which one. Now the test side, with cargo test --no-run --lib, which builds the library's test harness without running it:
app ['lib'] target/debug/deps/app-582f27387be0f6f4The executable is app-582f27387be0f6f4, and the sixteen hex digits are a hash of the build's inputs. They change when features, dependencies or the compiler change, which is why no one can put that path in a configuration by hand. The filter { "name": "app", "kind": "lib" } picks it out every time. If your tests live in the binary crate, use --bin app and "kind": "bin"; integration tests in the tests/ directory are separate targets, selected with --test name and "kind": "test".
Arguments to the test harness
A test executable is an ordinary program with its own command-line interface, so the configuration's args field becomes the test harness's arguments. The three used above matter. A bare name is a substring filter. --exact turns it into a whole-name match. --nocapture lets println! output through, which the harness otherwise swallows for tests that pass. A mistake here is quiet, and I made it while writing this guide: my first configuration read "args": ["parses_negative", "--exact", "--nocapture"], and running the test binary with those arguments printed this:
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s"Zero tests ran, all passed" would look like success in an editor that only reports the exit status, and the debugger would simply never stop at your breakpoint. The cause is that with --exact the name must be the full path, tests::parses_negative, including the module. With that, the same run reports running 1 test and 1 passed; 0 failed; ... 1 filtered out, and the committed configuration uses the full path. The general lesson is to read the count in "running N tests" whenever a breakpoint in a test does not hit. If N is zero, the debugger did nothing wrong.
Other targets: integration tests and examples
The same filter mechanism covers every kind of target, and the output of cargo shows why the filter is not optional. I added an integration test in tests/cli.rs and an example in examples/demo.rs, then asked cargo for the machine-readable artifacts of each:
cargo test --no-run --test cli
app ['bin'] app
cli ['test'] cli-698bb790f1...
cargo build --example demo
demo ['example'] debug/examples/demoBuilding the integration test also produced the app binary, because integration tests are allowed to run it, so cargo reported two executables. Without a filter, CodeLLDB would have had two candidates and refused to guess. With { "name": "cli", "kind": "test" } it takes the second. For an example the arguments are build --example demo and the filter is { "name": "demo", "kind": "example" }. The pattern is always the same three steps: ask cargo to build the target, read the artifact it names, and filter by that name and kind. If a configuration ever launches the wrong program, run its cargo command with --message-format=json in a terminal and count the executables; the answer is on the screen.
Breakpoints that save time
A plain breakpoint in a loop that runs a million times is a way to spend an afternoon. CodeLLDB supports the variants that avoid it. These are the editor's documented features and were not exercised in a session here:
- Conditional breakpoint. Right-click the gutter dot, choose Edit Breakpoint, and enter an expression such as
d == 7. The program stops only when it is true. Inparse_signedthis lets you stop on the digit 7 of a long input without stepping over the others. - Hit count. Stop on the Nth time the line runs, for a failure that appears only late in a loop.
- Logpoint. Print a message such as
value={value} d={d}when the line runs and keep going. It isprintln!debugging without editing the file or rebuilding, and the record cannot be forgotten in a commit. - Function breakpoint. Break by name, for instance on
rust_panic, to stop at the point any panic begins, whichever line caused it.
The last is the one to remember for the common complaint "it crashed and I do not know where": set it once and run. The call stack panel then shows your function a few frames below the panic machinery, as the backtrace in a terminal would, with every local variable one click away.
Looking at values
When the program stops, the Variables panel shows locals, and CodeLLDB renders common Rust types readably: a Vec as a list of elements, a String as text, an Option as Some or None. The Debug Console accepts expressions, which are evaluated by LLDB and not by Rust, so the syntax is a close relative. value * 10 + d works. Method calls generally do not, because the debugger cannot run arbitrary Rust. Add a variable to the Watch panel to keep an eye on it across steps. Two traps follow from that. First, a variable can be optimised out if the build is optimised, which is the single most common reason a Rust debugging session looks broken; the dev profile does not optimise, and a release build does. Second, a variable that has not yet been assigned shows whatever was in memory. Read the value only after the line that sets it has run.
Stepping, and the standard library
Step Over runs a line without entering calls, Step Into enters the call, and Step Out finishes the current function. In Rust, Step Into often lands in places you did not write: an iterator adaptor, Option::ok_or_else, or the closure inside it. If you land in the standard library without source, install it with rustup component add rust-src; the debugger then shows real code instead of disassembly. For long chains it is faster to set a breakpoint on the line after the chain than to step through it, and to put a breakpoint inside a closure body, which is a normal line the gutter accepts.
Making a debug build easier to debug
The dev profile is unoptimised, which is right for stepping and wrong when the program is too slow to reach the interesting state. A middle path is to optimise your dependencies but not your own code, so the debugger still follows your lines exactly while the libraries run fast. This is standard cargo configuration:
[profile.dev.package."*"]
opt-level = 2
[profile.dev]
debug = truepackage."*" applies to dependencies only, not the crate you are working on. When a bug appears only in release, build a profile that optimises and keeps symbols, and debug that, accepting that variables will sometimes be optimised out and lines will step out of order:
[profile.release]
debug = "line-tables-only"I built the sample crate with each of the debug-info settings (none, line-tables-only, limited, full) and all four compiled and linked. The binaries were 492,656 bytes with none and about 557 KB with any of the others. That size comparison is not very informative on macOS, where the debug information stays in the object files and is found through a map rather than packed into the executable, so do not use it to judge what a release profile will cost on Linux, where the same setting grows the file noticeably.
When it does not work
- The debugger starts and nothing stops. Check the test count (see above), check that the build is the dev profile, and check that you set the breakpoint on a line that has code. A gutter dot on a blank line or on a
}may be greyed out. - Breakpoints show as unverified. The source on disk no longer matches the build, or the binary is a release build without line tables. Rebuild and set
debugas above. - It hangs the moment you press run, on macOS. This is what happened on the machine used for this guide:
lldbset a breakpoint onrust_panicand then never returned fromrun.DevToolsSecurity -statusreported "Developer mode is currently disabled". Enabling it needs administrator rights (sudo DevToolsSecurity -enable, or the Developer Mode toggle in Privacy and Security settings), and I did not change a system setting for a guide. If your first debug launch stalls, check this before anything else. - Variables read as
<optimized out>. Optimisation removed them. Loweropt-levelfor the crate under debug, as in the first profile above. - The wrong binary starts. In a workspace, the
--packageand--binarguments in thecargoblock must name the right crate, and the filter must match it.
A routine that wastes the least time
A debugging session goes faster when it starts from a failing case you can name. Reproduce the problem in a terminal with a command or a test, shrink the input until it is a single line like x7 in the sample, put that input in the launch configuration's args, and only then set the first breakpoint. Put the breakpoint where the wrong value is first used, not where the symptom shows, and walk backwards toward where it was produced. When you find the cause, close the loop by turning the input into a unit test, so the configuration you wrote for the session becomes a permanent, one-click way to rerun that exact case.
Where this stops
- The editor side is unverified. The program, the tests, the cargo JSON and the test filters in this guide were run. The extension's UI, the breakpoint dialogs and the Variables panel rendering were not, because VS Code is not installed on this machine. Treat those descriptions as the extension's documentation, not as observation.
- A debugger is slow for some bugs. A panic with a clear message and a backtrace, or a failing unit test with an assertion diff, is quicker to read than to step through. Use the debugger for state you cannot see, not for errors the compiler can already name.
- Async code steps strangely. A task can resume on a different thread, and stepping over an
.awaitmay not return where you expect. Logpoints andtracingspans are more reliable there. - Remote and container targets need extra setup. Debugging inside a container or on another host uses a different request type and a matching debug server, which is beyond this guide.
The reproducible core is small. cargo build --message-format=json names the binary to launch, cargo test --no-run --lib --message-format=json names the hash-suffixed test executable, and the harness arguments pick the test, but only if --exact gets the full tests::parses_negative path. Get those three right in a terminal first, and the editor is only a way to put a breakpoint in front of them.


