Python Decorators Explained: A Practical Guide

Blog / Python · January 3, 2025 · Updated June 10, 2026 · 10 min read
Python Decorators Explained: A Practical Guide

A decorator is a function (or class) that takes another function and returns a new function with extra behaviour wrapped around it — without you editing the original. You apply one with the @ syntax placed directly above a function or method. Decorators let you keep code DRY and pull cross-cutting concerns — logging, timing, caching, authentication, retries, rate limiting — out of your business logic and into small, reusable wrappers.

If you have ever written @property, @staticmethod, or @app.route("/") in a web framework, you have already used decorators. This guide builds them up from first principles using current Python 3.12 / 3.13, then walks through the patterns we reach for most often on real projects.

Functions are first-class objects (the foundation)

Decorators only make sense once you internalise two facts about Python:

  1. Functions are objects. You can assign them to variables, pass them as arguments, and return them from other functions.
  2. Inner functions form closures. A nested function remembers the variables from the scope where it was defined, even after the outer function has returned.

That closure is exactly how a decorator "remembers" the original function it wrapped.

def outer(message):
    # `message` is captured by the closure below
    def inner():
        print(f"Closure says: {message}")
    return inner  # return the function object itself, do not call it


greet = outer("hello")
greet()  # Closure says: hello
print(greet.__closure__[0].cell_contents)  # hello

Writing a basic decorator

A decorator is just a function that accepts a function and returns a replacement. The replacement (wrapper) calls the original somewhere in the middle and can run code before and after it. Use *args, **kwargs so the wrapper works for any signature.

def simple_logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper


@simple_logger
def add(a, b):
    return a + b


print(add(2, 3))
# Calling add
# Finished add
# 5

The @simple_logger line is pure syntactic sugar. It is exactly equivalent to reassigning the name yourself:

def add(a, b):
    return a + b

add = simple_logger(add)  # @simple_logger is shorthand for this

Why functools.wraps matters

There is a subtle bug in the basic version above. Because the original function is replaced by wrapper, its identity is lost — add.__name__ is now "wrapper", the docstring is gone, and tools that introspect functions (debuggers, Sphinx docs, framework routers, help()) see the wrong metadata.

functools.wraps is itself a decorator you apply to the wrapper. It copies __name__, __doc__, __module__, __qualname__, the signature, and __wrapped__ from the original onto the wrapper. Always use it.

import functools


def simple_logger(func):
    @functools.wraps(func)  # preserve the wrapped function's metadata
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@simple_logger
def add(a, b):
    """Return the sum of a and b."""
    return a + b


print(add.__name__)  # add        (without wraps -> 'wrapper')
print(add.__doc__)   # Return the sum of a and b.
print(add.__wrapped__)  # <function add ...>  -> access the original

Decorators that take arguments (the decorator factory)

To pass options to a decorator — like @retry(times=3) — you need three nested levels. The outermost function accepts the decorator's arguments and returns a real decorator; the middle level is the decorator that receives the function; the inner level is the wrapper that runs at call time.

repeat(times=3)        -> returns decorator
    decorator(func)    -> returns wrapper
        wrapper(*a)    -> runs the actual call
import functools


def repeat(times):
    """A decorator factory: returns a decorator that runs `func` `times` 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

Stacking decorators (order matters)

You can apply several decorators to one function. They are applied bottom-up (the one nearest the function wraps first), but they execute top-down at call time (the outermost runs first). Reading the stack as nested calls makes the order obvious.

import functools


def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper


def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper


@bold
@italic
def greet(name):
    return f"Hi {name}"


print(greet("Sam"))  # <b><i>Hi Sam</i></b>
# Equivalent to: bold(italic(greet)) -> bold runs outermost

Class-based decorators with __call__

Any object whose class implements __call__ is callable, so a class instance can act as a decorator. The function is captured in __init__, and __call__ runs on every invocation. Classes are handy when the decorator needs to hold state between calls (a call counter, a cache, accumulated metrics). Remember to apply functools.wraps to the instance so introspection still works.

For a refresher on __call__ and the other dunder methods, see our Python magic methods reference.

import functools


class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)  # the class equivalent of @wraps
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} of {self.func.__name__}")
        return self.func(*args, **kwargs)


@CountCalls
def say_hi():
    print("hi")


say_hi()  # Call #1 of say_hi / hi
say_hi()  # Call #2 of say_hi / hi
print(say_hi.count)  # 2  -> state lives on the instance

Function decorator vs class-based decorator

Both approaches solve the same problem; pick based on whether you need to carry state.

Aspect Function decorator Class-based decorator
Boilerplate Minimal More (__init__ + __call__)
Holding state between calls Awkward (needs closures/attributes) Natural (instance attributes)
Preserving metadata @functools.wraps(func) functools.update_wrapper(self, func)
Readability for simple cases Best Heavier
Configurable parameters Triple-nested factory Args in __init__
Typical use Logging, timing, auth checks Counters, caches, registries

Decorating methods

Decorating an instance method works the same way as a plain function — the wrapper just needs to accept self as its first positional argument. Because *args already captures self, a well-written generic decorator usually needs no changes at all.

import functools


def log_method(func):
    @functools.wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"{type(self).__name__}.{func.__name__} called")
        return func(self, *args, **kwargs)
    return wrapper


class Account:
    def __init__(self, balance):
        self.balance = balance

    @log_method
    def deposit(self, amount):
        self.balance += amount
        return self.balance


Account(100).deposit(50)  # Account.deposit called

Built-in and standard-library decorators

You do not have to write everything yourself — Python ships with decorators that cover the most common needs. These are the ones worth knowing by heart.

Decorator What it does
@property Expose a method as a read-only attribute (with optional .setter/.deleter). See our Python properties guide.
@staticmethod A method that takes neither self nor cls — namespaced under the class.
@classmethod Receives the class (cls) instead of an instance — great for alternative constructors.
@functools.lru_cache(maxsize=128) Memoise results in a bounded LRU cache.
@functools.cache (3.9+) Unbounded memoisation — lru_cache(maxsize=None) with a shorter name.
@functools.cached_property Compute a property once, then cache it on the instance.
@dataclasses.dataclass Auto-generate __init__, __repr__, __eq__ and more for a data class.
@functools.wraps Copy metadata from the wrapped function onto your wrapper.
@functools.singledispatch Function overloading dispatched on the first argument's type.
@abc.abstractmethod Mark a method that subclasses must implement.
import functools


class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2  # used like c.area, no ()

    @classmethod
    def unit(cls):
        return cls(radius=1)  # alternative constructor

    @staticmethod
    def describe():
        return "A round shape"


@functools.cache  # 3.9+, unbounded memoisation
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)


print(Circle(2).area)   # 12.56636
print(Circle.unit().radius)  # 1
print(fib(50))          # fast, thanks to caching

Real-world decorator patterns

These are the patterns we reach for on day-to-day work. They are all production-shaped and copy-paste runnable on Python 3.12+.

Timing how long a function takes

import functools
import time


def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.4f}s")
    return wrapper


@timed
def crunch():
    sum(range(1_000_000))


crunch()  # crunch took 0.0123s

Retrying on failure with backoff

import functools
import time


def retry(times=3, delay=0.5, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as exc:
                    last_error = exc
                    print(f"Attempt {attempt} failed: {exc}")
                    if attempt < times:
                        time.sleep(delay * attempt)  # linear backoff
            raise last_error
        return wrapper
    return decorator


@retry(times=3, delay=1, exceptions=(ConnectionError,))
def fetch():
    raise ConnectionError("network down")

An auth / permission check

This is the pattern behind framework decorators such as Django's @login_required or @permission_required. We build similar guards on the Django and FastAPI projects we deliver, where decorators (and FastAPI dependencies) keep authorisation logic out of every view.

import functools


class PermissionError403(Exception):
    pass


def require_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if role not in getattr(user, "roles", ()):
                raise PermissionError403(f"{user.name} lacks role '{role}'")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator


class User:
    def __init__(self, name, roles):
        self.name, self.roles = name, roles


@require_role("admin")
def delete_everything(user):
    return "deleted"


print(delete_everything(User("Ada", {"admin"})))  # deleted
# delete_everything(User("Bob", {"viewer"}))  -> raises PermissionError403

Caching and rate limiting

For caching, prefer the battle-tested functools.lru_cache / functools.cache over hand-rolled dictionaries unless you need custom keys or TTLs. A minimal rate limiter shows how a class-based decorator naturally holds the per-call timestamps it needs as state.

import functools
import time


# Caching: let the standard library do the work.
@functools.lru_cache(maxsize=256)
def expensive_lookup(key):
    print(f"computing {key}")
    return key.upper()


expensive_lookup("a")  # computing a
expensive_lookup("a")  # (cached, no print)
print(expensive_lookup.cache_info())  # hits=1, misses=1, ...


class RateLimit:
    """Allow at most `calls` invocations per `period` seconds."""
    def __init__(self, calls, period):
        self.calls, self.period = calls, period
        self.timestamps = []

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            self.timestamps = [t for t in self.timestamps if now - t < self.period]
            if len(self.timestamps) >= self.calls:
                raise RuntimeError("rate limit exceeded")
            self.timestamps.append(now)
            return func(*args, **kwargs)
        return wrapper


@RateLimit(calls=2, period=60)
def send_email(to):
    return f"sent to {to}"

Where decorators fit in production code

Used well, decorators make intent obvious — a reader sees @timed, @retry(times=3), @require_role("admin") and instantly knows the cross-cutting behaviour without wading through it. Used badly, deeply nested or magic-heavy decorators hide control flow and make debugging painful. Our rule of thumb: keep each decorator small and single-purpose, always apply functools.wraps, and reach for the standard library before writing your own.

At MicroPyramid we have spent 12+ years and 50+ projects building Python, Django and FastAPI systems, and clean decorator usage is one of the small things that keeps a large codebase readable. If you want a team that writes Python like this, see our Python development services.

Frequently Asked Questions

What is a decorator in Python?

A decorator is a callable that takes a function (or class) and returns a modified version of it, letting you add behaviour — logging, timing, caching, access control — without editing the original code. You apply one with the @decorator syntax placed directly above the definition. Under the hood, applying @deco above def f(): ... is simply shorthand for f = deco(f).

Why should I use functools.wraps in my decorators?

When your wrapper replaces the original function, it overwrites the function's metadata — __name__ becomes "wrapper", the docstring disappears, and the signature is lost. functools.wraps copies __name__, __doc__, __qualname__, the signature, and a __wrapped__ reference from the original onto the wrapper, so debuggers, documentation tools and framework routers still see the correct information. Always add it.

How do I write a decorator that takes arguments?

Use three nested levels (a "decorator factory"). The outer function accepts the decorator's arguments and returns a decorator; that decorator accepts the function and returns a wrapper; the wrapper runs at call time. So @repeat(times=3) first calls repeat(times=3), which returns the actual decorator that wraps your function.

In what order do stacked decorators run?

Decorators are applied from the bottom up — the one closest to the function wraps it first — but they execute from the top down at call time, so the outermost decorator runs first. @a on top of @b over f is equivalent to a(b(f)): a is the outer layer that runs first, then hands control to b, then to f.

When should I use a class-based decorator instead of a function?

Reach for a class-based decorator (one that implements __call__) when the decorator needs to carry state between calls — a counter, an in-memory cache, accumulated metrics, or a registry. Instance attributes make that state natural to manage. For simple, stateless concerns like logging or timing, a plain function decorator is shorter and clearer.

Are @property, @staticmethod and @classmethod decorators?

Yes. They are all built-in decorators. @property turns a method into a managed attribute, @staticmethod defines a method that takes neither self nor cls, and @classmethod passes the class as the first argument (ideal for alternative constructors). Other stdlib favourites include @functools.lru_cache, @functools.cache (Python 3.9+), @functools.cached_property and @dataclasses.dataclass.

Do decorators slow down my code?

A decorator adds one extra function call per invocation, which is negligible for almost all workloads. The bigger performance story is usually positive: caching decorators like @functools.lru_cache can turn an expensive recomputation into an instant dictionary lookup. If a hot path is truly performance-critical, profile it — but in normal application code the readability benefit far outweighs the tiny call overhead.

Share this article