Shadowing
`let x = 1; { let x = 2; }` — the inner `x` hides the outer one for the length of its scope. It is not a special rule; it is what "walk outward and take the first hit" does. Whether it is a feature or a warning depends entirely on which language you are in.
What happens when an inner scope declares a name that already exists outside it?
Two distinct bindings of the same name in two scopes on the same chain. The tree and the symbol table are unchanged in structure — what makes shadowing a topic is that resolution at a given point yields the nearer binding, so the same identifier text denotes different entities at different positions in the same function.
A resolver may treat a shadowing declaration as legal only where the language says the inner construct introduces its own scope; declaring the same name twice in *one* scope is a redeclaration error, and the difference between the two cases is exactly which scope the second declaration lands in. Where a language forbids shadowing (Java, for locals over locals) the resolver must reject rather than shadow, and where a language permits it the resolver must not merge the two bindings — merging them makes the outer binding mutable from the inner scope, which changes the program.
Key points
- Shadowing is what "first hit walking outward" plus "declaration checks only the current scope" produces. It is not a separate rule.
- The outer binding is untouched — not hidden, not modified, still visible outside the inner scope.
- Rust permits shadowing in the *same* scope and allows the type to change, which makes staged transformation of a value idiomatic.
- Java forbids local-over-local and permits local-over-field, which is why
this.x = xis a Java idiom and not a C++ one. - Python decides locality per function, not per position: assigning a name anywhere in a function makes it local everywhere in that function.
- A useful warning distinguishes shadowing by a derived value from shadowing by an unrelated one; a blanket warning gets turned off.
It is not a rule, it is a consequence
There is no shadowing code in a resolver. There is a lookup that walks outward and returns the first hit, and a declaration check that only consults the current scope. Put those two together and shadowing exists; you would have to add a rule to prevent it.
The consequence is worth stating precisely, because the imprecise version causes bugs. The outer binding is not modified, not hidden and not destroyed. It is still there, still holding its value, still visible to any code outside the inner scope — including to code that ran before the inner scope was entered and code that runs after it is left. What changed is which binding the *name* reaches at a particular position.
1let x = 1;2{3 let x = 2; // a new binding, in a new scope4 println!("{x}"); // 2 — lookup stops at the inner scope5}6println!("{x}"); // 1 — the outer binding never changed7 8// Same-scope shadowing, which Rust also allows and most languages do not:9let y = "42";10let y: i32 = y.parse().unwrap(); // a second binding named y, of a new type11let y = y * 2; // and a thirdThe last three lines are the case that divides languages. Rust permits rebinding a name in the same scope with a new type, so a value can be transformed through stages without inventing y_str, y_int, y_doubled. In C, Java, Go or TypeScript the same code is a redeclaration error, and the workaround is either three names or a mutable variable with one type.
Deliberate, and accidental
this.x = x exists as an idiom. And Python's rule is not positional — a name assigned anywhere in a function is local for the entire function body, so reading it before that assignment raises UnboundLocalError even though a global of the same name exists. That is shadowing decided at compile time by the presence of an assignment, not by its position.Deliberate shadowing is a real technique and Rust makes the strongest case for it. Parsing a string into a number, unwrapping an option, converting units — each produces a new value that means the same thing in a new form, and giving it the same name is honest. The alternative is either a mutable variable that can no longer be typed narrowly, or a sequence of names distinguished only by suffix, both of which are worse.
Accidental shadowing is the same mechanism producing a bug. The common shapes are a nested closure declaring a parameter that collides with an enclosing local, a loop variable in a nested loop, and — the worst one — a variable whose name matches a field or a global, so that the code appears to update shared state and updates a local instead. Nothing errors; the outer entity simply never changes, and the symptom is a value that is stubbornly stale.
The most dangerous form is shadowing across a *type* boundary in a language that will implicitly convert. If the inner binding has a different but compatible type, every use still compiles, and the behavior differs only in cases the tests do not cover.
| Language | Inner scope shadows outer local | Same scope, redeclared | Local shadows field / global |
|---|---|---|---|
| Rust | Allowed, idiomatic | Allowed — a new binding, and the type may change | Allowed; clippy::shadow_unrelated is opt-in |
| C / C++ | Allowed | Error — redeclaration | Allowed; -Wshadow warns, off by default |
| Java | Error for local over local | Error | Allowed for fields, and extremely common in constructors |
| Go | Allowed | Error, except that := may redeclare if at least one variable on the left is new | Allowed; go vet -shadow is opt-in and heuristic |
| JavaScript / TypeScript | Allowed for let/const | Error for let/const; var silently merges | Allowed; ESLint no-shadow is opt-in |
| Python | No block scope, so nothing to shadow within a function | Rebinding, not redeclaration — the same local | A local shadows a global for the *whole* function body |
What a compiler should say about it
Because shadowing is both a legitimate technique and a common bug, no language gets to be silent and right. The design space is a spectrum, and every point on it is occupied.
Java forbids local-over-local outright, which eliminates the bug and also eliminates the technique. Rust permits it and treats it as idiomatic, relying on the fact that its bindings are immutable by default so a shadow cannot silently change something. C, Go and JavaScript permit it and offer opt-in warnings, which in practice means most codebases have it on for new code and off for old.
The useful engineering position is that the *warning* should be narrower than the rule. Shadowing a name with a value derived from it — let x = x.trim() — is almost always intentional. Shadowing a name with something unrelated is almost always a mistake. Clippy draws exactly that line with shadow_reuse versus shadow_unrelated, and it is a much better signal than a blanket warning that everyone turns off.
- Shadowing with a value derived from the shadowed one: nearly always deliberate.
- Shadowing with an unrelated value of a different type: nearly always a mistake, and the one worth warning about.
- Shadowing a field or a global from a local: legal almost everywhere and the source of the "why did nothing change" class of bug.
- Shadowing in a closure parameter list: easy to miss because the declaration does not look like one.
- A blanket shadowing warning gets disabled; a targeted one gets fixed.
How it works
The steps, in the order the compiler takes them.
- The inner construct introduces a scope whose parent is the outer one.
- The declaration is inserted into the inner scope; the duplicate check consults only that scope, so it passes.
- A lookup from inside probes the inner scope first, finds the new binding, and stops before reaching the outer one.
- On leaving the scope the inner binding becomes unreachable, and the same name once again resolves outward to the original.
- A linter that wants to report this compares the two bindings — whether the initialiser mentions the shadowed name, whether the types match — rather than merely noting that a name repeats.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A closure parameter shadows an enclosing local, and assignments inside the closure update the parameter instead of the captured variable. The outer value stays at its initial state and the code looks correct line by line.
- A local shadows a field, and a method that appears to mutate object state mutates a stack slot that is discarded on return. The object never changes and no error is produced anywhere.
- An inner declaration shadows an outer one with a *convertible* type, so every use still compiles and only the boundary cases differ. Tests pass; production sees the difference.
- In Python, a name is assigned late in a function and read early. The read raises
UnboundLocalErroreven though a global of that name exists — a runtime failure caused by a compile-time locality decision. - A refactoring extracts a block into a function, and a name that was shadowed inside the block now resolves to the outer binding — or fails to resolve at all. The extraction looks mechanical and changes behavior.
- A shadowing warning is enabled repository-wide, produces four hundred hits in existing code, and is disabled the same day. The class of bug it was meant to catch is now permanently uncovered.
When it helps
- Transforming a value through stages while keeping one honest name — parse, validate, normalise — in a language that allows same-scope rebinding.
- Narrowing a type: shadowing an
Option<T>with the unwrappedT, or astring | nullwith the non-null form, so that the wider type is no longer reachable by accident. - Keeping short-lived names short in small scopes, where inventing distinct names adds noise without adding information.
When it hurts
- Long functions, where the shadowed and shadowing declarations are far apart and a reader cannot see both at once.
- Languages with mutable bindings by default, where a shadow and an assignment look identical at the use site and only one of them affects the outer value.
- Any code that shadows a field or a module-level name, which is where the "nothing changed and nothing errored" bug lives.
What it costs
Every one of these is paid by something.
- Allowing shadowing buys concise staged transformations and costs a class of silent bugs that no type checker will catch, because both programs type-check.
- Forbidding it buys the elimination of that class and costs expressiveness: every intermediate value now needs a distinct name, and the names get worse as there are more of them.
- Warning about it buys detection and costs signal-to-noise — a warning that fires on idiomatic code is a warning that gets suppressed, taking the real hits with it.
- Rust's combination — permit shadowing, but make bindings immutable by default — buys most of the safety without the restriction, and costs a language-wide decision about mutability that not every language can make.
What else you could do
What a different compiler or language does instead, and when that is better.
- Forbid shadowing entirely for locals, as Java does. Simple to explain and to check, and it removes a legitimate technique along with the bug.
- Require an explicit marker to shadow — a keyword or an attribute — so that deliberate shadowing is visible and accidental shadowing is an error. Rare in practice; it costs a keyword to save a lint.
- Rename automatically during resolution, so that every binding has a unique internal name and shadowing simply ceases to exist inside the compiler. This is what alpha-renaming does, and every serious compiler does it internally after resolution; it solves the compiler's problem and not the reader's.
- Eliminate names entirely with de Bruijn indices, where a variable is "two binders out" and shadowing is unrepresentable. Excellent for a core calculus, unusable as a surface syntax.
See it for yourself
The flag, dump or tool that shows you this directly.
- C / C++: compile with
-Wshadowand read what it reports on an existing codebase. The ratio of intentional to accidental hits is the argument about whether the warning should be on. - Rust:
cargo clippy -- -W clippy::shadow_unrelated -W clippy::shadow_reuseshows the two categories separately, which is the distinction that makes the lint usable. - Go:
go vetincludes a shadow analyzer;shadowis also available standalone and its false-positive rate on real code is instructive. - JavaScript: ESLint
no-shadowwithbuiltinGlobalsandhoistoptions — the option list is a good map of the different shapes shadowing takes. - Python:
python -c "import dis; dis.dis(f)"on a function that assigns a global-named variable showsSTORE_FASTrather thanSTORE_GLOBAL, which is the locality decision made visible in the bytecode.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Shadowing overwrites the outer variable." It does not touch it. Two bindings exist; only one is reachable at a given point.
- "Shadowing is always a bug." In Rust it is idiomatic and often the clearest available code. The bug is shadowing with something *unrelated*, which is a narrower claim.
- "If the compiler allows it, it must be safe." Every shadowing bug compiles. That is the entire problem.
- "Python does not have shadowing because it has no block scope." It has function-level shadowing of globals, decided by whether a name is assigned anywhere in the function — a stronger and more surprising rule than block shadowing.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Declare a name inside a block when one already exists outside, and the inner one wins for the length of that block. The outer one is unharmed and comes back the moment the block ends. That is all shadowing is, and it happens because lookup stops at the first binding it finds walking outward.
practical
When a value refuses to change, check whether the name you are assigning to is the name you think it is. The two classic shapes are a closure parameter with the same name as an enclosing local, and a local with the same name as a field. Both compile, both read correctly, and both leave the outer entity untouched. Hovering over the identifier in an editor and reading which declaration it resolves to settles it in a second.
advanced
Compilers stop caring about shadowing almost immediately, because resolution replaces every identifier with a reference to a unique declaration — after that pass, no two distinct entities share a name inside the compiler, and every later phase is shadowing-free by construction. That alpha-renaming is why [[static-single-assignment]] can be built without worrying about names at all, and it is also why compiler-generated temporaries in [[ast-transformations]] must use names no user code could have written: the compiler is inserting bindings into a scope after the point where the user's shadowing has been resolved away, and a collision there would silently rebind something the user is still using.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
:= rule permits partial redeclaration in the same scope when at least one name on the left is new — a rule that produces the well-known bug where an outer error variable is shadowed inside an if and the error is discarded.use imports, by loop variables in comprehensions, and by generic type parameters shadowing outer type names — each of which is the same resolution rule reached by a declaration form that does not look like a declaration.If you were asked this in an interview
- Explain what happens to the outer binding when an inner scope shadows it.
- Why does Rust encourage shadowing while Java forbids it for locals? What else about each language makes that choice reasonable?
- Write a shadowing bug that produces no warning in C and explain what the engineer observes.
- Would you enable a shadowing lint repository-wide? Argue it either way.