Python Decorators in 5 Minutes

Decorators explained clearly

Decorators are a "syntactic sugar" for a function that takes another function as an argument.

Usually the thing returned is another function, but it technically doesn't have to be.

Functions that take functions as arguments and return functions are referred to as higher-order functions.

@some_decorator
def foo():
    ...

# is equivalent to...

def foo():
    ...

foo = some_decorator(foo)

Decorators are often used in frameworks like Flask and FastAPI to create productive high-level abstractions.

Learning to write them can seem overwhelming at first, but once you grok the concept, writing decorators will become natural.

Just be careful not to abuse the pattern. As useful as decorators are, they add a layer of indirection that can make our code more difficult to understand.

A Simple Example

Every function in python will have a __name__ attribute that is its string name.

Let's write a decorator that prints a function's name at the time of decoration.

def print_name(func):
    print(f"The function being decorated is named {func.__name__}.")

@print_name
def greet():
    return f"Hello, world."

greet()
The function being decorated is named greet.
Traceback (most recent call last):
  at block 5, line 5
TypeError: 'NoneType' object is not callable

Why did our greet function raise an Exception?

We decorated greet with our print_name decorator, but print_name doesn't return anything.

So we basically did the equivalent of writing greet = None.

Let's fix that.

def print_name(func):
    print(f"The function being decorated is named {func.__name__}.")
    return func # < we return the original function

@print_name
def greet():
    return f"Hello, world."

for _ in range(3):
    print(greet())

# notice how we'll only print the description once, at the time we decorate `greet`. not on every invocation
The function being decorated is named greet.
Hello, world.
Hello, world.
Hello, world.

Input and Output

What if we wanted to do something with the decorated function's input or output?

In that case, we would need to use what's called a closure to wrap the decorated function.

import os

def print_name(func):
    print(f"The function being decorated is named {func.__name__}.", end=os.linesep * 2)
    def closure(*args, **kwargs):
        print(f"We're calling {func.__name__} with ({args = }, {kwargs = }).")
        result = func(*args, **kwargs)
        print(f"{func.__name__}({args[0]}) == {result}")
        return result
    return closure

@print_name
def greet(name):
    return f"Hello, {name}."

names = ["Jane", "Sam", "Batman"]

for name in names:
    greet(name)
    print("-" * 60)
The function being decorated is named greet.

We're calling greet with (args = ('Jane',), kwargs = {}).
greet(Jane) == Hello, Jane.
------------------------------------------------------------
We're calling greet with (args = ('Sam',), kwargs = {}).
greet(Sam) == Hello, Sam.
------------------------------------------------------------
We're calling greet with (args = ('Batman',), kwargs = {}).
greet(Batman) == Hello, Batman.
------------------------------------------------------------

Decorators with arguments

What if we want to alter the behavior of our decorators?

To do that we'll need to have an additional level of functional "nesting".

In effect, what we'll be doing is writing a factory function that returns our actual decorator, but to the caller, it will just look like a decorator with arguments.

import os

def print_name(excitedly=False):
    def decorator(func):
        msg = f"The function being decorated is named {func.__name__}."
        print(msg.upper() if excitedly else msg, end=os.linesep * 2)
        def closure(*args, **kwargs):
            msg = f"We're calling {func.__name__} with ({args = }, {kwargs = })."
            print(msg.upper() if excitedly else msg)
            return func(*args, **kwargs)
        return closure
    return decorator


@print_name() # notice the parenthesis here
def greet(name):
    return f"Hello, {name}."


@print_name(excitedly=True)
def plus_two(n):
    return n + 2


print(greet("Stephan"))
print("-" * 60)
print(plus_two(0))
The function being decorated is named greet.

THE FUNCTION BEING DECORATED IS NAMED PLUS_TWO.

We're calling greet with (args = ('Stephan',), kwargs = {}).
Hello, Stephan.
------------------------------------------------------------
WE'RE CALLING PLUS_TWO WITH (ARGS = (0,), KWARGS = {}).
2

Bonus

Retaining function metadata

Since we're writing functions that return functions, the original metadata we may have wanted or needed could be lost.

Let's see an example.

def print_name(func):
    print(f"The function being decorated is named {func.__name__}.")
    def closure(*args, **kwargs):
        print(f"We're calling {func.__name__} with ({args = }, {kwargs = }).")
        return func(*args, **kwargs)
    return closure

@print_name
def greet(first_name, last_name=None):
    """Say hello."""
    last_name = last_name or ""
    name = f"{first_name} {last_name}".rstrip()
    return f"Hello, {name}."

print(f"{greet.__name__ = } {greet.__doc__ = }")
The function being decorated is named greet.
greet.__name__ = 'closure' greet.__doc__ = None

We can fix this by using another decorator from the standard library, functools.wraps.

import functools

def print_name(func):
    print(f"The function being decorated is named {func.__name__}.")
    @functools.wraps(func) # notice our use of functools.wraps here
    def closure(*args, **kwargs):
        print(f"We're calling {func.__name__} with ({args = }, {kwargs = }).")
        return func(*args, **kwargs)
    return closure

@print_name
def greet(first_name, last_name=None):
    """Say hello."""
    last_name = last_name or ""
    name = f"{first_name} {last_name}".rstrip()
    return f"Hello, {name}."

print(f"{greet.__name__ = } {greet.__doc__ = }")
The function being decorated is named greet.
greet.__name__ = 'greet' greet.__doc__ = 'Say hello.'

Avoiding outer parentheses

If we want to avoid having to use outer parenthesis when writing decorators with arguments, we can do so by adding an initial argument to our factory and adjusting what's returned.

import os

def print_name(f=None, excitedly=False): # notice the extra initial argument
    def decorator(func):
        msg = f"The function being decorated is named {func.__name__}."
        print(msg.upper() if excitedly else msg, end=os.linesep * 2)
        def closure(*args, **kwargs):
            msg = f"We're calling {func.__name__} with ({args = }, {kwargs = })."
            print(msg.upper() if excitedly else msg)
            return func(*args, **kwargs)
        return closure
    return decorator if f is None else decorator(f) # we alter our output based on the value of f


@print_name # notice the lack of parens here
def greet(first_name, last_name=None):
    last_name = last_name or ""
    name = f"{first_name} {last_name}".rstrip()
    return f"Hello, {name}."


@print_name(excitedly=True)
def plus_two(n):
    return n + 2


print(greet("Stephan"))
print("-" * 60)
print(plus_two(0))
The function being decorated is named greet.

THE FUNCTION BEING DECORATED IS NAMED PLUS_TWO.

We're calling greet with (args = ('Stephan',), kwargs = {}).
Hello, Stephan.
------------------------------------------------------------
WE'RE CALLING PLUS_TWO WITH (ARGS = (0,), KWARGS = {}).
2

Subscribe to knowsuchagency

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe