Python Decorators Explained: From @property to Retry Logic in 15 Minutes (2026)
Decorators are the feature that separates "knows Python" from "reads Python fluently". They look magical, but they're mechanical: @decorator on a function is exactly f = decorator(f). That's it. Everything else in this tutorial is that one line applied in different shapes.
The one-line theory
When you write:
Python transforms it into:
That's the entire mechanic. @decorator_name above a def runs def_name = decorator_name(def_name) after the definition. If you internalise this, decorators stop being mysterious forever.
Writing your first decorator (60 seconds)
A decorator is any callable that takes a function and returns a function. The typical shape:
The *args, **kwargs in the wrapper is a defensive pattern — it lets the decorator work on any function regardless of signature. Skip it and you'll get TypeError: wrapper() takes 0 positional arguments but 2 were given the moment someone decorates a function with parameters.
The @functools.wraps fix
One subtle bug: after decorating, add.__name__ is "wrapper", not "add". Docstrings and type hints also vanish. The fix is one import:
Rule: every decorator you write should use `@functools.wraps(fn)`. It costs one line and prevents an entire category of debugging pain (introspection, pytest fixtures, logging formatters, pickle — all break silently without it).
The four decorators you'll see in every codebase
1. @property — computed attributes
@property makes a method callable as if it were an attribute. Perfect for read-only derived values. There's also @area.setter for the write path, but 90% of @property uses are read-only.
2. @staticmethod and @classmethod
@classmethod gets cls (the class itself) and is used for alternative constructors. @staticmethod gets nothing special and is a plain function that just happens to live inside the class namespace.
3. @functools.cache — memoisation
One of the most powerful stdlib decorators. Automatically memoises the function based on its arguments. Requires all args to be hashable (no lists / dicts as arguments — use tuples).
4. @dataclasses.dataclass — boilerplate elimination
@dataclass reads the class-level type annotations and generates __init__, __repr__, __eq__, and (with frozen=True) __hash__ automatically. It's the single biggest quality-of-life improvement in Python 3.7+.
Writing a @retry decorator (real-world example)
A decorator that retries a function on failure with exponential backoff:
Notice the three nested functions: retry accepts the config args and returns decorator; decorator accepts the function and returns wrapper; wrapper runs the retry loop. This three-layer pattern is what any decorator-with-arguments looks like — memorise the shape, not the specific example.
Class-based decorators (rare but useful)
A decorator is any callable. Classes with __call__ work too:
Use when the decorator needs to hold state that outlives a single call (a counter, a cache, a rate-limit bucket).
Common pitfalls
1. Forgetting `@functools.wraps` — introspection breaks silently.
2. Mutable default arguments in the decorator itself — the classic def wrapper(x, cache=[]) bug bites here too.
3. Decorating methods without accepting `self` — remember self is just a positional argument; *args, **kwargs catches it fine.
4. Ordering matters when stacking: @a\n@b\ndef f() is f = a(b(f)). Read stacks bottom-up.
Practice
Write a @timing decorator that prints how long the wrapped function takes to run, then apply it to a function that computes sum(range(10_000_000)). The whole decorator is 8 lines with functools.wraps + time.perf_counter.
Once you internalise that @x is f = x(f), decorators become one of Python's most productive features. They're everywhere in real codebases: Flask/FastAPI routes (@app.route), pytest fixtures (@pytest.fixture), Django views (@login_required), Pydantic validators (@field_validator). Learning to write them yourself unlocks the ability to read every one of those.
Next step: 10 hands-on decorator lessons — build @retry, @memoise, @rate_limit, and @requires_auth from scratch with real Python running in your browser.