How to Debug Rust in VS Code

Install the Rust extension, enable debug symbols in Cargo.toml, and configure launch.json to debug Rust code in VS Code.

When println! isn't enough

You are staring at a panic message. thread 'main' panicked at 'index out of bounds'. You added println! everywhere. You ran the code. You got the output. You still have no idea why the index went out of bounds. The println! approach works for simple logic, but it falls apart when the state changes rapidly or when the bug only happens once in a thousand runs. You need to pause execution, inspect variables, and step through the logic line by line. That is what a debugger does.

Stop printing. Start stepping.

The debugger needs a map

Debugging connects your editor to a debugger engine. In Rust, the engine is usually lldb or gdb. VS Code talks to that engine via the Debug Adapter Protocol. The engine talks to your running program. When you hit a breakpoint, the engine pauses the program. VS Code shows you the stack trace and variable values.

The key piece is debug symbols. Without them, the debugger sees machine code, not your Rust source. The compiler generates a map that links machine instructions back to source lines and variable names. This map lives in the binary or in separate .dSYM files on macOS. You need to tell Cargo to generate these symbols.

# Cargo.toml
[profile.dev]
debug = true // Generates DWARF debug symbols. Essential for breakpoints and variable inspection.

The debug = true setting is the default for the dev profile, but explicit configuration prevents accidents. If you see debug = 0 or opt-level = 3 in your dev profile, you have broken debugging. The compiler will strip the map and optimize variables away. Your breakpoints will vanish.

Debug symbols are the map. Without them, you are navigating blind.

Setting up the launch configuration

VS Code needs a configuration to know how to start your program and attach the debugger. Create a .vscode/launch.json file in your project root. The Rust extension provides snippets, but writing the config by hand clarifies what is happening.

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Binary",
      "type": "lldb",
      "request": "launch",
      "cargo": {
        "args": ["run", "--bin", "my_app"]
      },
      "cwd": "${workspaceFolder}"
    }
  ]
}

The type field selects the debugger backend. lldb works on macOS and Linux. On Windows, the Rust extension usually installs codelldb automatically, which is a standalone LLDB implementation that avoids GDB dependency issues. If you are on Windows and lldb fails, switch the type to codelldb.

The cargo.args array tells Cargo what to run. run builds and executes. --bin specifies the binary if you have multiple. The cwd sets the working directory so relative paths in your code resolve correctly.

Save the file. Hit F5. The debugger is waiting.

Stepping through real code

Consider a function that processes a list and filters invalid items. You suspect the filtering logic is dropping data it should keep.

/// Processes items and filters invalid ones.
fn process_items(items: Vec<String>) -> Vec<String> {
    let mut result = Vec::new();
    for item in items {
        let trimmed = item.trim().to_string();
        // Bug hypothesis: trim might be removing valid characters?
        if trimmed.len() > 0 {
            result.push(trimmed);
        }
    }
    result
}

fn main() {
    let data = vec![" hello ".to_string(), "".to_string(), " world ".to_string()];
    let processed = process_items(data);
    println!("{:?}", processed);
}

Set a breakpoint on the line let trimmed = item.trim().to_string();. Click the gutter to the left of the line number. A red dot appears. Press F5.

The program builds and starts. Execution pauses at the breakpoint. The debugger highlights the current line. The Variables view in the sidebar shows items, result, and item. You can expand items to see the vector contents. You can inspect item to see the current string.

Click "Step Over" to execute the current line and move to the next. The trimmed variable appears in the Variables view. Check its value. If item was " hello ", trimmed should be "hello". Step over again to the if check. Inspect trimmed.len(). Step over the if. If the condition is true, result updates.

This loop repeats. You can watch result grow. If you see an item disappear, you have found the bug. The debugger lets you verify state at every step without guessing.

Step over skips the function. Step into enters it. Choose carefully.

The debugger UI and controls

The debug toolbar appears at the top of VS Code. It contains five main controls.

  • Continue: Resumes execution until the next breakpoint or program end.
  • Step Over: Executes the current line. If the line calls a function, the function runs to completion, and execution pauses on the next line.
  • Step Into: Executes the current line. If the line calls a function, execution pauses on the first line inside that function.
  • Step Out: Executes the rest of the current function and pauses when control returns to the caller.
  • Restart: Stops the program, rebuilds if necessary, and starts debugging again.

The sidebar has three panes. Variables shows local and global variables. Watch lets you add expressions to monitor. Type result.len() in the Watch pane to track the vector size. Call Stack shows the function call hierarchy. Click a frame to jump to that location in the code. This is invaluable when a panic happens deep inside a library call.

Conditional breakpoints save time when the bug only occurs at a specific iteration. Right-click a breakpoint and select "Edit Breakpoint". Add a condition like i == 42. The debugger will only pause when i equals 42. This avoids stepping through hundreds of irrelevant iterations.

Pitfalls that break debugging

Debugging in Rust has a few traps. The compiler and tooling can hide information if you are not careful.

Optimizations are the biggest enemy. If you set opt-level = 1 or higher in your dev profile, the compiler reorders code, inlines functions, and eliminates variables. Breakpoints may jump to unexpected lines. Variables may show "optimized away" in the debugger. The code you see in the editor no longer matches the execution flow. Keep opt-level = 0 and debug = true for debugging. You can verify your profile settings by running cargo build -v and checking the compiler flags.

Another issue is the wrong binary. If your project has multiple binaries and you run cargo run without --bin, Cargo picks the default. Your debugger might attach to the wrong program. Always specify --bin in launch.json or set the default in Cargo.toml.

On Windows, GDB used to be the standard. Modern Rust toolchains prefer LLDB. If you see errors about missing libpython or GDB path issues, switch to codelldb. The Rust extension handles the download. You just need to update the type in launch.json.

If breakpoints vanish, check your profile. Optimizations hide bugs by hiding variables.

Choosing your debugging strategy

Different problems require different tools. Pick the approach that matches the situation.

Use VS Code with lldb when you are on macOS or Linux and want the standard debugger experience with full variable inspection and stack traces.

Use VS Code with codelldb when you are on Windows and need a reliable debugger without installing full GDB toolchains or dealing with Python dependency errors.

Use the dbg! macro when you need a quick value check and do not want to configure a launch file. dbg!(expression) prints the file, line, and value, then returns the value. It is faster than println! for one-off checks.

Use cargo expand when the bug is inside a macro and you need to see the generated code. The debugger shows the expanded code, but cargo expand lets you inspect it statically.

Use conditional breakpoints when the bug only occurs at a specific iteration or state. Stepping manually through thousands of loops is inefficient.

Pick the tool that matches your OS and your patience level.

Where to go next