Debugging

#211 Jun 2026

211. Iterator::inspect — Debug a Lazy Chain Without Tearing It Apart

When a map/filter chain gives the wrong answer, the reflex is to rip it into a for loop just to drop in a println!. You don’t have to — inspect lets you peek at every value as it flows past.

inspect runs a closure on each item and then hands the same item downstream, untouched. It’s a tap on the pipe: perfect for seeing what survives each stage without restructuring anything.

Say this sum is wrong and you want to know which numbers actually make it through the filter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let nums = [1, 2, 3, 4, 5, 6];

let sum: i32 = nums
    .iter()
    .filter(|&&n| n % 2 == 0)
    .inspect(|n| println!("kept: {n}"))
    .map(|&n| n * 10)
    .inspect(|n| println!("scaled: {n}"))
    .sum();

assert_eq!(sum, 120); // (2 + 4 + 6) * 10

You can drop a tap between any two adapters, as many as you like, and the result is identical to the chain without them. Because iterators are lazy, the closures fire only as items are pulled — so the prints interleave with the real work instead of dumping everything up front.

The key property is that inspect is transparent: it borrows each item (&T) and returns the iterator unchanged, so types and values flow through exactly as before:

1
2
3
4
5
6
let evens: Vec<i32> = (1..=6)
    .inspect(|n| eprintln!("saw: {n}"))
    .filter(|n| n % 2 == 0)
    .collect();

assert_eq!(evens, [2, 4, 6]);

Reach for eprintln! inside the closure so debug output goes to stderr and stays out of any data you’re printing to stdout. And when you’re done, deleting the tap is a one-line change — not an unwind of the loop you’d otherwise have written.

96. Result::inspect_err — Log Errors Without Breaking the ? Chain

You want to log an error but still bubble it up with ?. The usual trick is .map_err with a closure that sneaks in a eprintln! and returns the error unchanged. Result::inspect_err does that for you — and reads like what you meant.

The problem: logging mid-chain

You’re happily propagating errors with ?, but somewhere in the pipeline you want to peek at the failure — log it, bump a metric, attach context — without otherwise touching the value:

1
2
3
4
5
6
7
8
9
fn load_config(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

fn run(path: &str) -> Result<String, std::io::Error> {
    // We want to log the error here, but still return it.
    let cfg = load_config(path)?;
    Ok(cfg)
}

Adding a println! means breaking the chain, introducing a match, or writing a no-op map_err:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# fn load_config(path: &str) -> Result<String, std::io::Error> {
#     std::fs::read_to_string(path)
# }
fn run(path: &str) -> Result<String, std::io::Error> {
    let cfg = load_config(path).map_err(|e| {
        eprintln!("failed to read {}: {}", path, e);
        e  // have to return the error unchanged — easy to mess up
    })?;
    Ok(cfg)
}

That closure always has to end with e. Miss it and you’re silently changing the error type — or worse, swallowing it.

The fix: inspect_err

Stabilized in Rust 1.76, Result::inspect_err runs a closure on the error by reference and passes the Result through untouched:

1
2
3
4
5
6
7
8
# fn load_config(path: &str) -> Result<String, std::io::Error> {
#     std::fs::read_to_string(path)
# }
fn run(path: &str) -> Result<String, std::io::Error> {
    let cfg = load_config(path)
        .inspect_err(|e| eprintln!("failed to read config: {e}"))?;
    Ok(cfg)
}

The closure takes &E, so there’s nothing to return and nothing to get wrong. The value flows straight through to ?.

The Ok side too

There’s a mirror image for the happy path: Result::inspect. Same idea — &T in, nothing out, value preserved:

1
2
3
4
5
6
let parsed: Result<u16, _> = "8080"
    .parse::<u16>()
    .inspect(|port| println!("parsed port: {port}"))
    .inspect_err(|e| eprintln!("parse failed: {e}"));

assert_eq!(parsed, Ok(8080));

Both methods exist on Option too (Option::inspect) — handy when you want to trace a Some without consuming it.

Why it’s nicer than map_err

map_err is for transforming errors. inspect_err is for observing them. Using the right tool means:

  • No accidental error swallowing — the closure can’t return the wrong type.
  • The intent is obvious at a glance: this is a side effect, not a transformation.
  • It composes cleanly with ?, and_then, and the rest of the Result toolbox.
1
2
3
4
5
6
7
# fn load_config(path: &str) -> Result<String, std::io::Error> {
#     std::fs::read_to_string(path)
# }
// Chain several observations together without ceremony.
let _ = load_config("/does/not/exist")
    .inspect(|cfg| println!("loaded {} bytes", cfg.len()))
    .inspect_err(|e| eprintln!("load failed: {e}"));

Reach for inspect_err any time you’d otherwise write map_err(|e| { log(&e); e }) — you’ll have one less footgun and one less line.

#030 Mar 2026

30. dbg! macro

Still using println! for quick debugging? Try dbg! instead — it prints the expression, its value, and the file/line number to stderr. And it returns the value, so you can wrap it around anything.

1
2
3
4
5
6
let a = 2;
let b = dbg!(a * 2) + 1; // prints: [src/main.rs:3] a * 2 = 4
assert_eq!(b, 5);

// works with multiple values too
dbg!(a, b, a + b); // prints each as a tuple

Unlike println!, dbg! takes ownership (or copies for Copy types). If you need to keep the value, pass a reference:

1
2
3
let name = String::from("Ferris");
dbg!(&name); // borrows, doesn't move
println!("{name}"); // still works!

Bonus: dbg! works the same in release builds, and outputs to stderr so it won’t pollute your stdout.