Python Basics — Closures

3 minute read

Closures

A closure is a function that remembers the variables from its enclosing scope, even after that scope has finished executing. Closures are one of Python’s most elegant features.

What Is a Closure?

A closure occurs when:

  1. You have a nested function (a function inside another function)
  2. The inner function references variables from the outer function
  3. The outer function returns the inner function
def make_greeter(greeting):
    def greeter(name):
        return f"{greeting}, {name}!"    # greeting is "closed over"
    return greeter

hello = make_greeter("Hello")
namaste = make_greeter("Namaste")

print(hello("Alice"))    # Hello, Alice!
print(namaste("Bob"))    # Namaste, Bob!

After make_greeter returns, greeting should be gone — but the inner function remembers it. That’s a closure.

Free Variables and Cell Objects

The variable greeting in the example above is called a free variable — it’s not local to the inner function, nor is it global. Python stores it in a special cell object:

def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

double = make_multiplier(2)

# Inspect the closure
print(double.__code__.co_freevars)  # ('factor',)
print(double.__closure__)           # (<cell at 0x...: int object at 0x...>,)
print(double.__closure__[0].cell_contents)  # 2

Closure Use Cases

Data Hiding

Closures can create private state without classes:

def make_bank_account(initial_balance):
    balance = initial_balance

    def deposit(amount):
        nonlocal balance
        balance += amount
        return balance

    def withdraw(amount):
        nonlocal balance
        if amount > balance:
            return "Insufficient funds"
        balance -= amount
        return balance

    def get_balance():
        return balance

    return deposit, withdraw, get_balance

deposit, withdraw, check = make_bank_account(1000)
print(deposit(500))    # 1500
print(withdraw(200))   # 1300
print(check())         # 1300
# No way to access 'balance' directly — it's hidden!

Function Factories

Create specialized functions on-the-fly:

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

square = make_power(2)
cube = make_power(3)

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

Logging / Tracking

def make_logger(prefix):
    def log(message):
        print(f"[{prefix}] {message}")
    return log

info = make_logger("INFO")
error = make_logger("ERROR")

info("Server started")     # [INFO] Server started
error("Connection failed") # [ERROR] Connection failed

Caching (Memoization)

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

def slow_square(n):
    """Simulates an expensive computation."""
    return n ** 2

fast_square = make_cached(slow_square)
print(fast_square(10))  # 100 — computed
print(fast_square(10))  # 100 — from cache

The __closure__ Attribute

Every function has a __closure__ attribute. It’s None for regular functions and a tuple of cell objects for closures:

def regular():
    return 42

def make_closure():
    x = 10
    def inner():
        return x
    return inner

print(regular.__closure__)        # None
print(make_closure().__closure__)  # (<cell at 0x...>,)

Closures vs Classes

Both closures and classes can maintain state. When should you use which?

Feature Closure Class
Simple state (1-2 vars) Preferred Overkill
Complex state Harder to manage Preferred
Multiple methods Awkward (return tuple) Natural
Readability Great for small patterns Better for complex logic
Memory Slightly lighter Slightly heavier
# Closure approach — great for simple cases
def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

# Class approach — better for complex cases
class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count

    def reset(self):
        self.count = 0

Common Pitfall: Late Binding in Loops

# ❌ Surprise — all functions return 4!
functions = []
for i in range(5):
    functions.append(lambda: i)

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

The lambda closes over the variable i, not its value. By the time the lambdas run, i is 4.

Fix: Default Argument

# ✅ Capture the current value with a default argument
functions = []
for i in range(5):
    functions.append(lambda i=i: i)

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

Summary

  • A closure is a function that remembers variables from its enclosing scope
  • Three requirements: nested function, references outer variable, outer returns inner
  • Free variables are stored in __closure__ cell objects
  • Use cases: data hiding, function factories, logging, caching
  • Closures are best for simple state; use classes for complex state
  • Watch out for late binding in loops — use default arguments to fix

Next up: Scope Best Practices →

Updated: