Skip to main content
Python intermediate Lesson 14 of 28

Python Decorators and Closures

Understand closures, write function and class decorators, and use functools for production-grade wrapping patterns.

Decorators are one of Python’s most powerful features. They allow you to modify or extend the behaviour of functions and classes without changing their source code — a core principle of the Open/Closed design principle.

Closures — The Foundation

Before writing decorators, you need to understand closures. A closure is a function defined inside another function that captures variables from the outer scope and continues to access them even after the outer function has returned. This “remembered” state is what makes decorators possible — the wrapper function needs to hold a reference to the original function long after the decorator has finished running. Understanding closures makes the decorator pattern feel obvious rather than magical.

def make_multiplier(factor: int):
    """Returns a function that multiplies its input by `factor`."""

    def multiplier(x: int) -> int:
        # `factor` is captured from the enclosing scope of make_multiplier
        # It stays alive as long as this inner function exists
        return x * factor

    return multiplier   # return the function object itself, not the result of calling it

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

print(double(5))   # 10 — factor=2 is remembered inside double
print(triple(5))   # 15 — factor=3 is remembered inside triple

# You can inspect the captured variable directly
print(double.__closure__[0].cell_contents)   # 2

Writing Your First Decorator

A decorator is a function that takes a function as its argument and returns a new function that wraps the original. The wrapper can run code before and after the original call, inspect or modify arguments and return values, or suppress exceptions. This pattern lets you add cross-cutting concerns — logging, timing, authentication — to many functions from a single definition, keeping that logic out of the functions themselves. @functools.wraps is essential: without it, the wrapper replaces the original function’s name and docstring, which breaks debugging tools and help text.

import functools
import time

def timer(func):
    """Decorator: prints how long the wrapped function takes to run."""

    @functools.wraps(func)   # copy __name__, __doc__, etc. from func to wrapper
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)          # call the original function
        elapsed = time.perf_counter() - start
        print(f"[timer] {func.__name__!r} took {elapsed:.4f}s")
        return result                           # always return the original result

    return wrapper


@timer   # equivalent to: slow_sum = timer(slow_sum)
def slow_sum(n: int) -> int:
    return sum(range(n))

total = slow_sum(10_000_000)
# [timer] 'slow_sum' took 0.3412s

Decorators with Arguments (Decorator Factories)

Sometimes you need to configure a decorator — set the number of retry attempts, choose which exceptions to catch, or specify a cache size. Adding parameters requires one more layer of nesting: an outer factory function that accepts the configuration and returns the actual decorator. The three-level structure (factory → decorator → wrapper) is a standard Python pattern worth internalising. The @retry(max_attempts=5) call invokes the factory, which returns the decorator, which is then applied to the function.

import functools
import logging

def retry(max_attempts: int = 3, exceptions: tuple = (Exception,)):
    """Decorator factory: retries the function up to `max_attempts` times on failure."""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)   # success — return immediately
                except exceptions as e:
                    last_error = e
                    logging.warning(f"Attempt {attempt}/{max_attempts} failed: {e}")
            raise last_error   # all attempts exhausted — re-raise the last exception
        return wrapper
    return decorator


# The factory is called with config, which returns a decorator applied to fetch_data
@retry(max_attempts=5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url: str) -> dict:
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network unstable")
    return {"status": "ok"}

Stacking Multiple Decorators

You can apply more than one decorator to a single function. Python applies them bottom-up at definition time — the decorator closest to the function runs first, and the outermost runs last. The resulting call order is top-down: when you call the function, the outermost wrapper runs first and calls into the next, and so on. Keeping this evaluation order in mind prevents surprises when combining decorators like @login_required and @cache.

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"   # wraps the result in bold tags
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"   # wraps the result in italic tags
    return wrapper


@bold          # applied second — outermost wrapper, runs first on call
@italic        # applied first — innermost wrapper, runs second on call
def greet(name: str) -> str:
    return f"Hello, {name}"

print(greet("Alice"))   # <b><i>Hello, Alice</i></b>
# Equivalent to: bold(italic(greet))("Alice")

Class-Based Decorators

Function-based decorators are stateless by default — each call to the wrapper is independent. When a decorator needs to maintain state between calls (a call counter, a timestamp history, a cache), a class with __call__ is cleaner than using a mutable list captured in a closure. The class __init__ stores the configuration, __call__ makes instances of the class behave like functions, and instance variables hold the per-decorator state naturally.

import functools
import time

class RateLimit:
    """Class decorator: limits calls to `max_calls` per `period` seconds."""

    def __init__(self, max_calls: int, period: float):
        self.max_calls = max_calls
        self.period = period
        self._calls: list[float] = []   # instance variable — persists between calls

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            # Remove timestamps that have fallen outside the current window
            self._calls = [t for t in self._calls if now - t < self.period]
            if len(self._calls) >= self.max_calls:
                raise RuntimeError(
                    f"Rate limit exceeded: {self.max_calls} calls/{self.period}s"
                )
            self._calls.append(now)   # record this call's timestamp
            return func(*args, **kwargs)
        return wrapper


@RateLimit(max_calls=3, period=1.0)
def send_notification(message: str) -> None:
    print(f"Sent: {message}")

Practical Built-in Decorators

Python’s standard library ships several decorators that you will use in almost every class you write. @property turns a method into an attribute accessed without parentheses, hiding the implementation detail of computed values. @classmethod gives the method access to the class itself rather than an instance, making it the standard way to write alternative constructors. @staticmethod attaches a utility function to the class namespace without giving it access to the instance or class — useful for related helper logic that doesn’t need any class state.

import math

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    @property
    def area(self) -> float:
        """Computed on access — callers write c.area, not c.area()."""
        return math.pi * self.radius ** 2

    @classmethod
    def unit(cls) -> "Circle":
        """Alternative constructor — cls is the class itself, enabling subclassing."""
        return cls(radius=1.0)

    @staticmethod
    def is_valid_radius(r: float) -> bool:
        """Utility that belongs logically to Circle but needs no instance or class state."""
        return r > 0


c = Circle.unit()
print(c.area)                      # 3.14159... — accessed like an attribute
print(Circle.is_valid_radius(-1))  # False — called on the class, not an instance

functools Essentials

The functools module provides several decorators that are useful enough to be considered standard practice. lru_cache is particularly valuable: it memoizes function results so that repeated calls with the same arguments return instantly from a cache instead of recomputing. This can turn an exponential-time recursive algorithm into a linear one with a single decorator.

DecoratorPurpose
@functools.wraps(func)Preserve wrapped function metadata
@functools.lru_cache(maxsize=128)Memoize function results (cache up to N entries)
@functools.cacheUnbounded memoization (Python 3.9+)
@functools.cached_propertyCompute once, cache as instance attribute
import functools

# Without memoization, fibonacci(50) would make ~2^50 recursive calls
# With lru_cache, each value is computed once and reused — O(n) calls total
@functools.lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))           # computed instantly via memoization
print(fibonacci.cache_info())  # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)

Frequently Asked Questions

What is a closure in Python?
A closure is a function that captures and remembers variables from its enclosing scope, even after that scope has finished executing. It is the mechanism that powers decorators.
Why should I use functools.wraps?
Without @functools.wraps, a decorated function loses its original __name__, __doc__, and other metadata. @functools.wraps copies that metadata from the wrapped function to the wrapper, which matters for debugging, introspection, and documentation tools.
What is the difference between a function decorator and a class decorator?
A function decorator wraps a function (or method) and returns a callable. A class decorator wraps an entire class, modifying or augmenting its attributes and methods when the class is defined.