5.4 Closures and Scope — LEGB Rules, nonlocal, global
Understanding variable scope lets you predict code behavior precisely. Mastering Python's LEGB rules and closures enables you to write advanced patterns like data hiding, state retention, and decorators.
The LEGB Rule
Python searches for variables in this order:
L(Local) → E(Enclosing) → G(Global) → B(Built-in)
# Built-in: Python's built-in namespace
print(len([1, 2, 3])) # len is Built-in
# Global: module-level variable
global_var = "global"
def outer():
# Enclosing: outer function's scope
enclosing_var = "enclosing"
def inner():
# Local: inner function's scope
local_var = "local"
print(local_var) # L: local variable
print(enclosing_var) # E: enclosing function variable
print(global_var) # G: global variable
print(len) # B: built-in function
inner()
outer()
Scope Search Example
x = "global"
def outer():
x = "outer"
def inner():
x = "inner"
print(x) # "inner" — closest scope
inner()
print(x) # "outer" — unaffected by inner's x
outer()
print(x) # "global" — unaffected by outer's x
# NameError if variable is not found
def no_local():
print(undefined_var) # NameError
# try:
# no_local()
# except NameError as e:
# print(f"Error: {e}")
global Keyword
# Must declare global to modify a global variable inside a function
count = 0
def increment():
global count # Declare intent to modify global count
count += 1
increment()
increment()
increment()
print(count) # 3
# Without global, attempting modification raises UnboundLocalError
total = 100
def bad_modify():
# total += 1 # UnboundLocalError
pass
def good_modify():
global total
total += 1
good_modify()
print(total) # 101
# Using global makes testing/debugging harder — prefer alternatives
class Counter:
def __init__(self):
self.count = 0
def increment(self) -> int:
self.count += 1
return self.count
counter = Counter()
print(counter.increment()) # 1
print(counter.increment()) # 2
nonlocal Keyword
# Modify an enclosing function's variable from an inner function
def make_counter(start: int = 0):
count = start # Enclosing variable
def increment(step: int = 1) -> int:
nonlocal count # Modify the outer count
count += step
return count
def decrement(step: int = 1) -> int:
nonlocal count
count -= step
return count
def reset() -> int:
nonlocal count
count = start
return count
def get() -> int:
return count # Reading is fine without nonlocal
return increment, decrement, reset, get
inc, dec, rst, get = make_counter(10)
print(get()) # 10
print(inc()) # 11
print(inc(5)) # 16
print(dec(3)) # 13
print(rst()) # 10 (reset to start)
# Attempting modification without nonlocal
def broken_counter():
count = 0
def inc():
# count += 1 # UnboundLocalError!
pass
return inc
Closures
A closure is an inner function that remembers variables from its enclosing scope (free variables).
# Basic closure
def make_multiplier(factor: int):
# factor is a local variable of make_multiplier
def multiply(x: int) -> int:
return x * factor # "remembers" factor
return multiply # Return the inner function
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# double and triple each remember their own factor independently
print(double.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3
# Data hiding with a closure
def make_bank_account(initial_balance: float = 0):
"""Hide the balance using a closure"""
balance = initial_balance
def deposit(amount: float) -> float:
nonlocal balance
if amount <= 0:
raise ValueError("Deposit amount must be positive")
balance += amount
return balance
def withdraw(amount: float) -> float:
nonlocal balance
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > balance:
raise ValueError(f"Insufficient funds: {balance}")
balance -= amount
return balance
def get_balance() -> float:
return balance # Read-only from outside
return deposit, withdraw, get_balance
deposit, withdraw, get_balance = make_bank_account(10000)
print(get_balance()) # 10000
deposit(5000)
print(get_balance()) # 15000
withdraw(3000)
print(get_balance()) # 12000
try:
withdraw(20000)
except ValueError as e:
print(f"Error: {e}")
Closure Usage Patterns
1. Configuration Factory
def make_logger(prefix: str, level: str = "INFO"):
"""Generate a logger function that remembers a prefix and level"""
import time
def log(message: str) -> None:
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}][{level}][{prefix}] {message}")
return log
api_logger = make_logger("API")
db_logger = make_logger("DB", "DEBUG")
error_logger = make_logger("ERROR", "ERROR")
api_logger("Request received: GET /users")
db_logger("Query: SELECT * FROM users")
error_logger("Connection failed: timeout")
2. Memoization (Manual)
def make_memoized(func):
"""Implement memoization using a closure"""
cache = {}
def memoized(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
def get_cache_info() -> dict:
return {"size": len(cache), "keys": list(cache.keys())}
memoized.cache = cache
memoized.cache_info = get_cache_info
return memoized
@make_memoized
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55
print(fibonacci(30)) # 832040
print(fibonacci.cache_info())
3. Partial Application
def partial_apply(func, *partial_args, **partial_kwargs):
"""Fix some arguments of a function"""
def wrapper(*args, **kwargs):
all_args = partial_args + args
all_kwargs = {**partial_kwargs, **kwargs}
return func(*all_args, **all_kwargs)
return wrapper
def send_request(method: str, url: str, headers: dict = None, body: dict = None):
headers = headers or {}
return f"{method} {url} headers={headers} body={body}"
get = partial_apply(send_request, "GET")
post = partial_apply(send_request, "POST")
print(get("https://api.example.com/users"))
print(post("https://api.example.com/users", body={"name": "Alice"}))
Closure vs Class
# Same functionality implemented as both closure and class
# Closure version
def make_accumulator(initial: float = 0):
total = initial
def add(n: float) -> float:
nonlocal total
total += n
return total
def reset() -> float:
nonlocal total
total = initial
return total
def value() -> float:
return total
return add, reset, value
# Class version
class Accumulator:
def __init__(self, initial: float = 0):
self._initial = initial
self._total = initial
def add(self, n: float) -> float:
self._total += n
return self._total
def reset(self) -> float:
self._total = self._initial
return self._total
@property
def value(self) -> float:
return self._total
# Closure usage
add, reset, value = make_accumulator(0)
print(add(10)) # 10
print(add(5)) # 15
print(reset()) # 0
# Class usage
acc = Accumulator(0)
print(acc.add(10)) # 10
print(acc.add(5)) # 15
print(acc.reset()) # 0
# When to choose:
# - Simple state, few methods: closure is more concise
# - Complex state, many methods: class is more maintainable
# - Need inheritance: class
# - Need serialization (pickle): class (closures are hard to pickle)
Understanding Decorators Through Closures
import time
import functools
# The essence of a decorator is a closure!
def timer(func):
"""A decorator that measures execution time"""
@functools.wraps(func) # Preserve func's metadata
def wrapper(*args, **kwargs):
# wrapper is a closure that "remembers" func
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_function(n: int) -> int:
"""Calculate sum up to n"""
return sum(range(n))
result = slow_function(1_000_000)
print(f"Result: {result:,}")
# Decorator with arguments = triple-nested closure
def repeat(n: int):
"""A decorator that runs a function n times"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(n):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(3)
def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("Alice"))
# ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']
Real-world Example: Event Handler System
from typing import Callable, Any
from collections import defaultdict
def make_event_emitter():
"""Closure-based event emitter"""
handlers: dict[str, list[Callable]] = defaultdict(list)
def on(event: str, handler: Callable) -> Callable:
"""Register an event handler. Returns a removal function."""
handlers[event].append(handler)
def off():
if handler in handlers[event]:
handlers[event].remove(handler)
return off
def emit(event: str, *args: Any, **kwargs: Any) -> int:
"""Emit an event. Returns the number of handlers called."""
called = 0
for handler in list(handlers.get(event, [])):
handler(*args, **kwargs)
called += 1
return called
def once(event: str, handler: Callable) -> None:
"""Handle an event only once"""
def one_time(*args, **kwargs):
handler(*args, **kwargs)
off() # Auto-remove after first call
off = on(event, one_time)
return on, emit, once
on, emit, once = make_event_emitter()
off1 = on("data", lambda d: print(f"Handler 1: {d}"))
off2 = on("data", lambda d: print(f"Handler 2: {d * 2}"))
once("connect", lambda: print("First connection"))
emit("connect") # "First connection"
emit("connect") # Nothing (once already removed it)
count = emit("data", 42)
print(f"Handlers called: {count}")
off1() # Remove handler 1
emit("data", 100) # Only handler 2 runs
Pro Tips
1. Watch Out for Loop Variable Capture in Closures
# Common bug: loop variable captured by reference in a closure
funcs_bad = []
for i in range(5):
funcs_bad.append(lambda: i) # References i (not value)
print([f() for f in funcs_bad]) # [4, 4, 4, 4, 4] — all refer to last i=4!
# Fix 1: capture current value as a default argument
funcs_good = []
for i in range(5):
funcs_good.append(lambda i=i: i) # Copies current i
print([f() for f in funcs_good]) # [0, 1, 2, 3, 4]
# Fix 2: factory function
def make_func(i):
return lambda: i
funcs_factory = [make_func(i) for i in range(5)]
print([f() for f in funcs_factory]) # [0, 1, 2, 3, 4]
2. Inspect Closure Internals with __closure__
def outer(x):
def inner():
return x
return inner
f = outer(42)
print(f.__closure__) # (<cell at 0x...>,)
print(f.__closure__[0].cell_contents) # 42
print(f.__code__.co_freevars) # ('x',) — list of free variables