Lesson 14: Scope, Closures & Decorators
LEGB scoping rules, closures that remember, and decorators that wrap functions with extra behavior.
Scope — Where Names Live
Python resolves names with the LEGB rule. When code refers to a name, Python searches in this order:
- Local — inside the current function
- Enclosing — in outer (nested) functions
- Global — at module top level
- Built-in — Python's built-in names like
print,len,sum
The first match wins. This is why you can have a local variable named len shadowing the built-in (don't do that).
Mental model: scopes are nested rooms. Python looks in your room first (local), then the room you're inside (enclosing), then the house (global), then the city (built-ins). First room with the name wins.
What You'll Learn in This Lesson
- Apply the LEGB scoping rules
- Modify outer variables with
globalandnonlocal - Build closures — functions that remember
- Write decorators that wrap functions with extra behavior
global and nonlocal
globallets a function modify a module-level variable.nonlocallets a nested function modify an enclosing function's variable.
count = 0
def increment_global():
global count
count += 1
increment_global()
print(count) # 1
Tip: in modern code,
globalis rare — passing values in/out via parameters and returns is cleaner. Butnonlocalis essential for closures (next).
Why do you even need global? Without it, writing count += 1 inside a function would create a new local variable called count (and crash with UnboundLocalError, since you're reading it before assignment). global tells Python "no, I mean the module-level one".
Closures — Functions That Remember
A nested function that references variables from its enclosing scope is a closure. It "remembers" those values even after the outer function returns:
def make_counter():
count = 0
def increment():
nonlocal count # modify the enclosing variable
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
The inner function increment carries a "backpack" containing count — it survives because increment still references it. This powers counters, factories, and much of functional programming in Python.
Mental model: a closure is a function with its own luggage — wherever it goes, its remembered variables come along.
A second closure example — factory functions:
def make_multiplier(n):
def multiply(x):
return x * n
return multiply
times_3 = make_multiplier(3)
times_5 = make_multiplier(5)
print(times_3(10)) # 30
print(times_5(10)) # 50
Each closure (times_3, times_5) remembers its own n — independent backpacks.
Decorators — Wrap & Extend
A decorator is a function that takes another function and returns a new one with extra behavior:
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timer
def slow_task():
return sum(range(100000))
The @timer syntax is just sugar for:
slow_task = timer(slow_task)
Think of a decorator as armor around a sword: the sword (original function) still works, but now it's protected (or timed, or logged).
Mental model: the decorator is a wrapping station. Your function goes in, gets wrapped in extra behavior, and the wrapped version comes out under the same name.
Classic Decorator Uses
| Use case | What it adds |
|---|---|
| Timing | Measures execution time |
| Logging | Prints when a function is called |
| Access control | Checks permissions before running |
| Retry logic | Retries on failure |
| Caching | Stores results of expensive calls |
The @wraps Gotcha
Without care, a decorator hides the original function's name and docstring:
from functools import wraps
def timer(func):
@wraps(func) # copies __name__, __doc__, etc.
def wrapper(*args, **kwargs):
...
return wrapper
Always use @wraps(func) in real decorators — otherwise help() and debugging tools show the wrapper instead of your function.
Why it matters: without @wraps, slow_task.__name__ becomes "wrapper" instead of "slow_task" — breaking debuggers, documentation, and anything that inspects function metadata. @wraps copies the identity over.
Real-World Decorators You Already Use
If you have used Flask or FastAPI, you have seen decorators in action:
from flask import Flask
app = Flask(__name__)
@app.route("/") # a decorator!
def home():
return "Hello"
The machinery you learned here — @name = f = name(f) — is exactly what powers these web frameworks.
Common Mistakes to Avoid
- Mistake: Using
globalwhen you meantnonlocal(or vice versa) — Fix:global= module level;nonlocal= enclosing function. - Mistake: A decorator that forgets to
return func(*args, **kwargs)— Fix: always return the wrapped result. - Mistake: Missing
@wrapsand losing the function's identity — Fix: decorate the wrapper. - Mistake: Forgetting
nonlocalin a closure and gettingUnboundLocalError— Fix: declarenonlocal countbefore modifying it. - Mistake: Applying a decorator without the
@and forgetting to reassign — Fix:f = decorator(f)or use the@sugar.
Professional Tips & Tricks
- Use
nonlocal(notglobal) to modify an enclosing function's variable. - Decorate with
@functools.wraps(func)so the wrapped function keeps its name and docstring. - Decorators are the foundation of Flask/FastAPI route decorators — this is the same machinery.
- Keep decorators generic with
*args, **kwargsso they work on any function. - For parameterized decorators, add one more wrapping layer (
def repeat(times): def deco(func): ...).
Key Takeaways
- LEGB: Local → Enclosing → Global → Built-in.
globalreaches module scope;nonlocalreaches enclosing function scope.- Closures remember enclosing variables — perfect for counters and factories.
- Decorators wrap functions with extra behavior;
@nameis sugar forf = name(f). - Always use
@wrapsto preserve function metadata.
Next up: Modules, packages & imports — organizing code into files.
# Closure: counter that remembers
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print("Call 1:", counter())
print("Call 2:", counter())
print("Call 3:", counter())
# Decorator: time any function
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timer
def slow_task():
total = sum(range(100000))
return total
print("Sum:", slow_task())Lesson Code (Python)
# Closure: counter that remembers
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print("Call 1:", counter())
print("Call 2:", counter())
print("Call 3:", counter())
# Decorator: time any function
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.6f}s")
return result
return wrapper
@timer
def slow_task():
total = sum(range(100000))
return total
print("Sum:", slow_task())Console Output
Call 1: 1
Call 2: 2
Call 3: 3
slow_task took 0.004812s
Sum: 4999950000Code Visualization Tips
- Draw scopes as nested boxes and imagine a telescope pointing outward from the innermost box.
- For closures, picture the inner function carrying a backpack with the outer variables inside.
- For decorators, picture wrapping a gift: the wrapper is the paper, the original function is the gift inside.
Professional Tips & Tricks
- Use nonlocal (not global) to modify an enclosing function's variable.
- Decorate with @functools.wraps(func) so the wrapped function keeps its name and docstring.
- Decorators are the foundation of Flask/FastAPI route decorators — this is the same machinery.
Python Code Judge & Practice Arena
LeetCode StyleRun real Python 3.12 WebAssembly code directly in your browser against automated test suites.
Problem 1: Stateful Closure Counter 1
Test Your Knowledge
Instant feedbackQuick Check: Lesson 14: Scopes (LEGB), Closures & Decorators
Up next · Continue learning
Modules, Packages & Imports
Organize code into files, import between them, use the __name__ guard, and install packages with pip.