Python - Closures with Examples

• Closures allow inner functions to remember and access variables from their enclosing scope even after the outer function has finished executing, enabling powerful patterns like data encapsulation...

Key Insights

• Closures allow inner functions to remember and access variables from their enclosing scope even after the outer function has finished executing, enabling powerful patterns like data encapsulation and factory functions. • Python creates closures automatically when an inner function references variables from an outer function’s scope, storing these references in the function’s __closure__ attribute. • Closures are particularly useful for creating decorators, callback functions with state, and implementing functional programming patterns without relying on classes.

Understanding Closures

A closure occurs when a nested function references variables from its enclosing scope. Python preserves these variables in the inner function’s closure, allowing access even after the outer function returns.

def outer_function(message):
    # This variable is in the enclosing scope
    prefix = "Message: "
    
    def inner_function():
        # inner_function "closes over" message and prefix
        return prefix + message
    
    return inner_function

# Create a closure
my_closure = outer_function("Hello, World!")
print(my_closure())  # Output: Message: Hello, World!

# The outer function has finished, but the closure retains access
print(my_closure())  # Still works: Message: Hello, World!

You can inspect a closure’s captured variables:

print(my_closure.__closure__)  # Shows cell objects
print(my_closure.__code__.co_freevars)  # ('message', 'prefix')

# Access the actual values
for cell in my_closure.__closure__:
    print(cell.cell_contents)
# Output:
# Hello, World!
# Message: 

Factory Functions

Closures excel at creating specialized functions with pre-configured behavior:

def multiplier_factory(factor):
    def multiply(number):
        return number * factor
    return multiply

# Create specialized multiplier functions
double = multiplier_factory(2)
triple = multiplier_factory(3)
times_ten = multiplier_factory(10)

print(double(5))      # 10
print(triple(5))      # 15
print(times_ten(5))   # 50

A more practical example with configuration:

def create_logger(level, prefix):
    def log(message):
        formatted = f"[{level.upper()}] {prefix}: {message}"
        print(formatted)
    return log

error_logger = create_logger("error", "Database")
info_logger = create_logger("info", "API")

error_logger("Connection failed")  # [ERROR] Database: Connection failed
info_logger("Request received")     # [INFO] API: Request received

Maintaining State

Closures provide a lightweight alternative to classes for maintaining state:

def counter(start=0):
    count = [start]  # Use list to allow modification
    
    def increment():
        count[0] += 1
        return count[0]
    
    def decrement():
        count[0] -= 1
        return count[0]
    
    def get_value():
        return count[0]
    
    return increment, decrement, get_value

inc, dec, get = counter(10)
print(inc())  # 11
print(inc())  # 12
print(dec())  # 11
print(get())  # 11

Using nonlocal keyword for cleaner state modification:

def counter_nonlocal(start=0):
    count = start
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    def reset():
        nonlocal count
        count = start
        return count
    
    return increment, reset

inc, reset = counter_nonlocal(0)
print(inc())    # 1
print(inc())    # 2
print(reset())  # 0

Decorators Using Closures

Decorators are perhaps the most common use of closures in Python:

def timing_decorator(func):
    import time
    
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    
    return wrapper

@timing_decorator
def slow_function(n):
    total = 0
    for i in range(n):
        total += i
    return total

result = slow_function(1000000)
# Output: slow_function took 0.0523 seconds

Decorator with configurable parameters:

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))
# Output: ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']

Practical Applications

Memoization

def memoize(func):
    cache = {}
    
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(100))  # Executes quickly due to caching

Callback Functions with Context

def create_button_handler(button_id, action_type):
    def handle_click(event):
        print(f"Button {button_id} clicked: {action_type}")
        print(f"Event data: {event}")
    return handle_click

# Simulate button handlers
save_handler = create_button_handler("btn_save", "save")
delete_handler = create_button_handler("btn_delete", "delete")

save_handler({"x": 100, "y": 200})
# Output: Button btn_save clicked: save
#         Event data: {'x': 100, 'y': 200}

delete_handler({"x": 150, "y": 250})
# Output: Button btn_delete clicked: delete
#         Event data: {'x': 150, 'y': 250}

Partial Function Application

def partial(func, *partial_args):
    def wrapper(*args):
        return func(*partial_args, *args)
    return wrapper

def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 25
print(cube(5))    # 125

Common Pitfalls

Late Binding in Loops

# Incorrect - all closures reference the same variable
functions = []
for i in range(5):
    def func():
        return i
    functions.append(func)

print([f() for f in functions])  # [4, 4, 4, 4, 4]

# Correct - create new binding for each iteration
functions = []
for i in range(5):
    def func(x=i):  # Default argument captures current value
        return x
    functions.append(func)

print([f() for f in functions])  # [0, 1, 2, 3, 4]

# Alternative using lambda
functions = [lambda x=i: x for i in range(5)]
print([f() for f in functions])  # [0, 1, 2, 3, 4]

Modifying Enclosed Variables

def broken_counter():
    count = 0
    def increment():
        count += 1  # UnboundLocalError!
        return count
    return increment

# Fix with nonlocal
def working_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

inc = working_counter()
print(inc())  # 1
print(inc())  # 2

Performance Considerations

Closures have minimal overhead but do consume memory for captured variables:

import sys

def create_closure(data):
    def inner():
        return data
    return inner

closure = create_closure([1, 2, 3])
print(sys.getsizeof(closure))  # Size of function object

# Memory is retained as long as closure exists
large_data = list(range(1000000))
closure_with_large_data = create_closure(large_data)
# large_data is kept in memory even if original reference is deleted
del large_data
# Still accessible through closure
print(len(closure_with_large_data()))  # 1000000

Closures provide elegant solutions for encapsulation, state management, and functional programming patterns. They’re fundamental to Python’s decorator syntax and enable writing cleaner, more maintainable code without the overhead of full class definitions. Understanding closures is essential for advanced Python development, particularly when working with callbacks, decorators, and functional programming paradigms.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.