Trace separate call frames and return points, follow Python’s name lookup rules, repair shadowing and assignment mistakes, and build small closures with independent state.
You will learn: Follow local frames and return points, apply LEGB lookup, diagnose shadowing and UnboundLocalError, and use nonlocal state deliberately.
Practice in: Google Colab, JupyterLab, or a local editor
Changing message inside add_priority does not overwrite the caller’s local message. Each active call has its own frame: a workspace holding that call’s parameters and local names.
This lesson will help you answer:
Which frame owns a name at a particular moment?
Where does Python look when a name is not local?
Why can an assignment cause UnboundLocalError?
How can a returned function remember state from an enclosing call?
2. A call frame remembers where execution must return
When build_dispatch calls add_priority, the caller pauses. Python retains the caller frame and the location that should receive the returned value.
The active call is on top. Its return moves control and a value back to the suspended caller below it.
flowchart BT
A["add_priority frame<br/>message: bridge closed"] -->|"returns prepared text"| B["build_dispatch frame<br/>destination: North Gate<br/>waiting at prepared assignment"]
B -->|"returns report"| C["top-level caller<br/>waiting at report assignment"]
Trace the active frames:
Moment
Active frame
Important local state
Waiting return point
1
build_dispatch
destination, message
top-level report = ...
2
add_priority
its own message
caller’s prepared = ...
3
build_dispatch
new prepared value
top-level assignment
4
top level
new report value
none
After a function returns, its ordinary local frame is removed. A later call gets a fresh frame.
3. Repeated calls receive independent local state
def count_words(text): count =len(text.split())return countfirst = count_words("red moon")second = count_words("quiet silver river")assert first ==2assert second ==3
The first call’s local text and count do not become inputs to the second. Identical parameter names describe roles within a call, not one shared storage box across all calls.
Recursion creates several frames for the same function at once. Lesson 6 will trace that special case. The rule remains the same: each active call owns its own parameters and locals.
4. Python looks for names through LEGB
When Python evaluates a name, it searches outward:
Local — the current function call;
Enclosing — any active enclosing function scope captured by a nested function;
Global — the module or notebook cell namespace;
Built-in — names such as len, sum, and print.
THEME ="moon"# globaldef make_label(prefix): # prefix belongs to an enclosing function scope separator =": "def label(name): # name is local to this callreturn prefix + separator + name +" / "+ THEME.upper()return labelwarning_label = make_label("Warning")assert warning_label("North Gate") =="Warning: North Gate / MOON"
Inside label, Python finds name locally, prefix and separator in the enclosing scope, THEME globally, and str.upper through the value’s type. A separate built-in example is len(name); len is found in built-ins when no closer scope defines it.
“Search outward” applies to reading a name. Assignment has an additional rule that we will make visible shortly.
5. Constants can be global; changing dependencies are clearer as inputs
A stable configuration constant is readable in uppercase:
signal ="global signal"def inspect_signal(): signal ="local signal"return signalassert inspect_signal() =="local signal"assert signal =="global signal"
This is legal. It becomes confusing when the hidden name is needed. Shadowing a built-in is especially inconvenient:
numbers = [2, 3, 5]sum=10# total = sum(numbers)
If the commented line runs, Python tries to call integer 10, not built-in sum. Rename the local value and remove the shadowing binding:
Other common names to protect include list, str, min, max, and input. The problem is not a special prohibition; it is ordinary nearest-scope lookup finding the wrong object first.
7. Assignment normally makes a name local to the whole function
This code looks as though it reads a global counter and then updates it:
attempts =3def add_attempt():# attempts = attempts + 1return"uncomment the assignment to observe UnboundLocalError"
If attempts = attempts + 1 is uncommented and the function is called, Python raises UnboundLocalError. Because the body assigns to attempts, Python treats that name as local throughout the function. The right side then tries to read the local value before that local value has been assigned.
The order of the lines does not turn the earlier read into a global read. Local scope is determined when the function is compiled.
This works. It also means any call may change state used elsewhere, so the function’s behavior depends on call history. A parameter and return value are usually easier for ordinary calculations. global is not forbidden; it is a strong coupling choice that should be visible in the contract.
9. A closure remembers an enclosing value
A nested function may survive after its enclosing call has returned:
label is returned as a function object. Each returned function retains access to the enclosing prefix created by its own make_channel_label call. That retained relationship is a closure.
Definition and call times matter:
Calling make_channel_label("North") creates prefix and defines label.
Returning label does not call it.
Calling north_label("ready") later creates a new local frame for message.
Name lookup finds remembered prefix in the enclosing scope.
The two closures have independent state because two calls to make_counter created two enclosing count bindings. State persists between calls to one returned closure, but it is not one global counter shared by every instance.
Use closure state when it makes a small behavior cohesive. If callers need to inspect and coordinate many pieces of state, Unit 11’s objects may offer a more explicit design.
11. Closures created in a loop can share the final loop value
Names in closures are looked up when the returned function runs, not copied automatically on every loop iteration:
def make_bad_labels(): labels = []for channel in ["North", "South"]: labels.append(lambda: channel)return labelsbad_labels = make_bad_labels()assert [label() for label in bad_labels] == ["South", "South"]
Both functions close over the same channel binding. After the loop, its value is "South". A beginner-readable repair creates a new enclosing binding through a factory call:
def make_fixed_label(channel):def label():return channelreturn labellabels = []for channel in ["North", "South"]: labels.append(make_fixed_label(channel))assert [label() for label in labels] == ["North", "South"]
You may also encounter a default-argument capture, but the named factory makes the new binding and its timing easier to explain.
12. Lab: trace a dispatch center
Complete the pipeline and counter factory:
def normalize_message(message):"""Return message stripped and converted to sentence case."""raiseNotImplementedErrordef route_message(channel, message, *, prefix="Dispatch"):"""Return one routed message using explicit configuration."""raiseNotImplementedErrordef build_dispatch(channel, message, *, prefix="Dispatch"):"""Normalize and route one message."""raiseNotImplementedErrordef make_dispatch_counter(channel):"""Return a function that records and reports this channel's call count."""raiseNotImplementedError
Before implementing, draw the stack while build_dispatch waits for normalize_message. After it returns, mark which frame owns clean_message.
Hint: distinguish the pipeline’s locals from remembered counter state
build_dispatch keeps clean_message only for that call and passes it to route_message. The factory creates count once; its nested function declares nonlocal count before incrementing it.
Show one complete solution after attempting the lab
def normalize_message(message):"""Return message stripped and converted to sentence case."""return message.strip().capitalize()def route_message(channel, message, *, prefix="Dispatch"):"""Return one routed message using explicit configuration."""returnf"{prefix}{channel}: {message}"def build_dispatch(channel, message, *, prefix="Dispatch"):"""Normalize and route one message.""" clean_message = normalize_message(message)return route_message(channel, clean_message, prefix=prefix)def make_dispatch_counter(channel):"""Return a function that records and reports this channel's call count.""" count =0def record():nonlocal count count +=1returnf"{channel} dispatch {count}"return record