Scope & Closures
📖 Concept
Understanding variable scope and closures is essential for writing correct Python code. Python uses the LEGB rule to resolve variable names: it searches Local, Enclosing, Global, and Built-in scopes in that order.
LEGB Rule:
| Scope | Description | Example |
|---|---|---|
| Local | Inside the current function | Variables defined in the function body |
| Enclosing | Inside enclosing (outer) functions | Variables from an outer function in a nested function |
| Global | Module-level | Variables defined at the top level of a module |
| Built-in | Python's built-in names | print, len, range, True |
Key rules:
- Reading a variable searches LEGB from innermost to outermost
- Assigning to a variable makes it local by default
- Use
globalkeyword to modify a global variable from a function - Use
nonlocalkeyword to modify an enclosing scope variable
Closures occur when an inner function "remembers" variables from its enclosing scope even after the outer function has returned. This is the basis for decorators, factories, and callback patterns.
A closure has three properties:
- It's a nested function
- It references a variable from an enclosing scope (free variable)
- The enclosing function has returned (the free variable outlives its scope)
💻 Code Example
1# ============================================================2# LEGB scope resolution3# ============================================================4x = "global"56def outer():7 x = "enclosing"89 def inner():10 x = "local"11 print(f"Inner: {x}") # "local" (L)1213 inner()14 print(f"Outer: {x}") # "enclosing" (E)1516outer()17print(f"Global: {x}") # "global" (G)1819# Built-in scope20print(len([1, 2, 3])) # len is from built-in scope (B)2122# ============================================================23# Assignment creates local scope (common gotcha)24# ============================================================25count = 02627def increment_bad():28 # This fails! Python sees the assignment below and treats29 # 'count' as local, but it's read before assignment30 # count = count + 1 # UnboundLocalError!31 pass3233def increment_global():34 global count # Explicitly reference global variable35 count = count + 13637increment_global()38print(count) # 13940# ============================================================41# nonlocal keyword (modify enclosing scope)42# ============================================================43def make_counter():44 count = 04546 def increment():47 nonlocal count # Reference enclosing scope's count48 count += 149 return count5051 def get_count():52 return count # Reading doesn't need nonlocal5354 return increment, get_count5556inc, get = make_counter()57print(inc()) # 158print(inc()) # 259print(inc()) # 360print(get()) # 36162# ============================================================63# Closures64# ============================================================65def make_multiplier(factor):66 """The inner function 'closes over' the 'factor' variable."""67 def multiply(x):68 return x * factor # 'factor' is a free variable69 return multiply7071double = make_multiplier(2)72triple = make_multiplier(3)7374print(double(5)) # 1075print(triple(5)) # 157677# The closure still has access to 'factor' even though78# make_multiplier() has returned79print(double.__closure__[0].cell_contents) # 28081# ============================================================82# Closure pitfall: late binding83# ============================================================84# BAD: All lambdas capture the same variable 'i'85functions = []86for i in range(5):87 functions.append(lambda: i)8889# All return 4 (the final value of i)!90print([f() for f in functions]) # [4, 4, 4, 4, 4]9192# GOOD: Use default argument to capture current value93functions = []94for i in range(5):95 functions.append(lambda i=i: i) # Default arg captures current i9697print([f() for f in functions]) # [0, 1, 2, 3, 4]9899# BETTER: Use a factory function100def make_func(n):101 return lambda: n102103functions = [make_func(i) for i in range(5)]104print([f() for f in functions]) # [0, 1, 2, 3, 4]105106# ============================================================107# Practical closure: caching/memoization108# ============================================================109def memoize(func):110 cache = {} # Closure over cache111112 def wrapper(*args):113 if args not in cache:114 cache[args] = func(*args)115 return cache[args]116117 wrapper.cache = cache # Expose cache for debugging118 return wrapper119120@memoize121def fibonacci(n):122 if n < 2:123 return n124 return fibonacci(n - 1) + fibonacci(n - 2)125126print(fibonacci(50)) # 12586269025 (instant, thanks to caching)127print(fibonacci.cache) # Shows all cached values128129# ============================================================130# Closure: configuration pattern131# ============================================================132def create_logger(prefix, level="INFO"):133 """Returns a logger function configured with prefix and level."""134 def log(message):135 print(f"[{level}] {prefix}: {message}")136 return log137138db_logger = create_logger("DATABASE")139api_logger = create_logger("API", level="DEBUG")140141db_logger("Connected") # [INFO] DATABASE: Connected142api_logger("Request sent") # [DEBUG] API: Request sent
🏋️ Practice Exercise
Exercises:
Trace the LEGB resolution: create a variable named
xat global, enclosing, and local scope. Printxat each level and explain the output.Write a
make_counter(start=0, step=1)function that returnsincrement,decrement,reset, andget_valuefunctions using closures andnonlocal.Demonstrate the late-binding closure bug with a loop. Show three different ways to fix it.
Build a
rate_limiter(max_calls, period_seconds)using closures that tracks function calls and rejects excess calls within the time period.Implement a closure-based
accumulatorthat keeps a running total: each call adds to the total and returns the current sum.Explain why
x = x + 1inside a function (withoutglobal) causesUnboundLocalErroreven thoughxexists globally. Write code to demonstrate.
⚠️ Common Mistakes
Getting
UnboundLocalErrorwhen trying to modify a global variable inside a function. Python sees the assignment and creates a local variable, but then the read on the right side fails because the local hasn't been assigned yet.Overusing
global— it makes code hard to reason about because any function can modify shared state. Pass values as parameters and return results instead.Not understanding closure late binding: lambdas in a loop all capture the SAME variable reference, not the value at the time of creation. Use default arguments or factory functions.
Shadowing built-in names:
list = [1, 2, 3]orlen = 5— these override built-in functions. Use descriptive names likeitemsorlength.Confusing
globalandnonlocal—globalreferences module-level variables,nonlocalreferences the nearest enclosing function's variable. Using the wrong one can modify unexpected variables.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Scope & Closures