Skip to main content
Advertisement

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
Advertisement