Mastering Python Decorators: A Practical Guide

Programming
Date:August 8, 2026
Topic:
Mastering Python Decorators: A Practical Guide
3 min read

You've seen @property, @staticmethod, and @lru_cache scattered across Python codebases. Maybe you've even copied a retry decorator from Stack Overflow. But if you treat decorators as magic syntax rather than callable objects, you're leaving one of Python's most expressive metaprogramming tools on the table.

What a Decorator Actually Is

A decorator is a callable that takes a callable and returns a callable. That's it. The @ syntax is syntactic sugar:

python
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

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

# Equivalent to:
# greet = my_decorator(greet)

The original function gets replaced by whatever the decorator returns. This simple mechanism powers cross-cutting concerns: logging, timing, authentication, caching, retries, and more.

Preserve Metadata with <code>functools.wraps</code>

Without wraps, your decorated function loses its name, docstring, and annotations. This breaks introspection, debugging, and tools like Sphinx or FastAPI's automatic docs.

python
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        import time
        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
💡
TipAlways use @wraps. It copies __name__, __qualname__, __doc__, __module__, __annotations__, and __dict__ from the wrapped function.

Decorators That Accept Arguments

Real-world decorators often need configuration. Add a factory layer:

python
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts:
                        raise
                    import time
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
    ...

Class Decorators and Method Decorators

Decorators work on classes too. A class decorator receives the class object and returns a new class (or the same one modified).

python
def add_repr(cls):
    @wraps(cls)
    class Wrapper(cls):
        def __repr__(self):
            attrs = ', '.join(f"{k}={v!r}" for k, v in self.__dict__.items())
            return f"{cls.__name__}({attrs})"
    return Wrapper

@add_repr
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

# User('alice', '[email protected]')

For methods, the same rules apply. But watch self binding: the wrapper receives the instance as the first argument automatically.

Stacking Decorators

Multiple decorators apply bottom-to-top (closest to the function first). Order matters.

python
@timer
@retry(max_attempts=3)
@log_calls
def fragile_operation():
    ...
# Execution order: log_calls -> retry -> timer
⚠️
WarningA retry decorator wrapping a timer will re-time each attempt. Put timer inside retry if you want total elapsed time.

Built-in Decorators You Should Know

DecoratorModulePurpose
@propertybuiltinsManaged attribute access
@classmethodbuiltinsClass-bound method
@staticmethodbuiltinsNamespace-bound method
@lru_cachefunctoolsMemoization
@cached_propertyfunctoolsPer-instance cached property
@dataclassdataclassesAuto-generate __init__, __repr__, etc.
@contextmanagercontextlibGenerator-based context manager
@singledispatchfunctoolsFunction overloading by type

Real-World Pattern: Rate Limiting

python
import time
from functools import wraps
from collections import defaultdict
from threading import Lock

def rate_limit(calls_per_second):
    min_interval = 1.0 / calls_per_second
    last_called = defaultdict(float)
    lock = Lock()
    
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with lock:
                elapsed = time.monotonic() - last_called[func]
                if elapsed < min_interval:
                    time.sleep(min_interval - elapsed)
                last_called[func] = time.monotonic()
            return func(*args, **kwargs)
        return wrapper
    return decorator

Best Practices Checklist

  • Use @wraps on every wrapper.
  • Accept *args, **kwargs and pass them through unchanged.
  • Return the wrapped function's result.
  • Handle exceptions explicitly; don't swallow them unless intended.
  • Keep decorators focused: one concern per decorator.
  • Prefer composition over monolithic decorators.
  • Document the decorator's behavior and parameters.
  • Test the decorated function, not just the wrapper.


Decorators aren't syntax tricks. They're first-class functions that let you separate cross-cutting logic from business logic. Start small: wrap a function with timing, add @wraps, then parameterize it. Next time you copy-paste logging boilerplate across ten functions, write a decorator instead. Your future self will thank you.

Share𝕏 Twitterin LinkedInin Whatsapp