2.3 Shadowing and scope: why same-named variables can coexist
The previous section established two rules: reassigning an already-initialized variable without let (n = n + 1;) requires mut at declaration time (or fails with E0384) and never allows turning an integer variable into a String (or fails with E0308).
In the program below, however, the variable name n appears three times without a single mut, computes n + 1, and finally turns n into a String—yet the compiler accepts it without complaint:
examples/memory/variables/src/bin/shadow.rs
fn main() { let n = 7; let n = n + 1; println!("{n}"); let n = String::from("eight"); println!("{n}");}Running it prints 8 and then eight. Why do both restrictions from the previous section disappear as soon as we put let in front? More importantly, since reassignment drops the old value in place, does writing a second let also drop the old variable’s heap memory on the spot?
Reassignment vs. shadowing
Section titled “Reassignment vs. shadowing”A single fact explains the difference: every let creates a brand-new variable on the stack.
Trace shadow.rs line by line:
let n = 7;: creates the first variable on the stack (typei32, value7) and binds the namento it.let n = n + 1;: the right-hand side first reads the first variable currently bound ton(getting7) and computes8; theleton the left then creates a second variable on the stack (holding8) and rebinds the namento this second variable.let n = String::from("eight");: theleton the left creates a third variable on the stack. Because it is a brand-new variable, its type is determined independently asString—the 4-byte integer variable from earlier never changed its type at runtime.
In Rust, declaring a new variable with the same name via let—so that subsequent uses of that name resolve to the new variable and can no longer access the old one directly by name—is called shadowing.
Placing reassignment and shadowing side by side makes the boundary clear:
| Dimension | Reassignment (n = n + 1;) |
Shadowing (let n = n + 1;) |
|---|---|---|
| Which variable is operated on | The same existing variable | Creates a brand-new variable and rebinds the name to it |
Does the original variable need mut? |
Yes (otherwise E0384) |
No (the original variable is not being modified) |
| Can the type change? | No; the new value must match the existing variable’s type | Yes; the new variable’s type is inferred independently |
Shadowing inside a block scope
Section titled “Shadowing inside a block scope”Since shadowing rebinds a name within the current scope, what happens when the second let n sits inside an inner {} block?
examples/memory/variables/src/bin/nested-shadow.rs
fn main() { let n = 7; { let n = n + 1; println!("{n}"); } println!("{n}");}Running the program prints 8 and then 7.
A scope determines the region of source code in which a binding is accessible. The inner let n = n + 1; creates a new variable that belongs only to the inner block:
- Inside the inner
{}, the namenresolves to the inner variable (8), temporarily shadowing the outern. - Once execution passes the inner
}, the inner variable goes out of scope and the shadowing ends—the outernwas never modified, so printingnin the outer scope still reads7.
When is a shadowed value freed?
Section titled “When is a shadowed value freed?”Now we can answer the central physical question of this section.
In the previous section’s reassignment target = source;, because the new value overwrote target’s stack slot, the "old" heap buffer previously held by target was dropped on the spot at the = line.
What about shadowing? Suppose we write let s = String::from("hello"); and immediately write let s = 7; in the same scope. Now the original string variable is shadowed and can no longer be accessed through the name s. Is the "hello" heap buffer held by that old string variable dropped right at let s = 7;?
Instead of guessing, we can test it directly with a shared reference (&T): before the first s is shadowed, record its address in a reference r = &s; after let s = 7; shadows s, read through r.
Think about what must happen: if the old String were dropped at let s = 7;, then r would immediately become a dangling reference pointing at freed memory, and the borrow checker would reject the program. Let’s see what the compiler does:
examples/memory/variables/src/bin/shadow-reference.rs
fn main() { let s = String::from("hello"); let r = &s; let s = 7; println!("{r} {s}");}The program compiles cleanly and prints hello 7.
This experiment confirms the physical reality of shadowing on the stack and the heap:
- Shadowing neither invalidates the old variable nor drops the old value in place.
- After
let s = String::from("hello");andlet s = 7;, two distinct variables coexist on the current stack frame: the old variables(String) remains on the stack holding the three-word header for"hello", while the new variables(i32) occupies a fresh 4-byte integer slot. - All
let s = 7;changes is that subsequent occurrences of the namesin source code resolve to the new variable. Because the old variable has neither been invalidated nor left its scope, the referencercreated earlier can still read"hello"through its address; the heap buffer held by the old variable follows the baseline rule from Chapter 1 and is freed only whenmaincloses at}.
(This also illustrates a detail from Section 5 of Chapter 1: the borrow checker computes the valid span of r up to its last use at println!, whereas the old variable frees its heap memory later at the closing }. Because the reference’s use span stays inside the owner’s live span, compilation succeeds.)
When shadowing meets a Move: let s = s;
Section titled “When shadowing meets a Move: let s = s;”What if the right-hand side of a shadowing statement uses the shadowed variable itself? Look at these three lines:
Source:examples/memory/variables/src/bin/shadow-move.rs
fn main() { let s = String::from("hello"); let s = s; println!("{s}");}At let s = s;, shadowing and a Move happen together:
- The
son the right reads the old variable; becauseStringdoes not implementCopy, its control header and cleanup duty move into the new variablescreated byleton the left. - Here the old variable does become invalid—not because the left-hand side wrote a same-named
let, but because the right-hand side performed a Move. Whenmaincloses at}, only the new variablesfrees"hello"once.
Chapter summary and comprehensive review
Section titled “Chapter summary and comprehensive review”Across these three sections, you now have three yardsticks for checking variable validity and drop timing on any line of Rust code that uses let or =:
| Syntax form | Underlying action | Effect on the old variable and heap memory |
|---|---|---|
Initial binding (let b = a;) |
Creates a new variable to take an initial value | Bitwise copy if Copy; Move if not Copy (old variable invalidated, duty transferred); borrow if prefixed with & (duty stays put) |
Reassignment (x = new_val;) |
Updates the same existing variable (requires let mut, cannot change type) |
If x holds an active Move value, drops the old value at the = line before taking the new one; if already moved out or uninitialized, takes the new value directly |
Shadowing (repeating let x = ...;) |
Creates a new variable with the same name and rebinds subsequent uses of the name (no mut needed, can change type) |
Does not drop the old variable in place; old and new variables coexist on the stack frame, and the old value is freed at its scope exit } (unless moved by the right-hand side) |
Comprehensive review
Section titled “Comprehensive review”Before running the program below, trace it line by line using the three yardsticks above:
Source:examples/memory/variables/src/bin/review.rs
fn main() { let mut s = String::from("first"); let t = s; s = String::from("second"); println!("{s}"); { let r = &t; let s = 7; println!("{s} {r}"); } s = t; let s = s; println!("{s}");}Answer three review questions:
- What do the three
println!calls print? - Is the heap string
"second"freed when the inner block closes (}), or dropped in place whens = t;runs afterward? - When
mainfinishes and exits at}, which variable is responsible for freeing"first"?
Click to check the line-by-line trace
- Printed output:
second, then7 first, thenfirst. - First four statements in outer scope (Move and refilling a vacated variable):
let mut s = String::from("first");: outersholds"first".let t = s;: moves"first"intot;tis now responsible for freeing"first", and outersbecomes vacated (moved-from).s = String::from("second");: assigns a new value into the vacated outers(Case 2: old value already moved out, no drop triggered); outersbecomes valid again and takes cleanup duty for"second". The firstprintln!printssecond.
- Inner
{}block (borrowing and shadowing):let r = &t;: borrowst(which holds"first").let s = 7;: creates a brand-new integer variablesinside the inner block, shadowing the outer string variables. The secondprintln!prints the inners(7) and the referencer(first).- Inner
}closes: only the inner integersand the referencerexit scope. Because the outer string variablesbelongs to the outer scope,"second"is not freed at the inner}.
- Final three statements in outer scope (in-place drop and same-name Move):
s = t;: outersis currently holding the active value"second"(Case 1: reassigning an active value). Therefore,"second"is dropped in place right ats = t;, while"first"moves out oftinto outers(tbecomes invalid).let s = s;: creates a new variableswith the same name and moves"first"from the old outersinto this news.- At the closing
}ofmain, the new variablescreated bylet s = s;frees"first"; bothtand the old outerswere already moved from and do nothing.
Hands-on modification
Section titled “Hands-on modification”After checking your trace, make one small edit in review.rs: inside the inner block, change let s = 7; to s = String::from("third"); (without let), leaving every other line unchanged.
Think first, then run: after removing let, which variable does that line operate on? How does the drop timing of "second" change? And which string gets dropped in place at s = t; later?
Answer: removing let turns the inner line from shadowing into a reassignment on the outer mutable variable s. As a result, "second" held by outer s is dropped in place right inside the inner block, and outer s takes "third" (so the inner println! prints third first); when s = t; runs afterward, the string replaced and dropped in place is now "third".
Moving on to Part 2: Types
Section titled “Moving on to Part 2: Types”Across the two chapters of Part 1, we derived the ownership, borrowing, and lifetime model from physical memory and verified variable declarations, reassignment, and shadowing in real Rust code.
Along the way, a deeper thread has surfaced:
- Why does the exact same syntax
let b = a;behave so differently depending solely on whether the right-hand side isi32,String, or&String? - Why does adding
mutto a variable declaration unlock mutation without ever allowing the variable’s declared type to change? And how—as we asked in the previous section—can the compiler tell at the call sites.push_str("!")that the method mutates memory without inspecting the function body? - How do we express the first open problem left at the end of Chapter 1: mutating data through a reference while keeping read and write access from interfering?
Every one of those questions points to the same core question: what is a “type” in Rust? In Rust, a type is never just a label stating how many bytes a value occupies—it is a compile-time set of rules bundling memory representation together with usage constraints (who may mutate it, who is responsible for freeing it, and over what span it remains valid).