Mastering Python Decorators: A Complete Guide

Programming
Date:July 18, 2026
Topic:
Mastering Python Decorators: A Complete Guide
3 min read

Python decorators look like magic. They're not. They're just functions that take functions and return functions. Once you see the mechanics, you can build tools that clean up cross-cutting concerns — logging, retries, caching, auth — without cluttering business logic.

First-Class Functions and Closures

Everything starts here. Functions are objects. You can assign them, pass them, nest them. A closure captures variables from its enclosing scope.

python
def outer(msg):
    def inner():
        print(msg)
    return inner

say_hi = outer("Hello")
say_hi()  # prints Hello

inner remembers msg after outer returns. That's the engine under every decorator.

The Basic Decorator Pattern

A decorator wraps a function, adding behavior before, after, or around the call.

python
def timer(func):
    import time
    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_add(a, b):
    time.sleep(0.1)
    return a + b

The @timer syntax is sugar for slow_add = timer(slow_add). The original function gets replaced by wrapper.

⚠️
WarningWithout functools.wraps, the wrapped function loses its name, docstring, and annotations. Debugging and introspection break.

Preserve Metadata with functools.wraps

python
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter()-start:.4f}s")
        return result
    return wrapper

@wraps(func) copies __name__, __doc__, __module__, __annotations__, and more. Always use it.

Decorators That Accept Arguments

Need a configurable retry? Three levels: the outer factory takes config, returns a decorator, which returns the wrapper.

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

@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch(url):
    ...

Class Decorators

A class with __call__ works as a decorator. Useful when you need state.

python
class RateLimiter:
    def __init__(self, max_calls, period):
        self.max_calls = max_calls
        self.period = period
        self.calls = []
    
    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            import time
            now = time.time()
            self.calls = [t for t in self.calls if now - t < self.period]
            if len(self.calls) >= self.max_calls:
                raise RuntimeError("Rate limit exceeded")
            self.calls.append(now)
            return func(*args, **kwargs)
        return wrapper

@RateLimiter(max_calls=10, period=60)
def api_call():
    ...

Real-World Patterns

PatternUse CaseKey Insight
Cache (lru_cache)Expensive pure functionsBuilt-in, thread-safe, maxsize controls memory
RetryFlaky I/OExponential backoff + jitter prevents thundering herd
LoggingObservabilityStructured logs with context, not print statements
AuthRoute protectionStack decorators: @login_required @admin_required
MetricsLatency/error ratesIncrement counters in wrapper, export to Prometheus
"

Decorators let you separate what a function does from how it's executed. That separation is the heart of clean architecture.

Python Software Foundation

Stacking Order Matters

Decorators apply bottom to top. @a @b def f() means a(b(f)). The innermost wrapper runs first.

python
@timer
@retry(times=3)
@RateLimiter(10, 60)
def fragile_api():
    ...
# Execution: timer wraps retry wraps rate-limiter wraps fragile_api
💡
TipWrite a tiny decorator library for your team. Standardize retry policies, log formats, and metric names. Consistency beats cleverness.


Start small. Wrap a function with a timer. Add @wraps. Build a retry with configurable backoff. Then compose them. The pattern scales from scripts to services. Your future self will thank you when a flaky dependency goes down and your code just works.

Share𝕏 Twitterin LinkedInin Whatsapp