Lexical Scope
A name means whatever the enclosing text says it means. Global contains the function, the function contains the block, the block contains another block, and a lookup walks outward until it finds a binding — which is why you can read a program's meaning off the page without running it.
How does a compiler decide which declaration an identifier refers to?
A tree of scopes running in parallel with the AST: each construct that introduces bindings owns a scope whose parent is the scope of the construct enclosing it. Every identifier occurrence belongs to exactly one scope, determined by where it appears in the text. The structure exists to answer "which binding is visible here", and it can answer it because visibility is a property of position in the tree, not of anything that happens at runtime.
A resolver may assume that the visible bindings at any point are exactly those in the chain from the point's own scope to the root, and that this chain is fully determined by the source text. That assumption is what licenses resolving every name at compile time and never again — and it is exactly the assumption [[static-vs-dynamic-scoping]] shows is false under dynamic scope, where the visible bindings depend on the call stack and cannot be known until the call happens. A compiler that resolves lexically for a dynamically-scoped construct binds the wrong entity, silently.
Key points
- Scopes nest exactly as the text nests, forming a tree parallel to the AST.
- Resolution walks outward from the use to the root and takes the first binding found. That single rule produces shadowing and block-locality for free.
- What counts as a scope is a language decision, and languages disagree about loops, blocks, catch clauses, comprehensions and class bodies.
- JavaScript ships two answers in one language:
varis function-scoped,letis block-scoped with a fresh binding per loop iteration. - Python has no block scope; names assigned in a block are locals of the enclosing function and outlive the block.
- Because the answer does not depend on the call site, closures, rename refactoring and locality-based optimization are all possible.
The scope tree
Scopes nest exactly as the text nests. A module scope contains function scopes; a function scope contains its parameters and its body block; a block contains statements and possibly further blocks. The result is a tree, and it is a different tree from the AST only in that most AST nodes do not introduce a scope.
Resolution is then the simplest possible algorithm: start at the scope containing the identifier and walk toward the root, returning the first binding found. That is it. Everything people find confusing about scope — shadowing, closures capturing the wrong variable, a name that works in one branch and not another — falls out of that single rule plus a question about *where the scope boundary is*.
Read it asTwo lookups from the innermost block tell the whole story. total is found in s4 and the walk stops, so the outer total is invisible here — that is [[shadowing]]. n is found in s1 after four steps, so a parameter is visible everywhere inside its function. Nothing about either lookup depends on what the program does; both are decided by where the text sits.
Where the boundaries are is a language decision
except E as e: binds e for the handler and then deletes it, so e is unbound afterwards even though Python has no block scope; and a Python class body is a scope that methods do *not* nest inside, so a method referring to a class-level name without self or the class name fails at runtime. JavaScript's per-iteration let binding in for is likewise specified, not an optimization.The lookup rule is universal; what counts as a scope is not, and that is where languages actually differ. Whether a for loop's variable is scoped to the loop, whether an if without braces introduces a scope, whether a catch parameter is scoped to the handler, whether a comprehension has its own scope — each is a separate decision and languages disagree on all of them.
JavaScript is the instructive case because it has both. var is function-scoped: a var inside a block is visible throughout the enclosing function, which is why var in a loop and a closure produced one of the most-asked questions in the language's history. let and const are block-scoped and, in a for loop, produce a fresh binding per iteration. The same file can use both, and the difference is invisible in the shape of the code.
Python takes a third position: it has no block scope at all. A name assigned inside an if or a for is a local of the enclosing function, visible after the block ends. That is not an oversight; it makes the common for loop idiom work without nonlocal gymnastics, and it costs the ability to have a genuinely block-local temporary.
| Construct | C / C++ / Java / Rust | JavaScript | Python |
|---|---|---|---|
| Function body | Yes | Yes | Yes |
Any { } block | Yes | Yes for let/const, no for var | No — Python has no block scope |
for loop variable | Scoped to the loop | let: fresh binding per iteration; var: function-scoped | Leaks to the enclosing function and survives the loop |
catch parameter | Scoped to the handler | Scoped to the handler | Deleted at the end of the except block |
| Comprehension | n/a | n/a | Own scope since Python 3 — the loop variable does not leak |
| Class body | Class scope, members visible to methods | Own scope | Own scope, and *not* visible inside methods |
Why lexical scope is worth what it costs
Lexical scoping's payoff is that meaning is local and readable. You can determine what every name in a function refers to by reading outward from it, without knowing anything about who calls the function. That property is what makes almost every static tool possible: renaming a variable is safe because its uses are exactly the ones in its scope subtree; go-to-definition has one answer; an optimizer can prove a local is not aliased.
It also enables closures. If a nested function refers to a name from an enclosing function, the binding it refers to is fixed by the text — so the compiler can determine at compile time exactly which variables must survive past the enclosing function's return, and arrange for them to. That analysis is [[closures]] and [[closure-conversion]], and it is only possible because the answer does not depend on the call site.
What it costs is that names cannot be injected. A caller cannot rebind something inside a callee, which is occasionally exactly what you want — for a logging context, a mock in a test, a temporary configuration override. Languages that keep lexical scope provide those with explicit mechanisms instead, and those mechanisms are dynamically scoped things living inside a lexically scoped language.
- Every use of a local is visible in its scope subtree, which makes rename and find-all-references exact.
- A local whose address never escapes cannot be modified by anything outside, which is what
[[escape-analysis]]and register promotion depend on. - A closure's captured set is computable at compile time, so the runtime representation can be laid out statically.
- Nothing a caller does can change what a name in a callee means — which is the guarantee, and also the limitation.
How it works
The steps, in the order the compiler takes them.
- On entering a construct that introduces bindings, create a scope whose parent is the current scope.
- Insert each declaration into the scope that the language says owns it — parameters into the function scope, block locals into the block scope.
- On meeting an identifier, probe the current scope, then its parent, then upward until a binding is found or the root is exhausted.
- Record the found declaration on the identifier so nothing is resolved twice.
- On leaving the construct, restore the parent as the current scope; whether the scope object survives depends on whether anything will query it later.
- For a nested function referring to an outer binding, mark that binding as captured, since it must now outlive the scope that owns it.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A block scope is not created where the language says one exists, and a variable declared inside an
ifremains visible afterwards. The code compiles, and reads a value that was never assigned on the path taken. - A loop variable is bound once instead of per iteration, and every closure created in the loop captures the same binding. All of them see the final value — the classic result where ten callbacks all print 10.
- Parameters are inserted into the body block rather than the function scope, so a body-level declaration with the same name is accepted as a shadow instead of rejected as a redeclaration. A typo silently becomes a new variable and the parameter is ignored.
- A scope is entered and the matching exit is skipped on an early return inside the resolver. Names leak upward and a later function resolves an identifier to a binding from an unrelated block.
- A class body is treated as an enclosing scope of its methods in a language where it is not. Code that looks obviously correct fails at runtime with a name error, and only in methods.
When it helps
- Reading and reviewing code: the meaning of every name is determined by the text you can see, so nothing needs to be traced through call sites.
- Refactoring tools: rename, extract-function and inline all rely on a use set that is exactly determined by the scope subtree.
- Optimization: a local binding no one else can name is a local no one else can modify, which is the precondition for keeping it in a register.
When it hurts
- Cross-cutting context — request ids, trace spans, locale, a test's temporary configuration — which is genuinely dynamic and has to be threaded explicitly through every call, or smuggled in through a dynamically-scoped side channel.
- Deeply nested code, where a lookup walks a long chain and a reader has to as well. Depth is a readability cost as much as a lookup cost.
- Languages that also allow runtime name creation, where the static scope tree is only part of the story and the compiler must fall back to runtime lookup for the rest.
What it costs
Every one of these is paid by something.
- Lexical scope buys statically decidable meaning — and therefore rename, go-to-definition and closure layout — and costs the ability for a caller to influence a callee's names, which must then be provided by a separate explicit mechanism.
- Fine-grained block scoping buys tight variable lifetimes and fewer accidental captures, and costs a scope object per block, which in a function with many small blocks is a measurable allocation load in the frontend.
- Function-level scoping (Python, JavaScript
var) buys simplicity and fewer surprises about where a loop result went, and costs longer lifetimes, easier accidental reuse and no way to express a genuinely block-local temporary. - Per-iteration loop bindings buy correct closure capture and cost a fresh binding — and, without optimization, a fresh allocation — on every iteration of every loop that captures.
What else you could do
What a different compiler or language does instead, and when that is better.
- Dynamic scope, where a lookup consults the call stack rather than the enclosing text. Emacs Lisp's
defvarvariables, shell variables and Perl'slocalall work this way, and the trade is examined in[[static-vs-dynamic-scoping]]. - No nested scope at all: assembly, and early BASIC, where every name is global. Nothing to resolve, no closures possible, and every name collision is a bug waiting.
- Explicit environment passing, as in a language where the environment is an ordinary value handed to each function. Maximally flexible and maximally verbose; it is what dynamic scope simulates implicitly.
- First-class scopes or modules as values, as in Racket's syntax objects or ML functors, where a scope can be manipulated and instantiated. Powerful, and the resolution rules become substantially harder to explain and to implement.
See it for yourself
The flag, dump or tool that shows you this directly.
- Python:
symtable.symtable(src, "f.py", "exec")lets you walk the scope tree the compiler built and ask each name whether it is local, global or free. - JavaScript: paste a
for (var i...)loop and afor (let i...)loop into a debugger and inspect the closure scope of a callback created in each — the two show visibly different captured bindings. - C:
objdump --dwarf=info a.out | grep -A3 DW_TAG_lexical_blockshows the scope tree the compiler emitted for the debugger, with the address range of each block. - Rust: rust-analyzer's hover on any identifier reports the resolved binding and its declaration site; changing a
letinside a block and hovering outside it shows the scope boundary directly. - Our scope viewer at
/compilers/scopesrenders the scope tree beside the AST and highlights the lookup path for whichever identifier you select.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A scope is a pair of braces." A scope is whatever the language says introduces bindings. In Python braces do not exist and blocks do not scope; in JavaScript the same braces scope
letand notvar. - "Inner scopes can see outer variables, so scoping is about visibility." It is about *resolution order*. Visibility is what falls out: the outer binding is not hidden, it is simply found second.
- "Lexical scope means resolved at compile time." It means resolved by textual nesting. An interpreter for a lexically-scoped language resolves at runtime and still gets lexical answers — the two questions are independent.
- "Closures capture values." They capture *bindings*, which is why a captured loop variable can be observed changing, and why per-iteration binding is a separate language decision from having closures at all.
Misconceptions
The claim, and what is actually true.
let. Assuming otherwise produces code that reads a loop variable after the loop and gets an answer.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A name means whatever the nearest enclosing declaration says it means. Scopes nest like the text nests — global holds the function, the function holds the block, the block holds another block — and looking up a name means walking outward until you find one. Because that walk depends only on where the text sits, you can work out what every name refers to by reading, without running anything.
practical
When a variable holds an unexpected value, ask which binding the name resolves to before asking what was assigned. The two common answers are that an inner declaration shadowed the one you meant, and that the language's scope boundaries are not where you assumed — a loop variable that outlives its loop in Python, a var that is function-scoped in JavaScript, a class-body name invisible in methods. Hover in an editor answers this instantly, and reasoning about it does not.
advanced
The interesting consequence of lexical scope is that free variables are computable. A nested function's free set is the names it uses minus the names it binds, computed bottom-up, and that set is exactly what a closure must capture. Everything about closure representation follows: whether captures are by value or by reference, whether a captured variable must be boxed because it is mutated, whether the closure can be stack-allocated because it does not escape. None of it is decidable under dynamic scope, which is a large part of why languages that wanted closures adopted lexical scope — Scheme's adoption of it in 1975 is the pivot the rest of the industry eventually followed.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
let binding, Python's function-level scoping with comprehensions excepted, and C's block scoping are all mandated. Where implementations do differ is in *diagnostics* about scope — whether shadowing is warned about, whether an unused block-local is reported — and those are not portable.If you were asked this in an interview
- Describe the algorithm that resolves an identifier under lexical scope, in one sentence.
- Ten closures created in a loop all observe the same final value. What does that tell you about the language's scoping rule?
- Name a construct that introduces a scope in one mainstream language and does not in another.
- Why does lexical scope make closures implementable and dynamic scope not?
Connections
- Programming Languages & Runtime Internals — How a captured binding is actually stored — closure objects, upvalues, boxed cells and the environment chain at runtimeLexical scope decides *what* must be captured; the runtime decides where it lives and what it costs to read. This lesson ends at the compile-time analysis.