Python Decorators Explained

Learn what Python decorators are, how the @ syntax works, and why decorators are a fundamental tool for writing reusable, composable behaviour in Python.

8 min read

Python decorators are one of the language's most elegant features: they let you wrap a function with another, adding behaviour before or after the wrapped function runs, without modifying the wrapped function's source code directly. If you have written Python for a while, you have almost certainly used decorators already. The staticmethod and classmethod markers you place above method definitions in a class are decorators. The property syntax that turns a method into an attribute-like accessor is a decorator. Flask's route registration that connects a URL to a view function is a decorator. Each of these uses the same underlying mechanism: a callable that receives a function, does something with it, and returns a replacement, all triggered by the at sign placed above the function definition.

The decorator pattern is not unique to Python, but Python's implementation is unusually clean because the language treats functions as first-class objects. You can pass a function as an argument to another function, return a function from a function, and assign a function to a variable, all without special syntax or wrapper types. Python decorators sit on top of this foundation. A decorator is simply a callable that accepts a callable and returns a callable, and the at-syntax automates the pattern of passing the defined function through the decorator and rebinding the name. If you have already read about higher-order functions in Python and understand how functions can be passed and returned like any other value, you already have the mental model that decorators depend on.

What makes decorators worth studying as a dedicated topic is that they unlock a specific kind of code reuse that is difficult to achieve with other Python features. Without decorators, if you wanted to add logging, timing, access control, or caching to ten different functions, you would need to modify each function body individually, copy and paste boilerplate into every one, or wrap every call site with the additional logic. Decorators let you write the extra behaviour once, give it a name, and apply it to any function with a single line above the definition. The original function stays focused on its core task, and the wrapping logic lives in one place where it can be tested, updated, and understood in isolation.

The problem decorators solve

Imagine you are building a module that contains several functions that perform expensive calculations. After writing the functions, you realize you need to measure how long each one takes so you can identify bottlenecks. Without decorators, you might add timing code inside every function like this:

pythonpython
import time
 
def compute_report(data):
    start = time.perf_counter()
    result = _do_expensive_work(data)
    end = time.perf_counter()
    print(f"compute_report took {end - start:.4f}s")
    return result

This approach has several problems that compound as your codebase grows. The timing logic is duplicated across every function, which means a change to the timing format requires updating every copy. The timing code is mixed with the business logic, making both harder to read. If you forget to add the timing to a new function, you get no measurement and might not notice until a performance problem appears in production. And if you later decide to replace the timing with a proper metrics library, you must touch every function again.

Decorators solve all of these problems by separating the cross-cutting concern, which is timing, from the function's core responsibility, which is computing a report. You write a single decorator function that accepts any function, wraps it in timing logic, and returns the wrapped version. Then you apply that decorator to every function that needs timing. The function body stays clean, the timing code lives in one place, and adding timing to a new function is a single line above its definition. This pattern of extracting orthogonal behaviour into a decorator appears again and again in real Python codebases: authentication checks, input validation, retry logic, rate limiting, caching, and transaction management all follow the same structure.

How the @ syntax actually works

The at-syntax for decorators was introduced in Python 2.4 through PEP 318, and it is one of the most immediately recognizable pieces of Python syntax. Despite its distinctive appearance, it is pure syntactic sugar. The following two code fragments are semantically identical. First, the decorator syntax:

pythonpython
@timer
def compute_report(data):
    return _do_expensive_work(data)

And second, the equivalent manual decoration. Python defines the function, passes the function object to the timer decorator, and assigns whatever timer returns back to the original name:

pythonpython
def compute_report(data):
    return _do_expensive_work(data)
 
compute_report = timer(compute_report)

In both cases, Python defines the function, then passes the function object to the decorator, and assigns whatever the decorator returns back to the name. The at-syntax simply moves this reassignment to the point of definition, which makes the decoration visible at a glance and avoids the repetition of writing the function name three times. The expression after the at-sign must evaluate to a callable that accepts a single argument (the function being decorated) and returns a value, typically a new callable that replaces the original.

This mechanism has an important implication that surprises many developers the first time they encounter it: the decoration happens at definition time, not at call time. When Python loads a module and processes a decorated function definition, it runs the decorator immediately, during import. The function that gets stored under the decorated name is already the wrapped version before any code calls it. This is why decorators are not suitable for logic that needs to run per-call and must be set up at import time instead. It also explains why decorator code that performs expensive initialization runs once, at module load, rather than on every function invocation.

Why functions must be first-class for decorators to work

Decorators depend on three properties of Python functions that together make up the idea of functions as first-class objects. The first property is that a function can be passed as an argument to another function. When you write the at-syntax above a function definition, Python passes the function object to the decorator just as it would pass an integer or a string to any other callable. The second property is that a function can be returned from another function: the decorator must return something, and that something is almost always a new function (the wrapper) that will replace the original. The third property is that a function can be assigned to a variable or reassigned to an existing name. The final step of decoration works because function names are just variables that happen to hold function objects.

These three capabilities are explored in detail in the article on first-class functions in Python, and they are the reason decorators feel natural in Python in a way they do not in languages that treat functions as second-class constructs. In Python, a decorator is not a special language construct with its own runtime rules. It is an ordinary function that happens to receive and return other functions, and the at-syntax is a convenient shorthand for calling it at definition time.

The same first-class treatment means that decorators themselves can be built out of other functions. A decorator can be a plain function, a lambda, an instance of a class that defines the call method, or even another decorated function. This composability is what makes the decorator ecosystem in Python so rich. Libraries like functools provide decorators that modify other decorators, and frameworks like Flask and Django build entire routing and middleware systems on top of decorator chains.

What a decorator looks like inside

A minimal decorator is a function that defines an inner function, uses that inner function to wrap the original, and returns the inner function. The inner function is called the wrapper, and it is the function that actually runs when someone calls the decorated function later. Here is the simplest possible decorator that does nothing except prove the pattern works:

pythonpython
def null_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

The null decorator receives a function, defines a new inner function that accepts any arguments and forwards them to the original, and returns the inner function. When you decorate a function with this, the original is replaced by the wrapper. Calling the decorated function calls the wrapper, which calls the original and returns its result. The behaviour is identical to the undecorated function, which makes this a good starting point for building real decorators: you add logic before the call, after it, or both.

The star-args and double-star-kwargs pattern in the wrapper is essential because the decorator should work with functions that have any number of positional and keyword arguments. Without this pattern, the wrapper would only work with functions that match its exact parameter list, which defeats the purpose of writing a reusable decorator. Later articles in this section cover argument forwarding and return value handling in full detail.

The order of stacked decorators

Python allows multiple decorators on a single function, stacked vertically above the definition. When more than one decorator is present, they are applied from the bottom up, meaning the decorator closest to the def line runs first. Consider two decorators stacked like this:

pythonpython
@decorator_a
@decorator_b
def greet(name):
    return f"Hello, {name}"

Python interprets this as passing the function through decorator_b first, then passing the result through decorator_a. When someone calls the decorated function, the call passes through decorator_a's wrapper, then through decorator_b's wrapper, then reaches the original function body. The return value travels back up through the same layers in reverse order.

This bottom-up application order matches how function composition works in mathematics. If you think of each decorator as a function that transforms another function, then stacking them is composition, and the innermost transformation applies first. The order matters whenever decorators have side effects or depend on each other. A timing decorator placed closest to the function measures only the original function's execution time, while a timing decorator placed on top measures the combined time of the original function plus any inner decorators.

Real-world examples you have already used

The Python standard library includes several built-in decorators that you have probably encountered even if you did not think of them as decorators at the time. The staticmethod decorator, applied to a method inside a class, tells Python not to pass the instance as the first argument. The classmethod decorator tells Python to pass the class instead of the instance. The property decorator turns a method into a descriptor that can be accessed like an attribute, enabling computed attributes with getter, setter, and deleter logic.

Outside the standard library, web frameworks make heavy use of decorators. Flask's route decorator registers a function as the handler for an HTTP path. Django's login-required decorator prevents unauthenticated users from accessing a view. Pytest's fixture decorator marks a function as a reusable test setup. Each of these frameworks uses the same underlying decorator mechanism, which means understanding how decorators work under the hood helps you use these tools more effectively and debug them when something goes wrong.

The next article in this section walks through creating your first Python decorator from scratch, starting with the wrapper pattern and building toward decorators that add real behaviour like logging and timing. Once you can write a basic decorator, the subsequent articles cover decorating functions with arguments, handling return values correctly, and building decorators that accept their own parameters.

Rune AI

Rune AI

Key Insights

  • A decorator is a callable that takes a function and returns a new function, applied with the @ syntax above the function definition.
  • The @ syntax is syntactic sugar for passing the function through the decorator and reassigning the result to the original name.
  • Decorators work because Python treats functions as first-class objects that can be passed as arguments, returned from other functions, and assigned to variables.
  • Understanding decorators requires understanding closures, higher-order functions, and the idea that a function definition creates an object that can be wrapped and replaced.
  • Multiple decorators can be stacked on a single function, and they apply from bottom to top, closest to the def first.
RunePowered by Rune AI

Frequently Asked Questions

What is a Python decorator?

A Python decorator is a callable that takes another callable (usually a function) as its argument and returns a new callable that typically extends or modifies the behaviour of the original. The @decorator syntax is syntactic sugar that applies the decorator at function definition time, replacing the original function name with the decorated version.

Why does Python use the @ symbol for decorators?

The @ symbol was chosen for decorator syntax in PEP 318 because it was an unused token in Python at the time (Python 2.4), which meant there was no ambiguity with existing code. It also visually resembles similar annotation features in Java. The syntax places the decorator directly above the function definition, making the transformation visible at the point of declaration rather than hidden after the function body.

Conclusion

Python decorators are one of the language's most elegant features because they take a pattern that every programmer needs, adding behaviour before and after a function call, and make it explicit, composable, and readable. The @ syntax places the transformation where you can see it, and the underlying mechanism of passing and returning callables keeps decorators consistent with how Python treats functions as first-class objects everywhere else in the language.