Understanding Python Decorators with Practical Examples

A decorator is just a function that takes a function and returns a replacement — but forget `@functools.wraps` and you silently lose `__name__`, `__doc__`, and your sanity. Here's how decorators really work, from arguments to stacking.

Understanding Python Decorators with Practical Examples

You write a decorator, run your test suite, everything passes — then you open your logs two days later and every single timed call says wrapper. Your timing data is useless. Your function names are gone. Your docstrings are empty strings.

Nothing crashed. That's the insidious part. A decorator built without functools.wraps silently overwrites the identity of the function it wraps, and you won't notice until something downstream depends on __name__ — a debugger, a tracing tool, an API that builds routes from function names, or a teammate who simply runs help(my_function) and gets nothing useful back.

Understanding Python decorators is less about memorizing the @ syntax and more about internalizing one idea: a decorator is a function that takes a function and returns a replacement function. Everything else — arguments, stacking, classes — is a variation on that single move.

Key Takeaways

  • A decorator receives a callable and returns a callable, usually a wrapper defined inside it.
  • Always apply @functools.wraps to the wrapper or you lose __name__, __doc__, and the signature.
  • Decorators that accept arguments require three nested functions, not two.
  • When you stack decorators, they apply bottom-up at definition, and the wrapper closest to the function runs first.
  • Class-based decorators are the right tool when you need state that persists across calls.

What a Python decorator actually is (and why the syntax confuses people)

The @ symbol is pure convenience. This:

@timer
def fetch_data():
    ...

means exactly this:

def fetch_data():
    ...
fetch_data = timer(fetch_data)

That's it. No magic, no special runtime machinery. Python evaluates the function object, hands it to timer, and rebinds the name to whatever timer returns. Once that clicks, every "advanced" decorator pattern becomes a mechanical extension rather than a mystery.

Why use decorators in Python?

The honest answer is separation of concerns. Timing, caching, logging, and permission checks are cross-cutting: you need them on twenty functions, but they have nothing to do with what those functions compute. Inlining a cache lookup at the top of every function pollutes the logic and guarantees that one day you'll forget to invalidate it somewhere.

A decorator keeps that concern in one place. I once inherited a codebase where retry logic had been copy-pasted into eleven different HTTP calls. Two of them had a different backoff multiplier. Nobody knew which was correct. Converting those eleven sites into a single @retry decorator took an afternoon and eliminated a whole category of bug reports.

Control flow you can see is control flow you can reason about. That's the real argument.

The metadata trap that breaks real debugging

Here is a decorator I wrote early on, and I'll admit it was naive:

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

Using func.__name__ inside the wrapper works — but the wrapper itself has no identity. Call fetch_data.__name__ from outside and you get "wrapper". Do it with ten decorated functions and your profiling output is ten identical lines.

The one-line fix: functools.wraps

import functools

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:.4f}s")
        return result
    return wrapper

That single line copies __module__, __name__, __qualname__, __doc__, and __dict__ from the original onto the wrapper. It also updates __wrapped__, which is how tools like inspect.signature() manage to recover the original parameter list.

Skip it and you break:

  • help() output, which is what your teammates actually read.
  • Framework route registration that keys off function names.
  • Test runners that report failures by function name.
  • Any introspection library doing signature-based dependency injection.

Treat @functools.wraps as non-optional. There is no realistic scenario where you want the wrapper's identity to leak.

Decorators with arguments: the nesting that trips everyone

Plain decorators take the function. But what if you want to configure the decorator itself — a retry count, a log level?

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

Three levels, three jobs:

  1. retry(attempts=3) — a factory that captures your configuration.
  2. decorator(func) — receives the function, returns the wrapper.
  3. wrapper — what actually replaces your function.

The reason this confuses people is that @retry(attempts=5) is not a decorator. It's a call that produces a decorator. Python evaluates the expression first, then applies the result to your function. Read it right-to-left and it stops being weird.

Multiple decorators Python: order matters more than you think

@log
@retry(attempts=3)
def send_payload():
    ...

Decoration applies bottom-up: retry wraps send_payload first, then log wraps that result. At call time, execution runs top-down — log fires, then retry, then the original body.

Practical consequence: put @log above @retry and you log one call. Put it below and you log each retry attempt. I've seen both behaviors wanted in different services, and there's no way to guess — you have to trace it.

Class decorator Python: when state needs to live somewhere

A decorator doesn't have to be a function. Any callable works, which means a class with __call__ is fair game.

Class decorator Python: when state needs to live somewhere
class RateLimit:
    def __init__(self, max_calls):
        self.max_calls = max_calls
        self.calls = 0

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if self.calls >= self.max_calls:
                raise RuntimeError("limit exceeded")
            self.calls += 1
            return func(*args, **kwargs)
        return wrapper

Why bother? Because self.calls persists across every invocation. A closure-based counter can do the same thing with nonlocal, but the class version is easier to read, trivial to unit-test, and gives you a place to add a reset method.

ApproachState handlingBest fit
Plain function decoratorNoneTiming, logging, wraps-only concerns
Decorator factoryConfig captured in closureParameterized retry, feature flags
Class decoratorMutable instance attributesCounters, caches, accumulators
Built-in @property, @staticmethodManaged by the descriptor protocolAttribute access on class members

The decorators you already use without thinking

@property, @staticmethod, and @classmethod are decorators shipped with the language. They behave differently under the hood — they're descriptors, not simple wrappers — but they occupy the same syntactic slot. If you understand the function-returning-a-function model, you're already 80% of the way to understanding why @property can intercept attribute access.

When decorators actively make your code worse

Real talk: decorators have a cost, and I've paid it.

Stack four or five of them on a single function and reading that function becomes archaeology. You look at the body, then jump to five definitions to figure out what actually executes. Tracebacks get longer and harder to parse. Type checkers sometimes lose the signature unless the wrapper is annotated with ParamSpec and TypeVar — and let me tell you, writing that annotation correctly the first time is not something anyone does.

My rule: two decorators on one function is comfortable, three is a smell, five means the function is doing too much. If you need five, the underlying design probably wants to be split rather than wrapped.

There's also the async problem. A synchronous decorator applied to an async def function returns a regular function, and awaiting it raises a TypeError. You need a wrapper defined with async def and await func(...). It's a five-second fix once you've been burned, and a twenty-minute debugging session before that.

The habit that matters

Write one decorator this week — a timer or a logger, doesn't matter — and deliberately leave out functools.wraps the first time. Print __name__ and __doc__ on the decorated function. Watch what disappears. Then add the line back and watch it return.

That thirty-second experiment will teach you more than any tutorial, because the next time you see wrapper in a stack trace at 2 a.m., you'll know exactly which line is missing — and you'll fix it before your coffee finishes brewing.

Yvonne Fairbanks

Yvonne Fairbanks

Yvonne Fairbanks is a seasoned technologist with deep expertise in cloud computing, DevOps, and distributed systems. Known for her practical approach to Kubernetes and scalable infrastructure, she helps teams navigate complex deployments with clarity and confidence. Her writing blends technical rigor with a personable style that makes advanced topics accessible to practitioners at all levels.

See all articles →

Related articles