Lesson 10 / 16
Scope Rules
Name lookup runs through four levels, and what decides which level it stops at is not where the name is read but where it is assigned: adding a single assignment line to a body turns the same read from a valid value into UnboundLocalError.
Contents
The previous lesson measured how arguments bind to parameters. Every bound argument establishes a name in the body; but the body does not only use those names. Names it builds itself, ones coming from the enclosing definition, ones sitting at the outermost level of the file, and ones the language itself supplies are there too — and all of them are read with the same notation, a bare name.
The Programming Fundamentals course’s scope lesson built this search order as a language-independent concept: start local, go to enclosing then global, end with built-ins, first match wins. That lesson defined scope through visibility, lifetime through duration, and explained shadowing. Here the same order is measured as a search chain, focused on one question: what decides which level a name is searched at? The answer runs against intuition — not where the name is read, but whether it is assigned somewhere in the body.
Four Levels
In Python, name lookup runs through four levels, and their order is fixed:
- Local — the function body currently executing.
- Enclosing — the function bodies surrounding it, inside out.
- Global — the file’s outermost level.
- Built-in — names the language itself supplies.
The lookup stops at the first place it finds the name and never looks at the levels
below. If none of the four have it, the result is not a value but NameError.
The fourth level differs from the others: the program does not write its names, the language does. But its place in the search order is not special — because it is last, any of the three levels above defining the same name shadows the built-in and makes it unreachable in that body.
What Makes a Name Local
This is the real rule, and it concerns the entire body. Python decides which level a name in a body belongs to while reading the body, not while running it. The rule is one sentence: if the name is assigned to anywhere in the body, that name is local for the entire body.
“Anywhere” has to be taken literally. Even if the assignment is on the body’s very
last line, the name is local from the body’s very first line onward. This is why a
read done before the assignment does not find the outer value — it finds a local
name that has not yet received a value, and the result is an exception:
UnboundLocalError.
This is the measurable form of shadowing. The Programming Fundamentals course defined shadowing as “the outer name becomes unreachable”; the measurement here says where that unreachability starts — not from the assignment line, from the body’s first line.
Writing Outward
A direct consequence of the rule: assigning to an outer name is impossible with bare notation. Assignment always establishes a local name; it does not change the outer one, it shadows it.
The language therefore recognizes two declarations. global declares that the name
belongs to the global level and routes assignments in that body there. nonlocal
declares that the name belongs to an enclosing body and routes assignments there;
it does not look at the global level, and errors if it cannot find one.
Reading needs no declaration; a declaration only changes the writing direction, since the rule governs writing, not reading. The asymmetry is deliberate: reading an outer value changes it for no one, but writing to it leaves a result visible outside the body. Requiring a declaration keeps that result from being born by accident — every body writing outward announces it in a single line.
The measurement’s assumptions:
- CF24 — The oracle is a separate string bound at each level naming that level; the returned value directly states which level the lookup stopped at.
- CF25 — The same name
BOUNDARYis measured in four states, and the states differ only in which levels define that name; the reading line in the bodies is character-for-character identical. - CF26 — The built-in level is measured with a separate name, because the
program cannot write the names at that level. The language-supplied name
minis used for the measurement and is shadowed at the levels above it. - CF27 — The assignment rule is measured with three bodies: one that only reads, one that reads and then assigns, one that only assigns. The reading line is identical in all three; the only thing that changes is whether the body contains an assignment, and where.
- CF28 — The writing direction is measured in three states: undeclared, with a
nonlocaldeclaration, and with aglobaldeclaration. Because the global-level name changes at the end of the measurement, that line is run last. - CF29 — Exceptions are written only by class name, their messages are not written.
- CF30 — In the closure measurement, three functions are produced in a loop and called after the loop ends. Leaving the call for after the loop is deliberate; what is measured is when the name resolves, not which value it carries.
Measurement
"""Four scope levels: at which level is a name found, where does assignment move it.""" BOUNDARY = "global" def measure(action): try: return action() except Exception as e: return f"!{type(e).__name__}" def none_defined(): return UNDEFINED_NAME def only_global(): return BOUNDARY def enclosing_too(): BOUNDARY = "enclosing" def inner(): return BOUNDARY return inner() def local_too(): BOUNDARY = "enclosing" def inner(): BOUNDARY = "local" return BOUNDARY return inner() SEARCH = ( ("no level", none_defined, "-"), ("only global", only_global, "global"), ("global + enclosing", enclosing_too, "global, enclosing"), ("global + enclosing + local", local_too, "global, enclosing, local"), ) print(f"{'levels where the name is defined':<30s} {'found':<26s} result") for name, f, defined in SEARCH: print(f" {name:<28s} {defined:<26s} {measure(f)}") def uses_builtin(): return min([3, 1, 2]) def enclosing_shadows_builtin(): min = "enclosing shadowed" def inner(): return min return inner() def local_shadows_builtin(): def inner(): min = "local shadowed" return min return inner() print() print(f"{'builtin name min':<32s} result") for name, f in (("no level defines it", uses_builtin), ("enclosing level defines it", enclosing_shadows_builtin), ("local level defines it", local_shadows_builtin)): print(f" {name:<30s} {measure(f)}") def only_reads(): return BOUNDARY def reads_then_assigns(): before = BOUNDARY BOUNDARY = "local" return before def only_assigns(): BOUNDARY = "local" return BOUNDARY print() print(f"{'what is done with BOUNDARY in body':<34s} {'reading in body':<24s} result") for name, f, reading in (("only read", only_reads, "none before assignment"), ("read, then assigned", reads_then_assigns, "before assignment"), ("only assigned", only_assigns, "after assignment")): print(f" {name:<32s} {reading:<24s} {measure(f)}") def declares_global(): global BOUNDARY BOUNDARY = "written from inside" def declares_nonlocal(): BOUNDARY = "enclosing" def inner(): nonlocal BOUNDARY BOUNDARY = "written from inside" inner() return BOUNDARY def writes_undeclared(): BOUNDARY = "enclosing" def inner(): BOUNDARY = "written from inside" inner() return BOUNDARY print() print(f"{'writing outward from body':<28s} {'target level':<13s} outer name's value after") for name, f, target in (("no declaration", writes_undeclared, "enclosing"), ("nonlocal declaration", declares_nonlocal, "enclosing")): print(f" {name:<26s} {target:<13s} {measure(f)}") before = BOUNDARY declares_global() print(f" {'global declaration':<26s} {'global':<13s} {before} -> {BOUNDARY}") def late_bound(): functions = [] for i in range(3): def produce(): return i functions.append(produce) return [f() for f in functions] def captured_by_default(): functions = [] for i in range(3): def produce(i=i): return i functions.append(produce) return [f() for f in functions] def name_after_loop(): for i in range(3): pass return i print() print(f"{'three functions produced in a loop':<34s} returned when called") print(f" {'bare name in body':<32s} {late_bound()}") print(f" {'name captured by default':<32s} {captured_by_default()}") print(f" {'name after loop ends':<32s} {name_after_loop()}")
levels where the name is defined found result no level - !NameError only global global global global + enclosing global, enclosing enclosing global + enclosing + local global, enclosing, local local builtin name min result no level defines it 1 enclosing level defines it enclosing shadowed local level defines it local shadowed what is done with BOUNDARY in body reading in body result only read none before assignment global read, then assigned before assignment !UnboundLocalError only assigned after assignment local writing outward from body target level outer name's value after no declaration enclosing enclosing nonlocal declaration enclosing written from inside global declaration global global -> written from inside three functions produced in a loop returned when called bare name in body [2, 2, 2] name captured by default [0, 1, 2] name after loop ends 2
The Chain’s Four Rungs
The top table builds the search chain rung by rung. The reading line of the four rows is character-for-character identical; the only thing that changes is which levels define that name.
When no level defines it, the result is NameError. In the second row the name
exists only at the global level, and that is what is found. In the third row the
enclosing level also defines the same name, and enclosing wins — the global-level
name is still sitting there, but never even enters the search. In the fourth, the
local level is added too, and this time local wins.
The pattern: every new level shuts out everything outside it. The search runs inside out, not outside in, and stops at the first match. When three levels define the same name, they do not compete — the innermost never even tests the other two.
The middle table adds the fourth level. In the first row, no level defines min, and
the search descends to the built-in level and finds the language-supplied name; the
result is 1, the smallest of the three-item list. In the next two rows the same
name is defined first at the enclosing, then the local level, and both shadow the
built-in. The built-in level has no privilege in search order; being last makes it
the easiest to shadow.
The practical consequence: giving a built-in’s name to a local variable makes that built-in unusable in that body. No error, no warning; the name now points to something else.
A Single Assignment Line
The third table is the lesson’s main measurement, and its three rows again have the same reading line.
First row: the body only reads the name BOUNDARY, never assigns to it anywhere.
The search chain runs, finds it at the global level, result global.
Second row: the body does the same read first, then makes an assignment to the
name. The reading line comes before the assignment and is character-for-character
identical to the one in the first row. But the result is no longer a value — it is
UnboundLocalError.
The only difference is that an assignment exists somewhere further down in the body. That assignment makes the name local for the entire body; by the time the reading line is reached, the search never goes out to the global level at all, it stops at the local level and finds a name there that has not received a value yet. The chain does not break — it is never built at all.
The order relationship here is critical: what decides a name is local is not execution order, it is a decision made by looking at the whole body. This is why the error-producing line does not contain the cause; the cause is the assignment line below it. The third row completes this — when the body only assigns and then reads, there is no problem, the result is local.
Three Ways to Write
The bottom table measures the writing direction. In the first row the inner body
makes an undeclared assignment, and the outer name does not change: the result
is still enclosing. The assignment did not update the outer one, it established a
new name belonging to the inner body, and that name vanished once the body ended. The
line written looks like an update; what it does is a shadowing.
In the second row the same body writes with a nonlocal declaration, and the outer
name does change. In the third row a global declaration changes the
global-level name; the value that was global before the measurement becomes
written from inside afterward.
What the three rows say: writing outward from a body needs a declaration, not
bare assignment. The declaration adds no new capability; it only states which level
the assignment targets. With no declaration, the target is always local — the other
face of the same rule as the previous table’s UnboundLocalError: one shows up in
reading, the other in writing.
This distinction also explains the difference between mutating an outer object and rebinding an outer name. The shared bucket in the previous lesson grew with no declaration at all, because what happened there was not assignment to a name, it was mutating the object the name pointed to. Scope rules govern the binding of names, not the content of objects.
When a Name Resolves
The last table measures when the chain runs, and gathers everything up to here into a single result. Three functions are produced in a loop; each reads the loop variable in its body with a bare name. All three are called after the loop ends.
The expected result is [0, 1, 2], the measured result is [2, 2, 2]. All three
functions return the same value.
The reason is the scope rule itself. The name in the functions’ bodies does not resolve when the function is produced; it resolves when it is called. What gets written into the body during production is not a value but a name — and that name points to a single variable in the enclosing body. The three functions do not carry three separate values, they carry the same name. Called, the search chain runs, finds the name at the enclosing level, and finds there the last value the loop left behind.
The third row shows what that last value is: the name is still alive after the loop ends, and its value is 2. In Python a loop does not open its own scope; the loop variable belongs to the enclosing body and does not vanish when the loop ends. The Programming Fundamentals course said the loop variable is local, and that is true — the measurement narrows it: the unit it is local to is not the loop, it is the entire function body. In languages where a brace- or keyword-closed block opens its own scope, this line would raise an error; here it gives a value.
The second row gives the resolution, and the resolution is the previous lesson’s
rule. When the name is written as a default value, the result becomes [0, 1, 2]. A default expression is evaluated once while the definition is read;
because the definition sits inside the loop body here, it reruns on every turn and
captures that turn’s value. Rather than being left to the search chain, the value
gets copied at definition time.
The two lessons’ rules meet here: scope says where a name will be searched for, while a default’s evaluation timing changes when it will be searched for. The same three-line loop gives two different results with a single change to a signature.
Summary
- Name lookup runs through four levels in a fixed order — local, enclosing, global,
built-in — stopping at the first match, and gives
NameErrorif none of the four have it. - Every level shuts out everything outside it: if the same name is defined at three levels, the innermost wins and the others never enter the search.
- The built-in level has no privilege in search order; being last, the same name defined at any level above makes the built-in unreachable in that body.
- What decides a name is local is not where it is read, but that it is assigned to
somewhere in the body; even if the assignment is last, the name is local from
the body’s start, and a read before the assignment produces
UnboundLocalError. - Writing outward from a body needs a declaration:
nonlocalroutes the assignment to the enclosing level,globalto the global level. An undeclared assignment does not update the outer name, it establishes a new local one that shadows it. - A name in a body resolves not when the function is produced but when it is
called: three functions produced in a loop share the same name, so all three
return
[2, 2, 2]; capturing the name as a default value makes the result[0, 1, 2].
Next Step
Up to here, the names at the search chain’s last rung only ever showed up as something to be shadowed. Yet the names sitting at that level are the language’s most heavily used functions, and each one connects directly to the protocols the first two lessons measured: one asks for length, one converts to text, one requests an iterator, one tests truthiness, one sorts. The next lesson measures these built-ins by their contracts — which special method each one calls, and what happens when that method is not defined. What emerges is that built-ins are not functions doing the work themselves, but thin shells calling a protocol.
To keep your progress and take notes, Log in
My notes
Log in to take notes.