1.2.30 · D3Introduction to Programming (Python)

Worked examples — Variable scope — LEGB rule (Local, Enclosing, Global, Built-in)

2,418 words11 min readBack to topic

Before we begin, one shared mental picture. Every "scope" is just a labelled box holding names. When Python reads a name, it walks these boxes in a fixed order and grabs the first box that has the name.

Figure — Variable scope — LEGB rule (Local, Enclosing, Global, Built-in)
  • L = the box created the moment a function starts running.
  • E = the box of a function that wraps the current one.
  • G = the box for the whole file (the module).
  • B = a permanent box Python ships with (len, print, range, sum, …).

Keep this picture open. Every example below is just "which box wins, and why."


The scenario matrix

Variable scope has a small number of axes that fully determine what happens. Every real program is a combination of these cells.

Axis The cases we must cover
Operation pure read · write with no keyword · write with global · write with nonlocal
Which box wins a read found in L · found in E · found in G · found in B · found nowhere (NameError)
The assignment trap read-before-write of a name that assignment made local (UnboundLocalError)
Shadowing a name in L/E/G hides a built-in (B)
Degenerate / zero cases function that assigns nothing · empty enclosing scope · nonlocal with no target · deleting a name
Mutation vs rebinding changing a list's contents (no scope keyword needed) vs pointing the name at a new list (needs global/nonlocal)
Real-world twist a counter shared across calls (closure)
Exam twist loop/if blocks do not make scope; comprehensions do

Below, each example is tagged with the cell(s) it covers. By the end, every cell above is ticked.


Example 1 — Read resolves in each box in turn

Steps

  1. Run inner(). Python needs to read tag for print. Why this step? print(tag) is a read, so the L→E→G→B walk begins.
  2. Look in L (inner's box). tag = "L-inner" is there → stop. Why this step? First match wins; Python never opens E or G.
  3. Result: "L-inner".

Now delete tag = "L-inner" from inner. The read walks past the empty L box into E and finds "E-outer". Delete that too, and it walks into G and finds "G-file".

Verify: with all three lines present → L-inner. With L removed → E-outer. With L and E removed → G-file.

Figure — Variable scope — LEGB rule (Local, Enclosing, Global, Built-in)

Example 2 — Falling all the way to Built-in

Steps

  1. Inside count_chars, read len. It's not in L (only word is), not in E (no enclosing function), not in G (file defines no len). Why this step? We must exhaust L, E, G before B — that is the whole order.
  2. Found in Blen is Python's built-in string length. len("hi") = 2. Why this step? This is why len, print, range work without importing: they live permanently in B.
  3. Now print(banana). Read banana: not in L, E, G, or B. Why this step? When all four boxes fail, there is nothing left to try.
  4. Python raises NameError: name 'banana' is not defined.

Verify: len("hi") = 2. banana is undefined everywhere → NameError.


Example 3 — The assignment trap (UnboundLocalError)

Steps

  1. At compile time (before the function ever runs), Python scans add_tip and sees total = …. Why this step? Any bare assignment to a name flags it Local for the entire function body — decided once, up front, not line by line.
  2. So total is a local name throughout add_tip. The global total = 100 is now invisible inside. Why this step? The local-creation rule overrides the L→E→G walk for that specific name.
  3. Execute total + 10. To compute the right-hand side we must read local total — but it has no value yet (the assignment hasn't finished). Why this step? The read happens before the write completes, and there is no other total to fall back to.
  4. Python raises UnboundLocalError: local variable 'total' referenced before assignment.
Figure — Variable scope — LEGB rule (Local, Enclosing, Global, Built-in)

Verify: calling add_tip() raises UnboundLocalError, never returns 110.


Example 4 — Fixing it with global

Steps

  1. global total tells Python: "for this function, total is the G name — do not make a local." Why this step? It cancels the compile-time local-creation rule from Example 3.
  2. Now total + 10 reads the global 100110, and the assignment rebinds the global to 110. Why this step? With the local rule gone, both read and write point at the same G box.
  3. add_tip() returns 110; afterwards the module-level total is also 110.

Verify: add_tip() = 110, and the global total = 110 afterward.


Example 5 — Fixing enclosing state with nonlocal

Steps

  1. make_counter() runs, creates n = 0 in its box, defines step, returns step (call it c). Why this step? Even after make_counter finishes, its n box survives because step still points at it — that's a closure.
  2. First c(): nonlocal n aims the name at the nearest enclosing box (E), not L, not G. Why this step? Without nonlocal, n += 1 would try to make a fresh local n and hit UnboundLocalError exactly like Example 3.
  3. n += 1 reads E's 0, writes back 1, returns 1. Second call → 2. Third → 3.

Verify: c(), c(), c() yields (1, 2, 3).


Example 6 — Shadowing a built-in

Steps

  1. Line 1: read sum → not in G yet → found in B (the built-in) → sum([1,2,3]) = 6. Why this step? Before shadowing, B is where sum lives.
  2. sum = 10 puts a name sum into the G box. Why this step? Now the L→E→G→B walk finds sum in G before reaching B — the built-in is hidden, not deleted.
  3. sum + 5 reads G's sum = 1015.
  4. sum([1,2,3]) reads G's sum = 10, then tries to call 10(...). Why this step? An integer isn't callable → TypeError.

Verify: original sum([1,2,3]) = 6; after shadowing, sum + 5 = 15; sum([1,2,3]) raises TypeError.


Example 7 — Mutation needs no keyword

Steps

  1. add_book: shelf.append(3) is a read of shelf (to find the list), then a method call that mutates it in place. Why this step? There is no assignment shelf = … here, so the local-creation rule never fires. The read walks L→E→G, finds the list, and .append edits that very object → [1, 2, 3]. See Mutable vs Immutable arguments.
  2. replace_shelf: shelf = [9, 9] is an assignment → shelf is local to that function. Why this step? It builds a brand-new list in the local box and throws it away when the function ends; the global list is untouched.
  3. Final global shelf = [1, 2, 3].

Verify: after both calls, shelf = [1, 2, 3].


Example 8 — Degenerate nonlocal with no target

Steps

  1. nonlocal m demands a variable m in an enclosing function's box. Why this step? nonlocal only reuses an existing E name; it cannot create one.
  2. outer has no m anywhere in its body → there is no E target. Why this step? This is the empty-enclosing-scope degenerate case.
  3. Python rejects this at compile time: SyntaxError: no binding for nonlocal 'm' found.

Verify: the code is a SyntaxError (compile-time), not NameError/UnboundLocalError.


Example 9 — Exam twist: blocks don't scope, comprehensions do

Steps

  1. if/for blocks create no new box in Python. msg lives in report's single local box. Why this step? Unlike C or Java, Python scopes only by function, module, and comprehension — not by {}/indentation blocks.
  2. report(True): the if runs, msg = "yes" fills the local box → prints yes.
  3. report(False): the if is skipped, so msg was never assigned → reading it → UnboundLocalError. Why this step? Same trap as Example 3: assignment marks it local, but this run never executed the write.
  4. The comprehension [k*k for k in range(4)] gives [0, 1, 4, 9], and its loop variable k does get its own box. Why this step? Comprehensions are one of the three scope-makers, so k does not leak into the module.
  5. print(k) at module level → NameError, because k never existed in G.

Verify: report(True)yes; report(False)UnboundLocalError; [k*k for k in range(4)] = [0,1,4,9]; module-level print(k)NameError.



Active Recall

Recall Self-test on the scenarios
  1. In Example 3, at what moment does Python decide total is local — compile time or run time?
  2. Why does shelf.append(3) need no global, but shelf = [9,9] would?
  3. What error class does a nonlocal with no enclosing target raise, and when?
  4. Why does report(False) crash while report(True) is fine?
  5. After sum = 10, why does sum([1,2,3]) give TypeError and not NameError?

Connections

  • Parent topic (Hinglish) — the core LEGB rule these examples exercise.
  • Functions and Parameters — each call is what creates the L box.
  • Closures and Nested Functions — Example 5's counter is a live closure over E.
  • Namespaces and the dict model — every box here is really a dictionary.
  • Mutable vs Immutable arguments — Example 7 is the mutate-vs-rebind distinction.
  • NameError and UnboundLocalError — the two crashes catalogued above.
  • Modules and import — populates the G box that Examples 4, 6, 7 read from.