DSM
70 XP
30 minsIntermediate70 XP

Scope & Closures

Last lesson left a mystery: make_adder(5) returned a function, make_adder finished and its variables should be gone — yet add5(1) still knows n is 5. Where does that 5 LIVE? The answer explains half the 'why does my variable...' questions you'll ever have.

What you'll learn

  • Trace name lookups through the LEGB layers
  • Explain why assignment inside a function creates a LOCAL name
  • Diagnose UnboundLocalError and shadowing bugs
  • Explain what a closure captures and when
  • Use module-level constants safely; avoid the global statement

What

Scope is the region of code where a name is visible. Python resolves every name through four layers — Local, Enclosing, Global, Built-in (LEGB) — and a closure is an inner function that keeps its enclosing layer alive after the outer function returns.

Why

Scope bugs are quiet: a function reads a global you meant to shadow, a loop variable leaks into your closure, an UnboundLocalError appears 'randomly'. Understanding the lookup order turns these from mysteries into thirty-second fixes — and makes factories and (later) decorators fully transparent.

Where it's used

Every function you write, module-level configuration constants, counter/accumulator closures, factory-built validators, and the decorators used across every Python framework.

Where this runs in production

FlaskApp factories and closures

Flask's application-factory pattern returns route functions that close over app configuration — a closure keeping settings alive per app instance.

TensorFlowTraining-step closures

Custom training loops define step functions that close over the model and optimizer — the closure is what lets tf.function trace and compile them.

DatadogMetric-tagging wrappers

Instrumentation helpers build recording functions that close over service and environment tags, so every call site logs consistently without repeating config.

Theory

The core ideas, in plain language.

When Python meets a name, it searches four layers in order and stops at the first hit: Local (the current function), Enclosing (any outer function wrapping this one), Global (the module file), Built-in (print, len, sum...). That's LEGB. Names defined in a deeper layer SHADOW same-named ones above — invisible, not overwritten.
rate = 0.08          # global

def quote(price):
    rate = 0.20      # local — shadows the global
    return price * (1 + rate)

print(quote(100))    # 120.0 — used the local
print(rate)          # 0.08 — global untouched

Two independent names that happen to be spelled the same. The function's assignment created a LOCAL rate; the global never changed. Shadowing is usually harmless — until you THINK you updated the global.

Analogy: The office building directory

Looking up 'Sam' in an office: first your own team room (Local), then the department floor (Enclosing), then the building directory (Global), finally the city phone book (Built-in). You stop at the first Sam you find — and a Sam on your team makes the department's Sam unreachable by that name. Hiring a teammate named 'print' is legal... and now the city's print is shadowed. That's why naming a variable list or sum breaks things.

Key Concept
Assignment decides locality — for the WHOLE function

Python decides at compile time: if a name is ASSIGNED anywhere in a function, that name is local EVERYWHERE in the function. Reading it before the assignment line doesn't fall back to the global — it raises UnboundLocalError. This is the rule behind the most confusing beginner error: adding `count = count + 1` at the bottom of a function suddenly breaks a read of count at the top.

total = 100

def report():
    print(total)      # UnboundLocalError!
    total = 0         # this line makes 'total' local everywhere

# report()  # would crash — comment kept for safety

Remove the assignment and the print happily reads the global. The fix is never sprinkling `global` — it's usually passing total as a PARAMETER and returning the new value. Explicit inputs and outputs beat invisible side doors.

Key Concept
Closures: the enclosing layer survives

When an inner function references a name from its enclosing function, Python attaches that variable to the inner function itself. Return the inner function and the captured variables travel with it — alive after the outer function ended. That attachment is the closure. It's not a copy of the VALUE at definition time; it's a live link to the VARIABLE (which is why each factory call gives a fresh, independent set).

Watch out
Globals: read constants, don't write state

Reading module-level CONSTANTS (TAX_RATE = 0.08) inside functions is normal, idiomatic Python. WRITING globals from inside functions via the `global` statement is the code smell: it creates invisible coupling — callers can't see from the signature that state changes. The professional default: functions take what they need as parameters and return what they produce. `global` appears in real codebases roughly never; treat wanting it as a design alarm.

Visual Learning

See the concept, then explore it.

The LEGB lookup ladder

Resolving `rate` inside an inner function — Python climbs until the first match. Click each layer.

Worked Examples

Watch it built up, one line at a time.

Very EasyLocal names don't leak

Prove a function's variables vanish when it returns.

Step 1 of 2

subtotal exists only while compute runs. After the return, the local layer is discarded.

Code
01def compute():
02 subtotal = 40 + 2
03 return subtotal
04 
05print(compute())
Practice Coding

Your turn — write the code.

Your task

Build make_limiter(max_calls) — a factory returning a function that allows up to max_calls invocations. Each call returns 'ok (n/max)' while allowed, then 'BLOCKED' forever after. Use a closure with nonlocal for the call count, and prove two limiters are independent.

Expected output
ok (1/2)
ok (2/2)
BLOCKED
ok (1/1)

Write your solution in the editor on the right, then hit Run.

Exercises

Prove it. Reach 80% to complete the lesson.

Mastery Gate0% / 80% required
Easy0/2 solved

In what order does Python search scopes for a name?

x = 10 def f(): x = 99 f() print(x) What prints?

Medium0/3 solved

n = 5 def bump(): n = n + 1 return n bump() What happens?

ScenarioA teammate 'fixes' the bug above with: def bump(): global n n = n + 1 It works, but the reviewer objects.

What's the reviewer's strongest argument?

Write make_tagger(tag) returning a function that wraps text as '[tag] text'. Create urgent = make_tagger('URGENT') and info = make_tagger('INFO'); print urgent('disk full') then info('backup done'). Expected: [URGENT] disk full [INFO] backup done

Closure captures tag:The inner function reads tag from the enclosing scope
Independent instances:urgent and info keep separate captured tags
Hard0/2 solved

When does a closure need the nonlocal statement?

ScenarioAfter a refactor, a script starts failing at line 80 with TypeError: 'list' object is not callable. Line 80 is `names = list(raw_names)`. Line 12, added in the refactor, is `list = [1, 2, 3]`.

What happened?

Complete 80% more exercises to unlock.
Interview Prep

How this shows up in real interviews.

Explain the LEGB rule and how shadowing follows from it.

Show model answer

LEGB is Python's name-resolution order: Local (the current function's names, including parameters), Enclosing (any wrapping function's names), Global (module-level names), Built-in (print, len, and friends) — searched in that order, stopping at the first match. Shadowing is the direct consequence: a name defined in an inner layer makes any same-named outer name unreachable by that spelling — not modified, just hidden. Practical corollaries worth naming: a function assigning `rate` never touches a global `rate`; a module variable named `list` silently breaks list() for the whole file because Global is checked before Built-in; and two functions can both use `i` and `total` without collision, which is exactly why local isolation is a feature rather than a limitation.

What causes UnboundLocalError, and what are the idiomatic fixes?

Show model answer

Python decides locality at compile time: if a name is assigned ANYWHERE in a function body — even inside a branch that never executes — that name is local for the entire function. UnboundLocalError fires when a line READS the name before any assignment has run: the lookup doesn't fall back to the global, because the name is already classified local. The classic trigger is adding `x = x + 1` or a conditional assignment to a function that previously just read a global x. Idiomatic fixes, in order of preference: pass the value as a parameter and return the new value (explicit dataflow); for closures that must rebind an enclosing variable, use nonlocal; and `global` as a last resort that usually signals the state deserves a better home — a parameter, a return value, or a class attribute.

What exactly does a closure capture — values or variables — and why does the distinction matter?

Show model answer

Variables, not values. The inner function keeps a live link to the enclosing VARIABLE, so it sees that variable's state at CALL time, not a snapshot from definition time. Two consequences matter. First, factories work correctly: each call to make_adder(n) creates a fresh enclosing scope, so add5 and add9 hold links to different variables and stay independent. Second, the famous loop gotcha: defining several lambdas in a loop that all reference the loop variable gives functions that all see its FINAL value, because they share one variable — the standard fix is a default argument (lambda x, i=i: ...) which really does snapshot the value at definition. Mentioning that closures underlie decorators — the wrapper closes over the wrapped function — is the natural interview capstone.

Common Mistakes to Avoid

1) Expecting a function's assignment to update a global — it creates a shadowing local. 2) Reading a name before its (anywhere-in-function) assignment — UnboundLocalError, not a global fallback. 3) Shadowing built-ins: list, sum, max, type as variable names break later calls. 4) Reaching for `global` when a parameter + return states the dataflow honestly. 5) Forgetting nonlocal when a closure rebinds its captured counter. 6) Loop-created closures all sharing the final loop value — snapshot with a default argument when you mean per-iteration capture.

Ask the AI Tutor

Try these prompts in the AI Tutor panel: • 'Quiz me: for each of these six snippets, which scope does each name resolve to?' • 'Walk me through an UnboundLocalError and its three possible fixes.' • 'Show the loop-variable closure gotcha and the default-argument fix.' • 'When should closure state graduate to a class?' • 'Interview mode: ask me what closures capture and grade my answer.'

Glossary

Scope — the region of code where a name is visible. LEGB — the lookup order: Local, Enclosing, Global, Built-in. Shadowing — an inner name hiding an outer one of the same spelling. UnboundLocalError — reading a name classified local before it's assigned. Closure — an inner function carrying live links to enclosing variables after the outer function returns. nonlocal — statement aiming an assignment at an enclosing variable. global — statement aiming an assignment at a module variable (avoid). Module-level constant — a CAPS-named global meant to be read, not written. Capture — the closure's link to an enclosing variable (variable, not value).

Recommended Resources

• Docs: 'Scopes and Namespaces' in the official Python tutorial — includes the exact LEGB example this lesson builds on. • Read: the Programming FAQ entry 'Why am I getting an UnboundLocalError?' — Python's own answer to its most-asked scope question. • Practice: write make_accumulator(start) returning a function that adds each argument to a running total (you'll need nonlocal), then explain to yourself where the total lives. • Next in DSM: Functions complete! The Data Structures module opens with Tuples — the immutable sequences you've already been returning from functions, made first-class.

Recap

✓ Name lookups climb Local → Enclosing → Global → Built-in and stop at the first hit. ✓ Assignment anywhere in a function makes that name local everywhere in it — the root of UnboundLocalError. ✓ Shadowing hides outer names (including built-ins) — it never modifies them. ✓ Closures capture VARIABLES (live links), which is why factories produce independent functions. ✓ nonlocal rebinding for closure counters; `global` writes are a design alarm. ✓ Read module constants freely; pass state as parameters and return results. Next up: Tuples — the Functions module is complete. Data Structures begins with the immutable sequence you've quietly used in every multi-value return, unpacking, and enumerate() this course.

scratchpad — preview this lesson's challenge anytime
rate_limiter.pyPython
Ready
Output

Run your code to see the output here.