When mutation gets in the way
You're parsing a configuration value. You read a raw string from a file. You trim whitespace. You parse it into an integer. You clamp the result to a valid range. You have four distinct values in your mental model, but they all occupy the same logical slot. In Python, you'd just reassign the variable. In Rust, you could mark it mutable and mutate it in place. Or you could use shadowing to create a fresh variable at each step, keeping the name but locking the value once the transformation is done.
Shadowing is Rust's way of giving you the flexibility of mutation while preserving the safety of immutability. It lets you refine data through a pipeline where each step produces a new, immutable binding. You get the clean name reuse without the risk of accidental mutation.
Shadowing is rebinding, not mutation
Shadowing creates a new variable that hides the old one. It is not mutation. Mutation changes the value inside the same memory slot. Shadowing puts a new slot in front of the old one and drops the old slot.
Think of a sticky note on a whiteboard. You write x = 5 and stick it up. Later, you write x = 10 on a fresh note and stick it directly over the first one. The first note is still there, just covered. The name x now points to the new note. When the new note falls off, the old one reappears.
In Rust code, the "peeling" happens when the new variable goes out of scope, but usually, you just keep stacking notes until the function ends. The critical difference is that the old note is dropped as soon as the new one covers it. The memory is reclaimed. The old value is gone.
Shadowing is rebinding, not mutation. The old value is gone, not hidden.
The minimal pattern
The syntax is simple. You use let again with the same name.
fn main() {
// Bind 'x' to the integer 5.
let x = 5;
// Shadow 'x' with a new value.
// The old 'x' is dropped immediately.
// The new 'x' is a separate binding.
let x = x + 1;
// Shadow again.
// This creates a third binding named 'x'.
let x = x * 2;
println!("The value of x is: {x}");
}
The output is 12. Each let creates a new variable. The name x is rebound. The previous value is used in the expression, then discarded.
What happens under the hood
When you write let x = 5, Rust binds the name x to the value 5. When you write let x = x + 1, the compiler performs a sequence of operations. It reads the current binding of x, which is 5. It computes 5 + 1 to get 6. It creates a new binding for x that holds 6. The old binding is removed from the scope.
Because the old binding is gone, the value 5 is dropped. If 5 were a complex type with a destructor, the destructor would run right here. The name x now points to 6. You cannot access the 5 anymore. It is not hidden behind a curtain. It is destroyed. The new x is a completely independent variable that happens to share the name.
This behavior enables a powerful optimization. Shadowing frees memory the moment you no longer need the old value. You don't have to wait for the end of the scope. The drop happens exactly when the shadow occurs.
Changing types and freeing memory
The real power of shadowing appears when you change types. You cannot mutate a String into a usize. You can shadow it. This pattern is idiomatic for parsing pipelines.
fn parse_config(raw: &str) -> usize {
/// Trim whitespace and parse the input into a clamped integer.
/// Each step shadows the previous binding to lock the result.
let value = raw;
// Shadow with a trimmed slice.
// The original &str is dropped.
// We only need the clean text now.
let value = value.trim();
// Shadow with a parsed integer.
// The &str is gone. 'value' is now a usize.
// This type change is impossible with let mut.
let value = value.parse::<usize>().expect("Invalid number");
// Shadow with a clamped result.
// The usize is moved into clamp and the result shadows it.
let value = value.clamp(1, 100);
value
}
Shadowing also enables early drops. If you hold a large allocation and only need a small derived value, shadowing reclaims the heap immediately.
fn process_data() -> usize {
// Allocate a large vector on the heap.
let data = vec![0; 1_000_000];
// Shadow 'data' with its length.
// The vector is dropped here.
// The heap memory is freed before we return.
let data = data.len();
data
}
If you used let mut, you'd have to introduce a new variable name to drop the vector, or keep the vector alive longer than necessary. Shadowing bridges types and manages memory in one stroke.
Type changes are the killer feature. Shadowing bridges types where mutation hits a wall.
Shadowing in match arms
Shadowing works inside match arms. You can extract a value and give it the same name as the outer variable. This is common when unwrapping options or results.
fn extract(x: Option<i32>) -> i32 {
/// Extract the inner value or return a default.
/// The match arm shadows 'x' with the contained integer.
match x {
// Shadow the outer 'x' with the inner value.
// The Option is consumed. 'x' is now the i32.
Some(x) => x,
None => 0,
}
}
The pattern Some(x) creates a new binding x that shadows the outer x. The outer x is moved into the match and dropped. The inner x holds the extracted value. This keeps the name consistent across the transformation. It signals that the inner value is the refined version of the outer one.
Pitfalls and compiler errors
Shadowing drops the old binding. If you have a reference to the old binding, shadowing breaks it. The compiler enforces this strictly.
fn broken() {
let x = String::from("hello");
let r = &x; // Borrow x.
// This shadows x.
// The old x is dropped because it's shadowed.
// But r still points to the old x.
// The compiler rejects this.
let x = x + " world";
}
The compiler rejects this with E0505 (cannot move out of x because it is borrowed). Shadowing counts as dropping the old variable. You cannot drop something while a reference points to it. You must drop the reference first, or avoid shadowing.
Another pitfall is shadowing without using the original value. If you write let x = 5; let x = 10;, the first x is never read. The compiler warns with E0601 (unused variable). It assumes you created a value and immediately buried it by mistake.
Clippy adds a lint called shadow_unrelated that triggers on this pattern. It keeps your shadowing intentional. If you want to discard a value, use let _x = 5; or simply don't bind it. Community convention treats shadowing as a transformation pipeline. It's idiomatic to see let s = s.trim(); let s = s.parse();. This signals "I'm refining this value." Don't shadow just to save typing. If the name becomes misleading, stop. let data = ...; let data = data.len(); is fine. let data = ...; let data = user_id; is confusing. The name should still describe the value.
Shadowing drops the old binding. If a reference exists, the compiler stops you. Respect the drop.
Decision: when to use shadowing
Use shadowing when you transform a value step-by-step and want to lock each intermediate result. Use shadowing when you need to change the type of a variable. let mut cannot change types. Use shadowing when you want to free memory early by dropping a large allocation and keeping only a small derived value. Use let mut when you need to mutate the value in place without allocating a new binding, such as in a loop that updates a counter. Use a new variable name when the new value has a distinct meaning that doesn't justify reusing the old name. Clarity beats brevity.
Pick the tool that matches the intent. Transformation gets shadowing. Accumulation gets mut.