Scope & Closures

0/4 in this phase0/54 across the roadmap

📖 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 global keyword to modify a global variable from a function
  • Use nonlocal keyword 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:

  1. It's a nested function
  2. It references a variable from an enclosing scope (free variable)
  3. The enclosing function has returned (the free variable outlives its scope)

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# LEGB scope resolution
3# ============================================================
4x = "global"
5
6def outer():
7 x = "enclosing"
8
9 def inner():
10 x = "local"
11 print(f"Inner: {x}") # "local" (L)
12
13 inner()
14 print(f"Outer: {x}") # "enclosing" (E)
15
16outer()
17print(f"Global: {x}") # "global" (G)
18
19# Built-in scope
20print(len([1, 2, 3])) # len is from built-in scope (B)
21
22# ============================================================
23# Assignment creates local scope (common gotcha)
24# ============================================================
25count = 0
26
27def increment_bad():
28 # This fails! Python sees the assignment below and treats
29 # 'count' as local, but it's read before assignment
30 # count = count + 1 # UnboundLocalError!
31 pass
32
33def increment_global():
34 global count # Explicitly reference global variable
35 count = count + 1
36
37increment_global()
38print(count) # 1
39
40# ============================================================
41# nonlocal keyword (modify enclosing scope)
42# ============================================================
43def make_counter():
44 count = 0
45
46 def increment():
47 nonlocal count # Reference enclosing scope's count
48 count += 1
49 return count
50
51 def get_count():
52 return count # Reading doesn't need nonlocal
53
54 return increment, get_count
55
56inc, get = make_counter()
57print(inc()) # 1
58print(inc()) # 2
59print(inc()) # 3
60print(get()) # 3
61
62# ============================================================
63# Closures
64# ============================================================
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 variable
69 return multiply
70
71double = make_multiplier(2)
72triple = make_multiplier(3)
73
74print(double(5)) # 10
75print(triple(5)) # 15
76
77# The closure still has access to 'factor' even though
78# make_multiplier() has returned
79print(double.__closure__[0].cell_contents) # 2
80
81# ============================================================
82# Closure pitfall: late binding
83# ============================================================
84# BAD: All lambdas capture the same variable 'i'
85functions = []
86for i in range(5):
87 functions.append(lambda: i)
88
89# All return 4 (the final value of i)!
90print([f() for f in functions]) # [4, 4, 4, 4, 4]
91
92# GOOD: Use default argument to capture current value
93functions = []
94for i in range(5):
95 functions.append(lambda i=i: i) # Default arg captures current i
96
97print([f() for f in functions]) # [0, 1, 2, 3, 4]
98
99# BETTER: Use a factory function
100def make_func(n):
101 return lambda: n
102
103functions = [make_func(i) for i in range(5)]
104print([f() for f in functions]) # [0, 1, 2, 3, 4]
105
106# ============================================================
107# Practical closure: caching/memoization
108# ============================================================
109def memoize(func):
110 cache = {} # Closure over cache
111
112 def wrapper(*args):
113 if args not in cache:
114 cache[args] = func(*args)
115 return cache[args]
116
117 wrapper.cache = cache # Expose cache for debugging
118 return wrapper
119
120@memoize
121def fibonacci(n):
122 if n < 2:
123 return n
124 return fibonacci(n - 1) + fibonacci(n - 2)
125
126print(fibonacci(50)) # 12586269025 (instant, thanks to caching)
127print(fibonacci.cache) # Shows all cached values
128
129# ============================================================
130# Closure: configuration pattern
131# ============================================================
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 log
137
138db_logger = create_logger("DATABASE")
139api_logger = create_logger("API", level="DEBUG")
140
141db_logger("Connected") # [INFO] DATABASE: Connected
142api_logger("Request sent") # [DEBUG] API: Request sent

🏋️ Practice Exercise

Exercises:

  1. Trace the LEGB resolution: create a variable named x at global, enclosing, and local scope. Print x at each level and explain the output.

  2. Write a make_counter(start=0, step=1) function that returns increment, decrement, reset, and get_value functions using closures and nonlocal.

  3. Demonstrate the late-binding closure bug with a loop. Show three different ways to fix it.

  4. Build a rate_limiter(max_calls, period_seconds) using closures that tracks function calls and rejects excess calls within the time period.

  5. Implement a closure-based accumulator that keeps a running total: each call adds to the total and returns the current sum.

  6. Explain why x = x + 1 inside a function (without global) causes UnboundLocalError even though x exists globally. Write code to demonstrate.

⚠️ Common Mistakes

  • Getting UnboundLocalError when 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] or len = 5 — these override built-in functions. Use descriptive names like items or length.

  • Confusing global and nonlocalglobal references module-level variables, nonlocal references 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