You are currently viewing Python Decorators Explained: Writing Practical, Reusable Function Wrappers
Photo by Mathews Jumba on Pexels

Python Decorators Explained: Writing Practical, Reusable Function Wrappers

  • Post category:Python
  • Post comments:0 Comments
  • Reading time:5 mins read
  • Post last modified:September 14, 2026

Decorators are one of those Python features that look like magic the first time you see the @ symbol, and then click permanently once you understand what’s actually happening underneath: a decorator is just a function that takes a function and returns a function. That’s the whole trick. Once that clicks, you can read (and write) decorators like @app.route, @lru_cache, or @pytest.fixture without treating them as syntax you memorize rather than code you understand.

This guide builds decorators from first principles, then walks through the patterns you’ll actually reach for in production code: timing, logging, retrying flaky calls, and caching expensive results.

Functions Are Just Objects

The foundation of decorators is that Python functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions, exactly like you would with a string or a list.

def greet(name):
    return f"Hello, {name}!"

say_hi = greet  # assign the function itself, not its result
print(say_hi("Ada"))  # Hello, Ada!


def call_twice(func, arg):
    func(arg)
    func(arg)

call_twice(print, "ping")  # prints "ping" twice

Because functions can be passed around, you can also define a function inside another function and return it. That nested, returned function is called a closure, and it’s the mechanism decorators are built on.

Building Your First Decorator

A decorator wraps a function with extra behavior without changing the original function’s code. Here’s a decorator that times how long a function takes to run:

import time

def timer(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_square(n):
    time.sleep(0.2)
    return n * n

slow_square(5)
# slow_square took 0.2001s

The line @timer above def slow_square(n): is exactly equivalent to writing:

def slow_square(n):
    time.sleep(0.2)
    return n * n

slow_square = timer(slow_square)

timer receives the original slow_square function, defines a new wrapper function that calls it while measuring time, and returns wrapper. The name slow_square in your module now points to wrapper, not the original function — but wrapper calls the original internally, so behavior is preserved and extended.

Don’t Lose Function Metadata

There’s a subtle bug in the decorator above: once wrapped, slow_square.__name__ is now "wrapper", and its docstring is gone. This breaks introspection tools, debuggers, and documentation generators. The fix is functools.wraps, which copies over the original function’s metadata:

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

Always use @functools.wraps(func) on your inner wrapper. It’s a one-line addition that saves you from confusing stack traces and broken help() output later.

Decorators That Take Arguments

Sometimes you want to configure the decorator itself, like @retry(times=3). This requires an extra layer of nesting: a function that takes the decorator’s arguments and returns the actual decorator.

import functools
import time

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


@retry(times=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
    # imagine this sometimes raises ConnectionError
    return call_flaky_api(url)

Here, retry(times=3, delay=0.5, ...) runs first and returns decorator. Python then applies decorator to fetch_data, exactly as in the simple case. This two-layer pattern (arguments → decorator → wrapper) is the standard shape for any configurable decorator, and it's worth memorizing because you'll see it constantly in real codebases.

A Practical Caching Decorator

Python's standard library already ships a production-grade caching decorator, functools.lru_cache, but building a simplified version is a great way to see how memoization works:

import functools

def simple_cache(func):
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper


@simple_cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(35))  # instant, instead of exponential blowup

Notice the cache dictionary lives in the enclosing scope of wrapper, not as a global or an instance attribute. Each decorated function gets its own private cache, because each call to simple_cache creates a fresh dictionary. In real code, prefer @functools.lru_cache(maxsize=128) unless you need custom eviction or key logic — it's implemented in C, thread-safe, and battle-tested.

Class-Based Decorators

Decorators don't have to be functions. Any object with a __call__ method works, which is useful when the decorator needs to track state across calls, like a call counter:

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
        print(f"Call #{self.calls} to {self.func.__name__}")
        return self.func(*args, **kwargs)


@CountCalls
def process_order(order_id):
    return f"processed {order_id}"

process_order("A1")
process_order("A2")
print(process_order.calls)  # 2

functools.update_wrapper(self, func) is the class-based equivalent of @functools.wraps, copying __name__, __doc__, and related attributes onto the instance.

Common Pitfalls

  • Forgetting *args, **kwargs: if your wrapper only accepts specific parameters, it breaks for any decorated function with a different signature. Always accept and forward both.
  • Mutable default cache keys: dictionaries and lists aren't hashable, so a naive cache decorator will throw a TypeError if called with a list argument. Either restrict inputs to hashable types or convert them before using as a cache key.
  • Stacking order confusion: when you stack multiple decorators, they apply bottom-up. @a above @b above def f means f = a(b(f)), so b runs closer to the original function and a wraps everything, including b's wrapper.
  • Decorating methods: when decorating instance methods, remember self is just the first positional argument — your *args capture handles it automatically as long as you don't hardcode a different signature.

Conclusion

A decorator is nothing more than a function that wraps another function and returns the replacement. Everything else — configurable decorators, class-based decorators, caching, retries — is a variation on that one idea: take a function in, return a function (or callable) out. Start by reaching for the standard library's functools.wraps, lru_cache, and cached_property before writing your own, but understanding how they work internally makes debugging decorated code and reading unfamiliar frameworks dramatically less mysterious.

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted