Decorators in Python: What They Are, How They Work and How to Write Your Own
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:
*args, **kwargs— the wrapper accepts any arguments and passes them on unchanged.return result— do not forget to return the result, otherwise the decorated function will always give backNone.@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:
| Decorator | What it does |
|---|---|
@property | turns a method into a read-only attribute; with @x.setter, into a managed attribute |
@staticmethod | a class method without self, an ordinary function in the class namespace |
@classmethod | the method receives the class cls instead of an instance — this is how alternative constructors are written |
@functools.cache, @functools.lru_cache | cache results by arguments |
@functools.wraps | copies the function's metadata onto the wrapper |
@dataclasses.dataclass | generates __init__, __repr__, __eq__ for a class |
@contextlib.contextmanager | turns 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.cachefor 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—TypeErrorwhen called with arguments. - Forgetting
wraps— wrong__name__and docstring, confusion in logs. - Missing brackets on a factory —
@repeatinstead of@repeat(3): the function ends up as thetimesparameter. - 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.
This topic is covered in depth with practice in the course “Python 3.15: от новичка до профи”.
Go to course