Basics

Decorators in Python: What They Are, How They Work and How to Write Your Own

Updated: · Skillaria.Top

A decorator in Python is a function that takes another function and returns a new one, usually a wrapper that adds behaviour before or after the call: logging, timing, caching, permission checks. The @decorator line above def is a shorthand for func = decorator(func); to make the wrapper keep the name and docstring of the original function, functools.wraps is used.

A decorator in Python is a function that takes a function and returns another function, usually a wrapper around the original one. This is how behaviour is added to a function — logging, timing, caching, retries — without changing its code. Python decorators are written as a line @name above a function declaration and appear everywhere: in Flask and FastAPI, in pytest, in the standard library.

What a decorator is in plain words

Imagine a gift and its wrapping paper. The gift (the function) stays the same, but a layer appears outside that acts first. A decorator is exactly such packaging: when the function is called, the wrapper's code runs first, it calls the original function and can do something with the result.

This works because in Python functions are ordinary objects. They can be passed to other functions, returned from functions and declared inside functions. Decorators are built on these three properties.

How a decorator works: @ is syntactic sugar

Let us take the simplest decorator, which prints a message before and after the call:

def log_calls(func):
    def wrapper():
        print(f"Calling {func.__name__}")
        result = func()
        print(f"{func.__name__} finished")
        return result
    return wrapper

@log_calls
def hello():
    print("Hello!")

hello()
Calling hello
Hello!
hello finished

The @log_calls line above def is exactly equivalent to the following assignment after the declaration:

def hello():
    print("Hello!")

hello = log_calls(hello)

The name hello now refers to wrapper, while the original function lives inside the closure — wrapper remembers the variable func from the outer function. An important detail: the decorator is applied once, at the moment the def runs, that is at import time, while the wrapper runs on every call.

Closures and decorators

A closure is a nested function that remembers the variables of the outer function after the outer one has finished. Without closures there would be no decorators: this is how wrapper gets access to func. A closure can also hold state, for example a call counter:

def count_calls(func):
    calls = 0
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        print(f"{func.__name__}: call #{calls}")
        return func(*args, **kwargs)
    return wrapper

How to write a decorator in Python: a universal template

The first example has two drawbacks: the wrapper accepts no arguments and it "swallows" the name of the original function. A working template, with which any decorator should start, looks like this:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # code before the call
        result = func(*args, **kwargs)
        # code after the call
        return result
    return wrapper

Three rules of the template:

  1. *args, **kwargs — the wrapper accepts any arguments and passes them on unchanged.
  2. return result — do not forget to return the result, otherwise the decorated function will always give back None.
  3. @functools.wraps(func) — copies the metadata of the original function onto the wrapper.

An example of a decorator — measuring execution time:

import functools
import time

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

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

slow_sum(10_000_000)   # slow_sum took 0.142 s

Why you need functools.wraps

Without wraps, the decorated function's name, docstring and other attributes get replaced — and the wrapper is what is visible from outside:

def plain(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@plain
def add(a, b):
    """Adds two numbers."""
    return a + b

print(add.__name__, add.__doc__)   # wrapper None

With @functools.wraps(func), the wrapper receives the original function's __name__, __doc__, __module__, __qualname__ and __annotations__, and the attribute __wrapped__ stores a reference to the original. This matters for debugging and tracebacks, for help(), for frameworks that register functions by name, and for inspect.signature, which shows the real signature.

A decorator with parameters (with arguments)

Sometimes a decorator needs settings: @retry(times=3), @app.route("/"). For this, one more level of nesting is added. The outer function accepts the parameters and returns the actual decorator — this construction is called a decorator factory.

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("ping")

ping()   # ping, ping, ping — three lines

The order of calls: repeat(times=3) returns decorator, which is applied to ping and returns wrapper. In other words, @repeat(times=3) is ping = repeat(times=3)(ping).

A practical example — retrying on error:

import functools
import time

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_rate():
    ...

Several decorators on one function

Decorators can be stacked one above another. They are applied from the bottom up: the one nearest to def goes first.

@timer
@log_calls
def report():
    ...

# the same thing:
report = timer(log_calls(report))

When called, the outer layer (timer) runs first, so it will also measure the time spent in log_calls. The order matters: for example, if one decorator checks access rights and another caches the result.

Standard and built-in Python decorators

Many useful decorators already exist in the language and the standard library:

DecoratorWhat it does
@propertyturns a method into a read-only attribute; with @x.setter, into a managed attribute
@staticmethoda class method without self, an ordinary function in the class namespace
@classmethodthe method receives the class cls instead of an instance — this is how alternative constructors are written
@functools.cache, @functools.lru_cachecache results by arguments
@functools.wrapscopies the function's metadata onto the wrapper
@dataclasses.dataclassgenerates __init__, __repr__, __eq__ for a class
@contextlib.contextmanagerturns a generator into a context manager for with

Caching: functools.cache and lru_cache

A caching decorator remembers the result for each set of arguments and, on a repeated call, returns it without recalculating. The classic example is recursive Fibonacci numbers, which without a cache take exponentially long to compute:

import functools

@functools.cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(100))   # 354224848179261915075

functools.cache (added in Python 3.9) stores all results without limit. @functools.lru_cache(maxsize=128) keeps only the most recent entries and evicts old ones — this is safer for long-running services. Statistics are viewed with fib.cache_info() and reset with fib.cache_clear(). The arguments of such functions must be hashable: a list passed as an argument raises TypeError: unhashable type: 'list'. And you should cache only pure functions — ones that always return the same result for the same arguments and have no side effects.

What the property decorator does

@property lets you access a method as an attribute, without brackets, and control assignment when needed:

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("The balance cannot be negative")
        self._balance = value

acc = Account(100)
print(acc.balance)   # 100
acc.balance = -5     # ValueError

staticmethod and classmethod

class Money:
    def __init__(self, kopecks):
        self.kopecks = kopecks

    @classmethod
    def from_rubles(cls, rubles):
        return cls(round(rubles * 100))

    @staticmethod
    def is_valid(value):
        return value >= 0

m = Money.from_rubles(12.5)
print(m.kopecks, Money.is_valid(-1))   # 1250 False

It is @classmethod that lets a method work with the class itself rather than its instances: from_rubles correctly creates an object of a subclass too.

Class decorators and decorator classes

A decorator can be applied to a class: it receives the class and returns a class (the same one or a modified one). This is how @dataclass works. A simple example is a class registry:

registry = {}

def register(cls):
    registry[cls.__name__] = cls
    return cls

@register
class CsvExporter:
    pass

print(registry)   # {'CsvExporter': <class '__main__.CsvExporter'>}

The reverse situation is a class used as a decorator. Any object with a __call__ method will do; the state is conveniently kept in the instance attributes:

import functools

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)

@CountCalls
def tick():
    pass

tick(); tick()
print(tick.calls)   # 2

An asynchronous decorator

For async def functions the wrapper must also be a coroutine and wait for the result with await, otherwise an unfinished coroutine object would be returned:

import functools

def log_async(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        print(f"Starting {func.__name__}")
        result = await func(*args, **kwargs)
        print(f"Done {func.__name__}")
        return result
    return wrapper

Why you need decorators in Python

  • Cross-cutting logic — logging, metrics, timing without copying code into every function.
  • Caching — @functools.cache for expensive pure computations.
  • Checks — access rights, argument validation, authorisation in web handlers.
  • Registration — routes in Flask and FastAPI (@app.get("/")), pytest fixtures.
  • Reliability — retries, timeouts, error handling.

The main advantage is separation of concerns: a function does its own job, and auxiliary behaviour is attached with one line and removed just as easily.

Common mistakes

  • The wrapper does not return the result — after decoration the function gives back None.
  • No *args, **kwargs — TypeError when called with arguments.
  • Forgetting wraps — wrong __name__ and docstring, confusion in logs.
  • Missing brackets on a factory — @repeat instead of @repeat(3): the function ends up as the times parameter.
  • Heavy code in the decorator body — it runs at import time, not at call time.

Summary

Decorators in Python are higher-order functions: they take a function, return a wrapper, and @ is just a short notation for an assignment. Start with the template that has *args, **kwargs, return result and functools.wraps; for settings, add a factory level. Functions, closures and annotations, which decorators rest on, are practised hands-on in our Python course.

FAQ

Can a decorator be removed from a function?

If the decorator uses functools.wraps, the original is available through the __wrapped__ attribute: func.__wrapped__(...) calls the function without the wrapper. Otherwise you can only remove the @ line from the code.

When does the decorator's code run?

The body of the decorator itself runs once, when the function is declared, usually during module import. The code inside wrapper runs on every call of the decorated function.

Can methods of a class be decorated?

Yes, an ordinary decorator with *args, **kwargs works with methods too: self simply becomes the first element of args.

How is a decorator different from inheritance?

Inheritance changes a class's behaviour through a hierarchy, while a decorator wraps a specific function or class from the outside and combines easily with other decorators without changing the code.

Learn this topic on video

This topic is covered in depth with practice in the course “Python 3.15: от новичка до профи”.

Go to course