Resourcesโ€บPython Tricksโ€บPython Decorators Explained Simply (With Real Examples)
๐ŸPython Tricksโ€” Python Decorators Explained Simply (With Real Examples)โฑ 7 min

Python Decorators Explained Simply (With Real Examples)

Decorators seem magical but they're just functions that wrap other functions. Here's how they work and when to use them.

๐Ÿ“…January 18, 2026โœTechTwitter.iopythondecoratorsfunctionspatterns

What Is a Decorator?

A decorator is a function that takes another function, adds some behavior, and returns a new function. That's it.

The @ syntax is just shorthand:

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

# Equivalent to:
def greet(name):
    return f"Hello, {name}"
greet = my_decorator(greet)

Building Your First Decorator

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@timer
def slow_function(n):
    time.sleep(n)
    return n

slow_function(1.5)
# slow_function took 1.503s

The wrapper function:

  1. Does something before (start = time.time())
  2. Calls the original function (func(*args, **kwargs))
  3. Does something after (print timing)
  4. Returns the result

The *args, **kwargs pattern passes through all arguments unchanged.


functools.wraps โ€” Preserve Metadata

Without functools.wraps, your decorated function loses its name and docstring:

@timer
def my_function():
    """Does something important."""
    pass

print(my_function.__name__)  # 'wrapper' โ† wrong!
print(my_function.__doc__)   # None โ† wrong!

Fix with @functools.wraps:

import functools

def timer(func):
    @functools.wraps(func)  # Preserves __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time()-start:.3f}s")
        return result
    return wrapper

Always use @functools.wraps in real decorators.


Decorators with Arguments

If you want @timer(unit='ms'), you need a decorator factory โ€” a function that returns a decorator:

def timer(unit='s'):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start = time.time()
            result = func(*args, **kwargs)
            elapsed = time.time() - start
            value = elapsed * 1000 if unit == 'ms' else elapsed
            print(f"{func.__name__} took {value:.1f}{unit}")
            return result
        return wrapper
    return decorator

@timer(unit='ms')
def fast_function():
    pass

# fast_function took 0.2ms

Real-World Decorator Examples

Retry with Exponential Backoff

import time
import functools

def retry(max_attempts=3, exceptions=(Exception,), backoff=1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts - 1:
                        wait = backoff * (2 ** attempt)
                        print(f"Attempt {attempt+1} failed. Retrying in {wait}s...")
                        time.sleep(wait)
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=3, exceptions=(ConnectionError,), backoff=0.5)
def fetch_data(url):
    # ... make HTTP request
    pass

Cache Results (Memoize)

Python has a built-in for this:

from functools import lru_cache, cache

# Cache with size limit
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2: return n
    return fibonacci(n-1) + fibonacci(n-2)

# Cache all results (Python 3.9+)
@cache
def expensive_lookup(key):
    return database.get(key)  # Only called once per unique key

Access Control

from functools import wraps

def require_auth(func):
    @wraps(func)
    def wrapper(request, *args, **kwargs):
        if not request.user.is_authenticated:
            raise PermissionError("Authentication required")
        return func(request, *args, **kwargs)
    return wrapper

def require_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            if request.user.role != role:
                raise PermissionError(f"Role '{role}' required")
            return func(request, *args, **kwargs)
        return wrapper
    return decorator

@require_auth
@require_role('admin')
def delete_user(request, user_id):
    pass

Class-Based Decorators

For stateful decorators, use a class with __call__:

class RateLimiter:
    def __init__(self, calls_per_second):
        self.calls_per_second = calls_per_second
        self.last_called = {}

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.time()
            last = self.last_called.get(func, 0)
            if now - last < 1.0 / self.calls_per_second:
                raise RuntimeError("Rate limit exceeded")
            self.last_called[func] = now
            return func(*args, **kwargs)
        return wrapper

rate_limiter = RateLimiter(calls_per_second=10)

@rate_limiter
def api_call():
    pass

Key Takeaways

  • A decorator is a function that wraps another function: wrapper = decorator(original)
  • @decorator syntax is shorthand for func = decorator(func)
  • Always use @functools.wraps to preserve function metadata
  • For decorators with arguments, add an outer factory function
  • Built-in @lru_cache and @cache for memoization โ€” no need to write your own
  • Class-based decorators for stateful behavior