Python Decorators: Writing @decorator Functions, Decorator Factories and functools.wraps

Key takeaways

A decorator is a function that takes a function and returns another, but skipping functools.wraps or stacking decorators carelessly causes confusing bugs. The post builds from a simple wrapper to parameterized and class decorators and ends with an API retry decorator.

Introduction

Decorators are a powerful Python feature for adding behavior around functions (or classes). Under the hood, @decorator above a function definition is just syntax sugar — @timer\ndef slow_function(): ... is exactly equivalent to writing def slow_function(): ... and then slow_function = timer(slow_function) on the next line. Once that clicks, decorators stop feeling like magic: a decorator is simply a function that takes a function and returns a (usually different) function, which is what makes them composable and why they’re Python’s idiomatic answer to “run this code before/after every call to X” without duplicating that logic into every X.


Function decorators basics

A simple function decorator

The closure here is the actual mechanism doing the work: wrapper captures func from its enclosing scope even after timer() has returned, which is why the replacement function still knows which original function to call. This also explains a common early mistake — forgetting to return wrapper at the end of timer(). Without it, timer implicitly returns None, and @timer silently replaces slow_function with None, turning any later call into TypeError: 'NoneType' object is not callable at the call site, far from where the actual bug is.

def timer(func):
    """Measure how long a function runs."""
    import time
    
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    
    return wrapper
@timer
def slow_function():
    import time
    time.sleep(1)
    return "done"
result = slow_function()
# slow_function took: 1.0012s

Logging decorator

def logger(func):
    """Log function calls."""
    def wrapper(*args, **kwargs):
        print(f"[call] {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"[return] {result}")
        return result
    return wrapper
@logger
def add(a, b):
    return a + b
add(3, 5)
# [call] add((3, 5), {})
# [return] 8

Note that wrapper takes *args, **kwargs rather than a fixed parameter list — that’s what lets a single decorator work on functions with any signature (add(a, b), a zero-argument function, a function with keyword-only arguments) without writing a separate wrapper per shape. A decorator that hardcodes specific parameter names only works for functions with that exact signature, which defeats the point of writing it once and reusing it everywhere.


Decorators with arguments

Decorator factory

This is the part of decorators that trips people up most: @repeat(3) needs three levels of nested functions, not two, because repeat(3) has to first return something that itself behaves like a plain decorator (a function that takes func and returns a wrapper). Concretely: repeat(3) returns decorator; decorator is then called with greet (that’s the @ syntax doing its job) and returns wrapper; greet is rebound to wrapper. If you ever need a decorator that works both with and without arguments (@retry and @retry(3)), that’s what requires checking whether the first argument is callable — worth knowing about, though it’s a more advanced pattern than most day-to-day decorators need.

def repeat(times):
    """Run the wrapped function multiple times."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                result = func(*args, **kwargs)
                results.append(result)
            return results
        return wrapper
    return decorator
@repeat(3)
def greet(name):
    return f"Hello, {name}!"
print(greet("Alice"))
# ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']

Practical decorators

Memoization (caching)

This is the textbook demonstration of why decorators matter beyond syntax convenience: naive recursive fibonacci(n) recomputes the same sub-calls exponentially many times (calculating fibonacci(30) calls fibonacci(28) twice, fibonacci(26) four times, and so on), but wrapping it in @memoize turns an exponential-time function into a linear-time one with zero changes to the function’s actual logic — the cache dictionary just remembers results it’s already computed. The if args not in cache check is the whole trick, but it has a real limitation: args becomes the dictionary key, so this specific implementation only works when every argument is hashable (numbers, strings, tuples) — pass it a list or dict argument and it raises TypeError: unhashable type. Python’s standard library has functools.lru_cache, which does the same thing with better edge-case handling and a configurable cache size limit, and is almost always preferable to writing this by hand.

def memoize(func):
    """Cache function results."""
    cache = {}
    
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    return wrapper
@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100))  # very fast!

Authentication decorator

def require_auth(func):
    """Require an authenticated user."""
    def wrapper(user, *args, **kwargs):
        if not user.get('is_authenticated'):
            raise PermissionError("Login required")
        return func(user, *args, **kwargs)
    return wrapper
@require_auth
def delete_post(user, post_id):
    return f"Post {post_id} deleted"
# Usage
user = {'name': 'Alice', 'is_authenticated': True}
print(delete_post(user, 123))  # Post 123 deleted
guest = {'name': 'guest', 'is_authenticated': False}
# delete_post(guest, 123)  # PermissionError!

This pattern is exactly what web frameworks like Flask and FastAPI use under the hood for route-level auth checks (@login_required and similar) — the decorator intercepts the call before the real function body runs, and either lets it through or raises before any of the actual logic executes. Notice the tradeoff versus the earlier decorators: this one hardcodes user as the first positional parameter instead of using pure *args, **kwargs, which makes it less generic (it only works on functions where the first argument is a user object) but lets it actually inspect and act on that argument — a decorator using only *args/**kwargs can wrap any function, but can’t easily reach into a specific argument’s value the way this one needs to.


Class decorators

def singleton(cls):
    """Singleton pattern."""
    instances = {}
    
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    
    return get_instance
@singleton
class Database:
    def __init__(self):
        print("Database connection")
        self.connection = "Connected"
# Usage
db1 = Database()  # Database connection
db2 = Database()  # no extra print (same instance)
print(db1 is db2)  # True

A class decorator works the same way as a function decorator, just applied to a class object instead of a function object — singleton receives Database itself (the class, not an instance), and Database gets rebound to whatever singleton returns. Here it returns get_instance, a plain function, which means Database is no longer actually a class afterward — it’s a function that happens to be called with the same syntax (Database()) as instantiating a class. That’s worth knowing because it has a real consequence: code that does isinstance(db1, Database) or tries to subclass Database after this decorator is applied will break, since Database no longer refers to the original class object.


functools.wraps

Preserve metadata

Without @wraps(func), wrapper — being its own, separate function object — has its own __name__ ('wrapper'), its own __doc__ (None, unless you write one), and its own module path, all of which shadow the original function’s metadata once the decorator replaces it. This isn’t just cosmetic: tools that introspect functions at runtime — debuggers, help(), API documentation generators, and Python’s own pickle module in some cases — read __name__ and __doc__ to know what they’re looking at, and every one of them gets misled if every decorated function in a codebase reports itself as 'wrapper'. functools.wraps copies that metadata across automatically, which is why it’s considered close to mandatory in real decorator code rather than merely good style.

from functools import wraps
def my_decorator(func):
    @wraps(func)  # keep original function metadata
    def wrapper(*args, **kwargs):
        """Wrapper docstring."""
        return func(*args, **kwargs)
    return wrapper
@my_decorator
def greet(name):
    """Greeting function."""
    return f"Hello, {name}!"
print(greet.__name__)  # greet (without wraps you'd see wrapper)
print(greet.__doc__)   # Greeting function.

Real-world example: API retry decorator

This combines everything above into a shape you’ll actually see in production code: a decorator factory (retry(max_attempts, delay)) wrapping a decorator (decorator) wrapping the real function (wrapper), with @wraps(func) preserving metadata along the way. The if attempt == max_attempts - 1: raise line is the part worth reading carefully — it re-raises the original exception on the final attempt rather than swallowing it, which matters because a retry decorator that eats every failure silently (returning None after exhausting attempts, say) turns a real, debuggable error into a confusing downstream bug where code just gets an unexpected None. A fixed delay between attempts is also a simplification worth flagging: real-world retry logic against external APIs usually wants exponential backoff (doubling the delay each attempt) so that a struggling service isn’t hit with a steady drumbeat of retries from every failing client at once.

import time
from functools import wraps
def retry(max_attempts=3, delay=1):
    """Retry on failure."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"Attempt {attempt + 1} failed: {e}")
                    time.sleep(delay)
        return wrapper
    return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    import random
    if random.random() < 0.7:
        raise ConnectionError("connection failed")
    return f"data from {url}"

Stacking decorators and functools.wraps

Decorator patterns

# ✅ Stacking multiple decorators
@timer
@logger
@retry(3)
def important_function():
    pass
# Execution order: retry → logger → timer → underlying function
# Decorators apply bottom-up when wrapping (closest to the function wraps
# first), but that means the outermost decorator, @timer, runs its own
# pre/post logic *first* on each actual call, since it's the outermost
# layer of the resulting nested closures — read stacked decorators
# bottom-to-top to find out what wraps what, and outside-in to find out
# what executes first when the function is actually called.
# ✅ Always prefer functools.wraps
from functools import wraps
def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Next in the series

The series moves on to web development with Flask web basics, where route registration with @app.route is the decorator-with-arguments pattern from this post in everyday use.