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

URL: https://skillaria.top/en/guides/python/python-decorators-explained

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:

| 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': }
```

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.


---
Source: Skillaria.Top (https://skillaria.top)
