Skip to content
Codeloom
Python

Advanced Python Decorators

Go beyond basic decorators: decorator factories, class decorators, functools.wraps, stacking behavior, and real-world patterns used in production Python code.

·7 min read · By Codeloom
Advanced 13 min read

What you'll learn

  • How decorator factories work and when to use them
  • Class-based decorators and the descriptor protocol
  • Why functools.wraps matters and what breaks without it
  • Stacking decorators and execution order
  • Production patterns: retry, caching, validation, auth

Prerequisites

  • Python functions as first-class objects
  • Basic decorator syntax (@decorator)
  • Familiarity with *args and **kwargs

A decorator is a function that takes a function and returns a modified function. That one-sentence definition gets you through tutorials. Production code demands more: decorators that accept arguments, decorators that work on classes, decorators that preserve metadata, and decorators that compose cleanly.

Quick review: the bare decorator

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

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

add(3, 4)
# Calling add
# add returned 7

The @log_calls syntax is equivalent to add = log_calls(add). The original add is replaced by wrapper.

functools.wraps: preserving identity

Without functools.wraps, the wrapper replaces the original function’s metadata.

@log_calls
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)    # "wrapper" -- wrong
print(add.__doc__)     # None -- lost

functools.wraps copies the original function’s __name__, __doc__, __module__, __qualname__, __dict__, and __wrapped__ to the wrapper.

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)    # "add"
print(add.__doc__)     # "Add two numbers."
print(add.__wrapped__)  # <function add at 0x...> -- the original

Always use functools.wraps. There is no good reason to skip it.

Decorator factories (decorators with arguments)

A bare decorator takes a function. A decorator factory takes arguments and returns a decorator. It is an extra level of nesting.

import functools
import time

def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    """Retry a function on failure."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts:
                        print(f"{func.__name__} failed (attempt {attempt}/{max_attempts}), retrying in {delay}s...")
                        time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=5, delay=2.0, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
    # might fail transiently
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

The call chain: retry(max_attempts=5, ...) returns decorator, then decorator(fetch_data) returns wrapper.

Making the parentheses optional

Sometimes you want a decorator that works with or without arguments: @retry and @retry(max_attempts=5).

import functools

def retry(_func=None, *, max_attempts=3, delay=1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise last_exception
        return wrapper
    
    if _func is not None:
        # Called without arguments: @retry
        return decorator(_func)
    # Called with arguments: @retry(max_attempts=5)
    return decorator

@retry
def func_a():
    pass

@retry(max_attempts=5)
def func_b():
    pass

The trick is the _func parameter. When used as @retry, Python passes the decorated function as _func. When used as @retry(...), _func is None and the factory returns the decorator.

Class-based decorators

Any callable can be a decorator. A class with __call__ works.

import functools

class CacheResult:
    """Cache function results with a max size."""
    
    def __init__(self, max_size=128):
        self.max_size = max_size
        self.cache = {}
    
    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args):
            if args in self.cache:
                return self.cache[args]
            
            result = func(*args)
            
            if len(self.cache) >= self.max_size:
                # Remove oldest entry
                oldest_key = next(iter(self.cache))
                del self.cache[oldest_key]
            
            self.cache[args] = result
            return result
        
        wrapper.cache_clear = lambda: self.cache.clear()
        wrapper.cache_info = lambda: {
            "size": len(self.cache),
            "max_size": self.max_size,
        }
        return wrapper

@CacheResult(max_size=256)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))
print(fibonacci.cache_info())

Class-based decorators shine when the decorator needs to maintain state across calls.

Decorating classes

Decorators can also be applied to classes. They receive the class object and return a modified (or new) class.

def add_repr(cls):
    """Auto-generate __repr__ from __init__ parameters."""
    import inspect
    
    params = list(inspect.signature(cls.__init__).parameters.keys())
    params = [p for p in params if p != "self"]
    
    def __repr__(self):
        args = ", ".join(f"{p}={getattr(self, p)!r}" for p in params)
        return f"{cls.__name__}({args})"
    
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

print(Point(3, 4))  # Point(x=3, y=4)

A more practical example: a singleton pattern.

def singleton(cls):
    """Ensure only one instance of the class exists."""
    instances = {}
    
    @functools.wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    
    return get_instance

@singleton
class DatabaseConnection:
    def __init__(self, url):
        self.url = url
        print(f"Connecting to {url}")

db1 = DatabaseConnection("postgres://localhost/mydb")
db2 = DatabaseConnection("postgres://localhost/mydb")
print(db1 is db2)  # True -- same instance

Stacking decorators

When you stack decorators, they apply bottom-up but execute top-down.

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"Hello, {name}"

print(greet("world"))
# <b><i>Hello, world</i></b>

The application order is: greet = bold(italic(greet)). When called, bold’s wrapper runs first (outermost), calls italic’s wrapper, which calls the original greet.

Real-world patterns

Timing decorator

import functools
import time

def timed(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

@timed
def process_batch(items):
    return [transform(item) for item in items]

Validation decorator

import functools

def validate_types(**type_hints):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
            
            for param_name, expected_type in type_hints.items():
                if param_name in bound.arguments:
                    value = bound.arguments[param_name]
                    if not isinstance(value, expected_type):
                        raise TypeError(
                            f"{param_name} must be {expected_type.__name__}, "
                            f"got {type(value).__name__}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_types(name=str, age=int)
def create_user(name, age, email=None):
    return {"name": name, "age": age, "email": email}

create_user("Alice", 30)           # works
create_user("Alice", "thirty")     # TypeError: age must be int, got str

Rate limiting decorator

import functools
import time
from collections import deque

def rate_limit(max_calls, period=60):
    """Allow at most max_calls within period seconds."""
    def decorator(func):
        calls = deque()
        
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.time()
            
            # Remove calls outside the window
            while calls and calls[0] < now - period:
                calls.popleft()
            
            if len(calls) >= max_calls:
                wait_time = calls[0] + period - now
                raise RuntimeError(
                    f"Rate limit exceeded. Try again in {wait_time:.1f}s"
                )
            
            calls.append(now)
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limit(max_calls=10, period=60)
def call_api(endpoint):
    return requests.get(endpoint)

Authorization decorator (Flask/FastAPI style)

import functools

def require_role(*roles):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Assume current_user is available in context
            user = get_current_user()
            if user.role not in roles:
                raise PermissionError(
                    f"Requires one of {roles}, user has {user.role}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin", "moderator")
def delete_post(post_id):
    db.posts.delete(post_id)

Async decorators

For async functions, your wrapper must also be async.

import functools
import asyncio

def async_retry(max_attempts=3, delay=1.0):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return await func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    if attempt < max_attempts:
                        await asyncio.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@async_retry(max_attempts=3, delay=2.0)
async def fetch_user(user_id):
    async with aiohttp.ClientSession() as session:
        async with session.get(f"/api/users/{user_id}") as resp:
            resp.raise_for_status()
            return await resp.json()

Debugging decorated functions

The __wrapped__ attribute (set by functools.wraps) lets you access the original function. This is valuable for testing and debugging.

@retry(max_attempts=3)
@timed
def my_function():
    pass

# Access the original, undecorated function
original = my_function.__wrapped__.__wrapped__

# In tests, you might want to skip the retry/timing behavior
result = my_function.__wrapped__(test_input)

Decorators are one of Python’s most powerful metaprogramming features. Start simple, use functools.wraps every time, and keep each decorator focused on a single concern. The patterns here cover the vast majority of what you will need in production.