Skip to content
Innovialabs

Python Decorators: How They Work and How to Write Your Own

Practice3 min

In short

A decorator is a function that takes a function and returns a new one with extra behavior before or after the call; the @decorator syntax is just shorthand for func = decorator(func).

What a decorator is

A decorator is a function that takes another function and returns a new one. Writing @timer above a function is simply shorthand for func = timer(func). Decorators let you move repeated logic — logging, timing, retries, permission checks — out of business code and into one place.

They work because of two Python features: functions are ordinary objects, and a nested function "remembers" the variables of its enclosing function. That second feature is called a closure.

Step 1: a closure

def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

hello = make_greeter("Hello")
print(hello("Anna"))  # Hello, Anna!

The inner greet still sees greeting after make_greeter has returned. A decorator works the same way, except the outer function receives a function instead of a string.

Step 2: your first decorator and functools.wraps

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__}: {time.perf_counter() - start:.3f} s")
        return result
    return wrapper

@timer
def build_report(n):
    return sum(range(n))

The details that matter:

  • *args, **kwargs — the wrapper accepts any arguments and passes them through.
  • return result — without it, the decorated function returns None.
  • functools.wraps copies __name__, __doc__ and other attributes from the original. Without it every function is called wrapper, which breaks logs, debugging and docs.

Step 3: a decorator with arguments

When a decorator needs parameters, add one more level: the outer function takes the settings and returns the actual decorator.

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == times:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rates():
    ...

Note that @retry() with parentheses and @retry without them are different. In the second case the function lands in the times parameter and the code breaks.

Practical examples

Caching. For pure functions, don't write your own cache — the standard library has functools.lru_cache and functools.cache:

@functools.lru_cache(maxsize=256)
def get_tariff(region_id):
    ...

Arguments must be hashable, and the cache lives in the process memory.

Permission checks. A decorator fits handlers that receive the user as the first argument:

def require_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if role not in user.roles:
                raise PermissionError(f"Role {role} required")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_order(user, order_id):
    ...

Class-based decorators vs class decorators

These are two different things:

WhatLooks likeUseful when
A class used as a decoratorclass with __init__(self, func) and __call__you need state, such as a call counter
A decorator for classesfunction takes a class and returns a classregistration, adding methods; this is how @dataclass works
class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

Common mistakes

  • Forgetting functools.wraps — the name and docstring are lost.
  • Forgetting return in the wrapper — the function silently returns None.
  • Mixing up @deco and @deco() for parameterized decorators.
  • Ignoring order: decorators are applied bottom to top, the one closest to def first.
  • Wrapping async def with a regular wrapper: for coroutines the wrapper must also be async and await func(...).
  • Hiding too much logic in a decorator — if the behavior isn't obvious from the name, readers will struggle.

FAQ

Every call goes through an extra function, so there is overhead, but it is usually tiny compared with the work the function does. It only becomes noticeable in very hot loops.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.