Shadowing in Rust and When It Beats Mutation Explained
Shadowing re-binds a name with let, even to a new type, while mut changes a value in place. See when each is the right tool and the one trap that surprises people.

Shadowing is the first feature in Rust that makes people ask whether it is a trick or a hazard. It is both: the same mechanism that lets you write a tidy parse pipeline lets you write a loop that silently does nothing. This post covers the cases where shadowing is the best tool, the one place it quietly goes wrong, and the lint that tells you which you wrote. For the basics next to mut, start with variables and mutability. All programs ran on Rust 1.99, edition 2024.
Refining a value in steps
The best use of shadowing is a value that goes through a sequence of forms, where only the last one is interesting:
use std::io::Read;
fn main() {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
let input = input.trim(); // same name, trimmed
let input: i64 = input.parse().unwrap(); // same name, a number now
let input = input * 2 + 1; // same name, transformed
println!("{input}");
}Given 20 on standard input:
41The same name, input, is a String, then a trimmed &str, then an i64, then a different i64. The earlier forms are noise once the next one exists, and inventing input_str, input_trimmed and input_num adds names that you must not accidentally use later. Shadowing lets the name always mean the best current form. The alternative, which only works for same-typed values, is let mut input and reassignment; the type change in the middle makes that impossible here.
Naming each step can be the clearer choice, though, when later code needs more than one form:
fn main() {
let raw = " 42 ";
let trimmed = raw.trim();
let parsed: u32 = trimmed.parse().unwrap();
let doubled = parsed * 2;
println!("{doubled}");
}84Each name says what the value is, and any line below can refer to raw or trimmed if it needs to. Shadow when the old value is dead; name the steps when it is not.
Scope: shadows end where their block ends
A shadow lasts until the end of the block that contains it, and then the earlier variable is visible again:
fn main() {
let level = 1;
println!("outer {level}");
{
let level = level + 10;
println!("inner {level}");
{
let level = level * 2;
println!("innermost {level}");
}
println!("inner again {level}");
}
println!("outer again {level}");
let total = 100;
for i in 1..=3 {
let total = total + i; // shadows per iteration; the outer total never changes
print!("{total} ");
}
println!("-> {total}");
}outer 1
inner 11
innermost 22
inner again 11
outer again 1
101 102 103 -> 100level is 1 outside, 11 in the inner block, 22 in the innermost, then the values unwind: "inner again" prints 11 and "outer again" prints 1. Nothing was modified. Each let created a different variable and each scope ended in turn. The loop shows the same thing per iteration: let total = total + i gives 101, 102, 103, and the total after the loop is still 100, because each pass created a fresh shadow from the unchanged outer one.
Freezing a mutable value
A second good use is to end mutability. Build a collection, then rebind it immutably:
fn main() {
// Mutable while building, then frozen under the same name.
let mut scores = vec![40, 90, 70];
scores.sort();
scores.push(100);
let scores = scores; // immutable from here on
println!("{scores:?}");
// scores.push(1); // would not compile: cannot borrow as mutable
}[40, 70, 90, 100]After let scores = scores; the vector cannot be changed through that name. The rest of the function can read it and nobody can alter it by accident. If someone later adds a mutation, the compiler refuses:
fn main() {
let mut scores = vec![40, 90, 70];
scores.push(100);
let scores = scores;
scores.push(1);
println!("{scores:?}");
}error[E0596]: cannot borrow `scores` as mutable, as it is not declared as mutable
--> src/main.rs:5:5
|
5 | scores.push(1);
| ^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
4 | let mut scores = scores;This is a cheap, local way to turn a let mut section into a read-only value. It is the shadowing form of the habit of keeping mutable code in the smallest scope possible.
What shadowing cannot do that mut can
Reassignment keeps the variable and the type. Shadowing is how you change the type. Try to do it with mut:
fn main() {
let mut width = "80";
width = width.parse::<u32>().unwrap();
println!("{width}");
}error[E0308]: mismatched types
--> src/main.rs:3:13
|
2 | let mut width = "80";
| ---- expected due to this value
3 | width = width.parse::<u32>().unwrap();
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&str`, found `u32`E0308: the variable was &str, and a u32 will not fit. Replace the second line with let width = width.parse::<u32>().unwrap(); and it compiles. The corollary is also true: shadowing cannot update something that other code already holds. A function that takes &mut and changes a value in place needs mut; shadowing only creates a new binding in the current scope.
Where it goes wrong
The danger is a shadow you meant as an assignment. Here is a retry counter that never counts:
fn main() {
let mut retries = 0;
for _ in 0..3 {
let retries = retries + 1; // meant `retries += 1`
println!("attempt {retries}");
}
println!("retries = {retries}");
}attempt 1
attempt 1
attempt 1
retries = 0Every pass prints attempt 1 and the final value is 0. The line let retries = retries + 1; created a new variable that dies at the end of each iteration; the outer retries was never changed. It compiles, and it does not do what it says. The giveaway is a warning the compiler prints by itself:
warning: variable does not need to be mutable
--> src/main.rs:2:9
|
2 | let mut retries = 0;
| ----^^^^^^^
| |
| help: remove this `mut`
|
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by defaultNothing mutates retries, so the mut is unnecessary, and that is the clue: if you wrote mut because you wanted to change it, and the compiler says you never do, you shadowed. Fix it with retries += 1;. Clippy can also flag the shadow directly with clippy::shadow_reuse, which is off by default:
warning: `retries` is shadowed
--> src/main.rs:4:13
|
4 | let retries = retries + 1; // meant `retries += 1`
| ^^^^^^^
|
note: previous binding is here
--> src/main.rs:2:9
|
2 | let mut retries = 0;
| ^^^^^^^^^^^
= note: requested on the command line with `-W clippy::shadow-reuse`Turn those lints on for a crate where the pattern bites you; they are noisy for code that uses shadowing on purpose.
A practical test for whether a shadow is safe is to ask what the old variable is used for afterwards. In the parse pipeline the answer is nothing, so hiding it costs nothing. In the retry loop the answer is "the next iteration reads it", and a shadow cuts that link. If the variable is read after the block where you shadowed it, or in the next pass of a loop, you want a mutation, not a new binding. Reading the code aloud helps: "set retries to retries plus one" is an assignment, while "let a new retries be the old one plus one, for this pass only" is what the shadow says, and it is rarely the sentence you meant.
Closures and patterns see the old binding
Two more interactions are worth knowing. A closure captures the variable that existed when it was written:
fn main() {
let rate = 2;
let double = |n: i32| n * rate; // captures the first `rate`
let rate = 10; // a new variable; the closure still sees the old one
println!("{} {}", double(5), rate);
}10 10double(5) printed 10, not 50. The closure captured the first rate (2), and the later let rate = 10; is a different variable that the closure never sees. And in patterns, if let Some(name) = name introduces a new name that holds the inner value, while the outer Option is untouched:
fn main() {
let name = Some("ferris");
let greeting = "hello";
if let Some(name) = name { // inner `name` is the &str, not the Option
let greeting = format!("{greeting}, {name}");
println!("{greeting}");
}
println!("{name:?} {greeting}"); // the outer ones are untouched
}hello, ferris
Some("ferris") helloThis is a common Rust idiom: it reuses the natural name for the unwrapped value. It reads well for a couple of lines and gets confusing when the block grows, because the same word means two things.
Rules of thumb
- Shadow for a pipeline of the same value in different forms: parse, trim, convert.
- Shadow with
let x = x;to end mutability. - Do not shadow to change a value inside a loop or across a long function. Use
mutand+=. - If the old value is still needed, give each step its own name.
- When the compiler says a
mutis unnecessary, look for a shadow you meant as an assignment.
When you should avoid it
In a long function, a name that changes meaning three times is harder to read than three names, because the reader has to track which version is in scope on every line. Code that others review is better served by explicit names. And in performance-sensitive code, shadowing is free but can hide a clone: let data = data.clone(); creates a second copy while the first is still alive, which shadowing makes easy to miss.


