Python Closures: Nested Functions and Free Variables

A closure is a function that captures and remembers variables from its enclosing scope, even after that scope has finished executing. In Python, closures emerge naturally from the combination of...

Key Insights

  • Closures enable functions to remember and access variables from their enclosing scope even after that scope has finished executing, making them powerful tools for creating function factories and maintaining private state
  • The nonlocal keyword is essential when you need to modify (not just read) variables from an enclosing scope, but forgetting it leads to one of Python’s most common closure bugs
  • While closures and classes can solve similar problems, closures excel at simple state encapsulation with minimal boilerplate, whereas classes are better for complex objects with multiple methods and inheritance needs

Introduction to Closures

A closure is a function that captures and remembers variables from its enclosing scope, even after that scope has finished executing. In Python, closures emerge naturally from the combination of nested functions and lexical scoping rules. When an inner function references variables from an outer function, it “closes over” those variables, preserving their values for later use.

Here’s the simplest possible closure:

def outer(message):
    def inner():
        print(message)  # 'message' is a free variable
    return inner

greeting = outer("Hello, closures!")
greeting()  # Output: Hello, closures!

Even though outer() has finished executing, the inner() function still has access to the message variable. This behavior isn’t magic—Python stores references to these captured variables within the function object itself.

Anatomy of a Closure

For a proper closure to exist, three conditions must be met:

  1. You must have a nested function (a function defined inside another function)
  2. The inner function must reference variables from the enclosing scope (these are called “free variables”)
  3. The inner function must be returned or passed elsewhere, outliving its enclosing scope

Let’s contrast a closure with a regular nested function:

# Regular nested function - NOT a closure
def calculator():
    def add(a, b):
        return a + b
    result = add(5, 3)
    return result

# Closure - inner function references 'multiplier' from enclosing scope
def make_multiplier(multiplier):
    def multiply(number):
        return number * multiplier  # 'multiplier' is a free variable
    return multiply

times_three = make_multiplier(3)
print(times_three(10))  # Output: 30

The key difference: in the closure example, multiply references multiplier from its enclosing scope and is returned, allowing it to be called later with the captured value intact.

You can inspect a function’s closure using the __closure__ attribute:

def create_counter(start):
    count = start
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = create_counter(10)
print(counter.__closure__)  # (<cell at 0x...: int object at 0x...>,)
print(counter.__closure__[0].cell_contents)  # Output: 10

Practical Use Cases

Closures shine in several real-world scenarios. Let’s explore the most common patterns.

Function Factories

Closures excel at creating specialized functions based on configuration:

def make_counter(start=0, step=1):
    count = start
    def increment():
        nonlocal count
        count += step
        return count
    return increment

counter_by_ones = make_counter(0, 1)
counter_by_fives = make_counter(0, 5)

print(counter_by_ones())  # 1
print(counter_by_ones())  # 2
print(counter_by_fives())  # 5
print(counter_by_fives())  # 10

Configuration-Based Validators

Closures can encapsulate validation logic with configuration:

def make_validator(min_value, max_value):
    def validate(value):
        if not isinstance(value, (int, float)):
            return False
        return min_value <= value <= max_value
    return validate

age_validator = make_validator(0, 120)
percentage_validator = make_validator(0, 100)

print(age_validator(25))   # True
print(age_validator(150))  # False
print(percentage_validator(50))  # True

Decorators

Most Python decorators are implemented using closures:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

add(3, 5)
# Output:
# Calling add with args=(3, 5), kwargs={}
# add returned 8

The nonlocal Keyword

Reading free variables from an enclosing scope works automatically, but modifying them requires the nonlocal keyword. This is a common source of bugs.

Here’s the problem without nonlocal:

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

# This will raise an error when called

Python sees the assignment count += 1 and assumes count is a local variable. Since it’s referenced before assignment, you get an error.

The fix is straightforward:

def working_counter():
    count = 0
    def increment():
        nonlocal count  # Explicitly declare we're modifying the enclosing scope
        count += 1
        return count
    return increment

counter = working_counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

Important distinction: you only need nonlocal for reassignment. Mutating mutable objects works without it:

def list_appender():
    items = []
    def append(item):
        items.append(item)  # No nonlocal needed - we're mutating, not reassigning
        return items
    return append

appender = list_appender()
print(appender(1))  # [1]
print(appender(2))  # [1, 2]

Closures vs. Classes

Closures and classes can solve similar problems. Here’s the same functionality implemented both ways:

# Closure approach
def make_account(initial_balance):
    balance = initial_balance
    
    def deposit(amount):
        nonlocal balance
        balance += amount
        return balance
    
    def withdraw(amount):
        nonlocal balance
        if amount > balance:
            raise ValueError("Insufficient funds")
        balance -= amount
        return balance
    
    def get_balance():
        return balance
    
    return {'deposit': deposit, 'withdraw': withdraw, 'balance': get_balance}

# Class approach
class Account:
    def __init__(self, initial_balance):
        self._balance = initial_balance
    
    def deposit(self, amount):
        self._balance += amount
        return self._balance
    
    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return self._balance
    
    def get_balance(self):
        return self._balance

# Usage is similar
closure_account = make_account(100)
class_account = Account(100)

print(closure_account['deposit'](50))  # 150
print(class_account.deposit(50))        # 150

When to use closures:

  • Simple state encapsulation with one or two functions
  • Function factories and configuration-based behavior
  • Decorators and callbacks
  • When you want minimal boilerplate

When to use classes:

  • Complex objects with many methods
  • Need inheritance or polymorphism
  • Multiple instances with shared behavior
  • When clarity and conventional OOP patterns matter more than brevity

Common Pitfalls and Best Practices

The Late Binding Loop Problem

This is the classic closure gotcha:

# WRONG - all functions reference the same 'i'
functions = []
for i in range(3):
    def func():
        return i
    functions.append(func)

print([f() for f in functions])  # [2, 2, 2] - not [0, 1, 2]!

The problem: all closures share the same i variable, which has value 2 after the loop completes.

Solution 1: Default argument

functions = []
for i in range(3):
    def func(x=i):  # Capture current value of i
        return x
    functions.append(func)

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

Solution 2: functools.partial

from functools import partial

def return_value(x):
    return x

functions = [partial(return_value, i) for i in range(3)]
print([f() for f in functions])  # [0, 1, 2]

Memory Considerations

Closures keep references to their enclosing scope, which can prevent garbage collection:

def create_large_closure():
    large_data = [0] * 1000000  # 1 million integers
    def small_function():
        return "I don't even use large_data"
    return small_function

func = create_large_closure()
# large_data is still in memory because of the closure!

If you don’t need certain variables, delete them explicitly or restructure your code.

Closures are a fundamental Python feature that enables elegant solutions for state management, function factories, and decorators. Master them, and you’ll write more expressive, functional-style Python code. Just remember to use nonlocal when modifying enclosing variables, watch out for late binding in loops, and choose between closures and classes based on complexity and clarity needs.

Liked this? There's more.

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