PythonIntermediate

Python Decorators Explained: What They Are and How to Write Your Own

A decorator is just a function that wraps another function — no special magic, once you see the plain function underneath the @ syntax. A practical walkthrough with real, useful examples.

DevFieldGuideJuly 19, 2026 (updated July 26, 2026)6 min read
Share:

@decorator syntax looks like special language magic the first time you see it — it's actually just a function that takes a function and returns a (usually wrapped) function, with @ as syntactic sugar for calling it.

What @ actually desugars to

python
@my_decorator
def greet():
    print("Hello")

is exactly equivalent to:

python
def greet():
    print("Hello")
 
greet = my_decorator(greet)

@my_decorator above def greet is shorthand for "call my_decorator with greet, and reassign greet to whatever it returns." Nothing more mysterious than that.

Writing a basic decorator

python
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:.4f}s")
        return result
    return wrapper
 
@timer
def slow_calculation(n):
    return sum(i * i for i in range(n))
 
slow_calculation(1_000_000)
# slow_calculation took 0.0821s

wrapper is what actually runs when you call slow_calculation(...) — it calls the real function, measures the time around that call, and returns the real result unchanged. *args, **kwargs let the wrapper forward any arguments the original function accepts, regardless of its actual signature.

functools.wraps: don't skip it

Without @functools.wraps(func), the decorated function's introspection metadata (__name__, __doc__) gets replaced by the wrapper's own — breaking anything that relies on it, including debuggers, documentation generators, and simple print(func.__name__) calls:

python
# Without functools.wraps
print(slow_calculation.__name__)  # "wrapper" — wrong, confusing
# With functools.wraps
print(slow_calculation.__name__)  # "slow_calculation" — correct

A decorator that takes its own arguments

Decorators that need configuration (a retry count, a cache size) require an extra layer — a function that returns the actual decorator:

python
def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    print(f"Attempt {attempt + 1} failed: {e}")
            raise last_error
        return wrapper
    return decorator
 
@retry(times=3)
def flaky_api_call():
    ...

@retry(times=3) calls retry(3) first, which returns the real decorator function, which then wraps flaky_api_call — three levels of nesting, but each layer has exactly one job: retry captures the argument, decorator does the actual wrapping, wrapper is what runs at call time.

Built-in decorators you're probably already using

python
class Circle:
    def __init__(self, radius):
        self._radius = radius
 
    @property
    def area(self):
        return 3.14159 * self._radius ** 2
 
    @staticmethod
    def from_diameter(diameter):
        return Circle(diameter / 2)
 
    @classmethod
    def unit_circle(cls):
        return cls(radius=1)

@property makes circle.area callable like an attribute instead of circle.area(). @staticmethod and @classmethod are the same wrapping mechanism, just built into the language for these two specific, common patterns.

Stacking multiple decorators

python
@timer
@retry(times=3)
def fetch_data():
    ...

Decorators apply bottom-up — retry wraps fetch_data first, then timer wraps the already-retry-wrapped function. This means timer measures the total time including all retry attempts, not just one call — the order of stacked decorators genuinely changes behavior, not just cosmetics.

Class-based decorators

A decorator doesn't have to be a function — any callable object works, and a class implementing __call__ is a genuinely useful alternative when the decorator needs to maintain its own state across calls:

python
class CallCounter:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0
 
    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call #{self.call_count} to {self.func.__name__}")
        return self.func(*args, **kwargs)
 
@CallCounter
def process_order(order_id):
    ...
 
process_order(1)  # Call #1 to process_order
process_order(2)  # Call #2 to process_order
print(process_order.call_count)  # 2

functools.update_wrapper is the class-based equivalent of functools.wraps — same purpose, applied to a class instance instead of a nested function. This pattern is worth reaching for specifically when the decorator needs its own persistent state (a call count, a cache) that's cleaner to model as object attributes than as variables closed over by a nested function.

Preserving function signatures for type checkers

For a codebase using type hints seriously, a decorator that doesn't preserve the wrapped function's signature can confuse static type checkers into losing track of the real parameter and return types. The ParamSpec typing feature addresses this directly:

python
from typing import Callable, TypeVar
from typing_extensions import ParamSpec
 
P = ParamSpec("P")
T = TypeVar("T")
 
def timer(func: Callable[P, T]) -> Callable[P, T]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        # ... timing logic ...
        return func(*args, **kwargs)
    return wrapper

Without ParamSpec, a type checker typically can't verify that calls to the decorated function match the original function's real parameter types — worth adopting for any decorator in a codebase that takes type checking seriously, since a decorator is exactly the kind of wrapping code that silently erases type information without this.

functools.lru_cache: memoization as a built-in decorator

Python ships a production-ready memoization decorator, rather than needing to hand-roll the cache pattern shown earlier for most cases:

python
from functools import lru_cache
 
@lru_cache(maxsize=128)
def expensive_computation(n):
    return sum(i * i for i in range(n))
 
expensive_computation(1_000_000)  # slow, computes and caches
expensive_computation(1_000_000)  # instant, returns cached result

maxsize=128 caps the cache at the 128 most recently used distinct argument combinations, evicting the least-recently-used entry once that limit is reached — worth knowing about before reaching for a hand-written cache decorator, since lru_cache already handles eviction correctly.

Common mistakes

Common mistakes
  • Forgetting functools.wraps, silently breaking __name__, __doc__, and anything else that introspects the decorated function — a small omission with real downstream effects on debugging and documentation tools.
  • Writing a decorator that doesn't accept *args, **kwargs, breaking the moment it's applied to a function with a different signature than whatever it was originally tested against.
  • Not considering stacking order when applying multiple decorators — since they apply bottom-up, the visual top-to-bottom order in the source doesn't match the actual wrapping order, which can produce surprising behavior if the decorators aren't independent of each other.
  • Doing expensive work directly at decoration time (inside decorator, not inside wrapper) when it should happen per-call instead — code outside the innermost wrapper function runs once, at decoration time, not on every call to the decorated function.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Python

View all