Decorators & Closures

Master closures, function decorators, @wraps, decorators with arguments, and Python's built-in decorators.

Intermediate 35 min read 🐍 Python

Closures: Functions That Remember

Before we can understand decorators, we need to understand closures. A closure is a function that "remembers" variables from its enclosing scope, even after that scope has finished executing. Think of it as a function bundled with its environment:

def make_multiplier(factor):
    """Returns a function that multiplies by factor."""
    def multiply(x):
        return x * factor  # 'factor' is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))    # 10
print(triple(5))    # 15
print(double(100))  # 200
Output
10
15
200

When make_multiplier(2) returns, its local variable factor would normally be destroyed. But the inner multiply function still needs it, so Python keeps it alive. Each call to make_multiplier creates a new closure with its own captured factor.

Function Decorators

A decorator is a function that takes another function as input and returns a new function that usually extends the behavior of the original. It's one of Python's most powerful patterns — used extensively in web frameworks, testing, caching, and logging.

The @decorator syntax is just syntactic sugar for func = decorator(func):

import time

def timer(func):
    """Measure how long a function takes to run."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer  # Same as: slow_function = timer(slow_function)
def slow_function():
    """Simulate a slow operation."""
    time.sleep(0.1)
    return "done"

result = slow_function()
print(result)
Output
slow_function took 0.1003s
done

The wrapper function accepts *args, **kwargs so it works with any function signature. It calls the original function, does something extra (timing in this case), and returns the result.

functools.wraps — Preserving Metadata

Without @wraps, the decorated function loses its name, docstring, and other metadata. Always use @wraps in your decorators:

from functools import wraps

def debug(func):
    @wraps(func)  # Copies name, docstring, etc. from func to wrapper
    def wrapper(*args, **kwargs):
        args_str = ", ".join(repr(a) for a in args)
        print(f"Calling {func.__name__}({args_str})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

@debug
def greet(name):
    """Greet someone by name."""
    return f"Hello, {name}!"

greet("Alice")
print(f"Name: {greet.__name__}")  # 'greet' (not 'wrapper')
print(f"Doc: {greet.__doc__}")    # 'Greet someone by name.'
Output
Calling greet('Alice')
greet returned 'Hello, Alice!'
Name: greet
Doc: Greet someone by name.
Key Takeaway: Always use @functools.wraps(func) in your decorator's wrapper function. Without it, debugging tools, documentation generators, and introspection break because the function appears to be named "wrapper".

Decorators with Arguments

Sometimes you want to configure a decorator. For example, a retry decorator that takes the number of attempts. This requires an extra layer of nesting — a function that returns a decorator:

from functools import wraps
import random

def retry(max_attempts=3, exceptions=(Exception,)):
    """Retry a function up to max_attempts times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts:
                        raise
                    print(f"  Attempt {attempt} failed: {e}. Retrying...")
        return wrapper
    return decorator

@retry(max_attempts=3, exceptions=(ConnectionError,))
def fetch_data():
    """Simulate an unreliable API call."""
    if random.random() < 0.7:
        raise ConnectionError("Server unavailable")
    return {"status": "ok", "data": [1, 2, 3]}

try:
    result = fetch_data()
    print(f"Success: {result}")
except ConnectionError:
    print("All attempts failed")

Built-in Decorators

Python comes with several powerful decorators. You've already seen @property in the OOP tutorial. Here are others you'll use frequently:

from dataclasses import dataclass, field
from functools import lru_cache

# @dataclass — auto-generates __init__, __repr__, __eq__
@dataclass
class Point:
    x: float
    y: float

    def distance_to(self, other):
        return ((self.x - other.x)**2 + (self.y - other.y)**2) ** 0.5

p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1)                    # Point(x=0, y=0)
print(p1.distance_to(p2))   # 5.0
print(p1 == Point(0, 0))    # True

# @lru_cache — memoize function results
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(100))  # Instant! Without cache, this takes forever
print(fibonacci.cache_info())
Output
Point(x=0, y=0)
5.0
True
354224848179261915075
CacheInfo(hits=98, misses=101, maxsize=128, currsize=101)
DecoratorModulePurpose
@propertybuilt-inDefine getter/setter as attribute access
@staticmethodbuilt-inMethod that doesn't need self or cls
@classmethodbuilt-inMethod that receives the class, not instance
@dataclassdataclassesAuto-generate boilerplate methods
@lru_cachefunctoolsCache function results (memoization)
@wrapsfunctoolsPreserve decorated function's metadata
@abstractmethodabcForce subclasses to implement a method

Chaining Decorators

You can stack multiple decorators. They're applied bottom-up (the decorator closest to the function runs first):

@timer      # Applied second (wraps the debug-wrapped function)
@debug      # Applied first (wraps the original function)
def process(data):
    return sorted(data)

process([3, 1, 4, 1, 5])

This is equivalent to process = timer(debug(process)).

🔍 Deep Dive: Class Decorators

Decorators aren't limited to functions. You can decorate classes too. @dataclass is the most common example. You can also write your own class decorators — they receive the class as an argument and return a modified (or new) class. A common use case is registering classes in a plugin system: @register adds the class to a registry dict so it can be discovered later.

⚠️ Common Mistake: Forgetting @wraps

Wrong:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper  # wrapper.__name__ is 'wrapper', not func's name!

Why: The decorated function loses its name, docstring, and module. This breaks debugging, logging, documentation, and serialization.

Instead:

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper