How to think about Python decorators (or, everything you never wanted to know about decorators but have been forced to find out anyway)

Posted on Wed 08 April 2026 in Software

Function decorators are one of the most powerful features in the Python language, as they provide a succinct way to modify the behaviours of functions – often providing a quick one-liner to endow functions with useful behaviours or properties.

However, they can be confusing because (a) the syntax for creating decorators themselves is somewhat ugly and (b) they are functions of functions, or functionals, a somewhat unfamiliar construction and possibly one's first introduction to that higher level of abstraction. Furthermore, there's a terminological confusion between "parameterised" and "unparameterised" decorators, discussed below.

For all that notational complexity, though, the roles that decorators play in Python are fairly straightforward to understand: they typically play one of two roles. So the following tutorial will first introduce the basic notation, show some silly/trivial examples, and then see some actual examples to see how the technique can be put to good use.

We'll be talking only about function decorators in any detail, but, as discussed below, class decorators have similar purposes too.

This post is based on comments on LinkedIn that I wrote in response to a question about the purpose of decorators: see there for another concrete example. See also the Python docs, and PEP 318 as reference material.

What decorators look like

Decorators show up above function definitions with an @ sign, like this:

@my_decorator
def fun(x):
    ...  # does something
    return x + 2

They apply immediately as soon as the function is defined, not when the function is called.

Unpacking the decorator notation

The above notation is shorthand for two steps:

def fun():
    ... # original function implementation
    return x + 2
    
# Calls the functional, then REASSIGNS the name `fun`
fun = my_decorator(fun) 

So my_decorator is some callable object that takes in the function fun as defined, performs some operations, and returns a function,1 and reassigns the name fun to refer to that new function.

In other words, decorators are functions of functions, also known as functionals.

So fun now refers to the function that you get back from the decorator.

Some silly examples

Before we talk more about the roles that decorators can play, let's look at a few examples to explore the notation a bit. This is for illustrating the syntax; in the next section we'll see some real applications.

Identity decorator

This is perhaps the most trivial possible decorator:

def identity_decorator(f):
    """doesn't do anything, just gives back the original function"""
    return f

When a function is decorated with @identity_decorator, nothing happens – the decorated function is identical to the undecorated function,2 and no "side effects" happen.

Constant-valued decorator

Here is another silly example. Define the following function:

def _constantly_one(*_, **__) -> int:
    """Function that accepts any number of arguments of any type,
    ignores them, and returns 1.
    """
    return 1

This is a constant-valued function: regardless of what inputs it is given, its output is always the value 1.

Then define this decorator:

def returns_one(f):
    return _constantly_one

When a function is decorated with @returns_one, its entire implementation is ignored, and its name is reassigned to a function that unconditionally returns 1.

Here's an example:

import math

@returns_one
def silly_sine(x: float) -> float:
    return math.sin(x)

print(silly_sine(0.1))  # 1

The signature of the original function is also ignored:

print(silly_sine())  # 1, not TypeError
print(silly_sine("spam spam spam"))  # 1, not TypeError
print(silly_sine(0, 1, 2, 3))  # 1, not TypeError

Something a bit more interesting

The following program prints two lines of text. When is each one printed?

def report_definition(f):
    print(f"Hi, you just defined the function {f.__name__}")
    return f


@report_definition
def hello(n: str) -> None:
    print(f"Hello, {n}")

hello("Joshua")

A legal example that pushes the boundaries

Finally, here's another decorator that is syntactically valid, but pushes the boundaries of what it means to be a decorator.

def noneify(f):
    return None

Example:

@noneify
def weird_func(x):
    return math.sin(x)


print(weird_func(0.1))  # TypeError: 'NoneType' object is not callable

What happened? Well, noneify returned None, and the name weird_func is then reassigned to None. So weird_func(0.1) resolves to None(0.1), which is indeed a TypeError.

This example shows that a decorator doesn't have to return a function: it can return any object, such as None, or a number, or a string.

Would you ever want to do this? Probably not. But it's legal Python syntax.

The two types of decorators

Having seen a few examples of useless decorators, we now talk about more practical cases.

Decorators therefore typically play one of two roles, which I'll call "wrapping" and "registering". I don't think these are standard names.

Wrapping decorators

Wrapping decorators take your original function, call it fun, and give you back a new function, call it wrapper, that wraps fun. Typically, wrapper will call fun but performs some transformation on its inputs and outputs.

A good example of this behaviour is the @silent decorator from the funcy library (doc). When a function is decorated with @silent, then, if it would ordinarily raise an exception, the exception is instead caught and the function returns None. This is useful for silencing errors.3

Here is how the @silent decorator could be implemented.4

def silent(fun):
    def wrapper(*args, **kwargs):
        try:
            return fun(*args, **kwargs)
        except Exception:
            return None
    return wrapper

Note the use of *args and **kwargs, so that wrapper accepts any number of arguments of any type: this function doesn't itself care about the arguments, it just needs to pass them on to fun. This is a common pattern in wrapping decorators.

This decorator doesn't have any immediate effect when you apply it to a function – you only see its effect when you call that function in such a way that an exception would be triggered.

Another example of a wrapping decorator is @cache from functools (doc), which causes a function to be memoized so that previously computed values are stored in memory. This is typically useful for recursive calculations, e.g.:

from functools import cache

@cache
def fibonacci(n: int) -> int:
    """Compute the `n`th Fibonacci number."""
    if n < 0:
        raise ValueError
    if n == 0 or n == 1:
        return n
	return fibonacci(n-1) + fibonacci(n-2)

The function definition body gives the operations required to calculate fibonacci(n). The memoized version of the function performs the same operations, but:

  • before computing a value, checks to see if a stored result is available;
  • after computing a value, stores that result in a cache so that it can be reused in the future.

Here's an implementation of a caching decorator, for functions that take single integer arguments and return integers:5

def cache_intfunc(fun):  # fun should be a Callable[[int], int]
    # dictionary to store results
    _results = {}
    
    def wrapper(n: int) -> int:
        # check if we can reuse a previous result
        if n in _results:
            return _results[n]
        else:    
            # if a previous result isn't available, compute fun(x)...
            r = fun(n)
            # ...and store it, before returning
            _results[n] = r
            return r
    
    return wrapper

@cache_intfunc
def fibonacci(n: int) -> int:
    ...  # as above

Registering decorators

"Registering" decorators are the opposite of "wrapping" decorators. They do something interesting as soon as you apply them to the definition of fun. Thus their purpose is to perform some sort of side effect at function definition time.

This is usually to register your function to some other part of your function: it lets something know that fun now exists without actually calling fun.

The @report_definition decorator, defined above, is a "registering" decorator in that it does something when the function is defined. (In that case it was just to print a message.)

A more useful, and typical, application is for routing in web frameworks such as FastAPI and Flask: your FastAPI app needs to know that your endpoint exists and that it should be used whenever a certain route is requested. Here's a basic example in FastAPI:

app = fastapi.FastAPI()

@app.get("/games")
async def list_games(request: Request) -> Response:
    games = ...  # perform some database query
    return templates.TemplateResponse(
        "games.html", {"request": request, "games": games}
    )

The app maintains a mapping of known endpoints and the associated functions that should be called when those endpoints are requested. The decorator @app.get("/games") registers list_games as the function that should be called when the server receives a request at the /games endpoint. This needs to happen before the server is started.

An example that's both wrapping and registering

Here's an example that is has both "registering" and "wrapping" behaviours.

def verbose(f):
    """Causes a message to be printed when the function is created,
    AND when it is called.
    """
    print(f"Hi, you just defined the function {f.__name__}")
    def wrapper(*args, **kwargs):
        print(f"You just called the function {f.__name__} with {args = } and {kwargs = })
        return f(*args, **kwargs)

    return wrapper

@verbose
def hello(n: str) -> None:
    print(f"Hello, {n}")

hello("Joshua")

An analogy: mutable and immutable methods

Compare this to how methods fall into two roles: most str methods (e.g. str.strip) create and return a new instance, while most list methods (e.g. list.append) mutate the object in place but return None.

In this analogy, the str methods are like wrapping decorators: they produce a new object; while the list methods are like registering decorators: their power is in their side effects.

The analogy is not perfect. Decorators are different because:

  • Decorators always return a function, not None.6
  • Decorators are applied as soon as the target function is defined.

Further notes

Type hints

It is usually the case that a decorated function has the same signature as its undecorated function – otherwise, the type hints in the function signature are misleading. (Remember that the name of the function is reassigned to the decorated function; the original form is no longer available.)

For example:

def double_trouble(f):
    """Causes the decorated function to return the result twice?!"""
    def wrapper(*args, **kwargs):
        r = f(*args, **kwargs)
        return [r, r]
    return wrapper


@double_trouble
def square(x: int) -> int:
    return x * x


print(square(4))  # [16, 16]

So the decorated function square is a function whose output type is actually list[int], not int: the signature in the function definition is misleading.

As with the example of the decorator that returns None instead of a function, this is legal Python syntax – but highly ambiguous. Nonetheless it's the kind of thing that a type checker will usually catch when you try to use the result in a place that is expecting an int rather than a list[int].

Parameterised decorators: A point of confusion

In all of the above, the term decorator has referred to functions of functions that return functions.

The object to the right of the @ sign is a decorator. For example, in the usage:

from functools import lru_cache

@lru_cache(maxsize=32)
def fibonacci(n: int) -> int:
    """Compute the `n`th Fibonacci number."""
    if n < 0:
        raise ValueError
    if n == 0 or n == 1:
        return n
	return fibonacci(n-1) + fibonacci(n-2)

Here, lru_cache(maxsize=32) is a decorator (specifically, a wrapping decorator).

The function lru_cache itself is therefore a function of an integer that returns a decorator.

Confusingly, it is also referred to as a decorator, specifically, a parameterised decorator.

Here's an example of creating a parameterised decorator:

def delay(seconds):
    """When decorated with @delay(n), a function will sleep for n seconds before executing."""

    def decorator(fun):
        def wrapper(*args, **kwargs):
            time.sleep(seconds)
            return fun(*args, **kwargs)
            
        return wrapper
        
    return decorator


@delay(5)
def square(x: int) -> int:
    return x * x


print(square(4))  # sleeps for 5 seconds, then prints 16

Sleeping before running a function might be used to avoid running into rate limits when making requests to an external service.7

With three layers of nesting, the definition is a bit ugly, but makes perfect sense when read inside-out:

  • wrapper is a function that wraps the decorated function fun, putting a time.sleep before executing it.
  • decorator is a function of a function: it takes fun as an input and returns wrapper
  • delay itself is a function of an integer: delay(5) is a decorator.

Using @functools.wraps

When a wrapping decorator returns a function wrapper around the original function fun, it is a good idea to decorate wrapper itself with @functools.wraps(fun) (doc).

Here's the Fibonacci example again:

from functools import wraps

def cache_intfunc(fun): 
    _results = {}

	@wraps(fun)
    def wrapper(n: int) -> int:
        r = ...  # as above
        return r 
    
    return wrapper

@cache_intfunc
def fibonacci(n: int) -> int:
    """Compute the `n`th Fibonacci number."""
    ...  # as above

When wrapper is decorated with @wraps(fun), it preserves the name, docstring and various other properties of the original function fibonacci.

This usually has no effect on the code at runtime, but is important for documentation and diagnostics.

Class decorators are the same idea

In all of the above examples, decorators were being applied to function definitions and this reassigns the function name. Exactly the same idea applies to class decorators: These are functions that

  1. take classes as inputs and return classes,
  2. run immediately after the class is defined,
  3. might return the input class, or some related class,
  4. reassign the name of the class to this returned class (possibly the same).

Again, the class decorator notation

@some_class_decorator
class MyClass:
    ...  # fields, methods, etc.

is equivalent to defining the class and then reassigning its name:

class MyClass:
    ...  # fields, methods, etc.

MyClass = some_class_decorator(MyClass)

Defining class decorators is a little more involved, but again, they play one of two roles: wrapping the target class by modifying/extending its behaviour, or registering the target class when it is defined.

  1. Possibly fun itself, possibly some other function. In fact, it may return any object: see the examples.
  2. That is, actually identical in the sense that f is identity_decorator(f); not just that they behave the same way.
  3. Catching all exceptions is a very blunt instrument; the funcy docs actually recommend against using silent on user-defined functions and the library provides alternatives that target specific exception types.
  4. This is not the actual implementation; see the above footnote.
  5. Again, this is not a complete implementation, it's just for demonstration.
  6. Ok, they don't have to return a function – see the silly examples above. But it would be a rather bad decorator.
  7. Again, this is a blunt instrument, a bit hacky.