Python Decorators — The Magic Behind the @ Symbol

Sanjeev SharmaSanjeev Sharma
4 min read

Advertisement

Introduction

Why This Matters

Decorators are one of Python's most powerful and widely-used features. Behind every @app.route() in Flask, every @router.get() in FastAPI, and every @login_required in Django is a decorator. They allow you to add behavior to functions or classes without modifying their source code, following the Open/Closed Principle.

Understanding decorators is essential not just for using popular frameworks but for writing your own reusable utilities: logging wrappers, authentication guards, retry logic, caching, rate limiting, and timing functions. They appear frequently in backend engineering interviews and code reviews as a signal of Python proficiency.

Decorators are just functions that accept and return functions — once you understand that, their syntax becomes intuitive.

How Decorators Work

A decorator is a callable that takes a function and returns a modified version of it.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before function call")
        result = func(*args, **kwargs)
        print("After function call")
        return result
    return wrapper
 
@my_decorator
def say_hello(name: str) -> str:
    return f"Hello, {name}!"
 
# @my_decorator is syntactic sugar for:
# say_hello = my_decorator(say_hello)
 
print(say_hello("Alice"))
# Before function call
# After function call
# Hello, Alice!

Preserving Function Metadata with functools.wraps

Without @wraps, the decorated function loses its name and docstring.

import functools
 
def my_decorator(func):
    @functools.wraps(func)  # preserves __name__, __doc__, __annotations__
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
 
@my_decorator
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b
 
print(add.__name__)  # 'add' (not 'wrapper')
print(add.__doc__)   # 'Add two numbers.'

Practical Decorators

Timing Decorator

import functools
import time
 
def timer(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
 
@timer
def slow_function():
    time.sleep(0.5)
 
slow_function()  # slow_function took 0.5001s

Retry Decorator

import functools
import time
 
def retry(max_attempts: int = 3, delay: float = 1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator
 
@retry(max_attempts=3, delay=0.5)
def fetch_data(url: str) -> dict:
    import httpx
    response = httpx.get(url)
    response.raise_for_status()
    return response.json()

Caching Decorator (with functools.lru_cache)

import functools
 
@functools.lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
 
print(fibonacci(50))  # extremely fast due to caching
print(fibonacci.cache_info())  # CacheInfo(hits=48, misses=51, ...)

Decorator Factories (Decorators with Arguments)

When a decorator needs parameters, you add an outer function.

import functools
 
def require_role(role: str):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get("role") != role:
                raise PermissionError(f"Requires role: {role}")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator
 
@require_role("admin")
def delete_user(user: dict, user_id: int) -> str:
    return f"Deleted user {user_id}"
 
admin = {"name": "Alice", "role": "admin"}
print(delete_user(admin, 42))  # 'Deleted user 42'

Class Decorators

You can also use classes as decorators by implementing __call__.

import functools
 
class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0
 
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} to {self.func.__name__}")
        return self.func(*args, **kwargs)
 
@CountCalls
def greet(name: str) -> str:
    return f"Hello, {name}!"
 
greet("Alice")  # Call #1 to greet
greet("Bob")    # Call #2 to greet
print(greet.count)  # 2

Common Mistakes

  • Forgetting @functools.wraps(func) — causes loss of function metadata
  • Calling the decorator instead of passing it: @timer() vs @timer (without arguments)
  • Not returning the result of the inner function call
  • Using mutable default arguments in decorator factories
  • Stacking many decorators without understanding the order they apply (bottom-up)

Best Practices

  • Always use @functools.wraps(func) in your wrapper functions
  • Keep decorators focused on a single concern — logging, timing, auth, etc.
  • Prefer functools.lru_cache over a hand-rolled caching decorator
  • Use decorator factories when you need configurable behavior
  • Document what your decorator does in its docstring

Key Takeaways

  • A decorator is a function that takes a function and returns a modified function
  • @decorator is syntactic sugar for func = decorator(func)
  • @functools.wraps(func) preserves the wrapped function's metadata
  • Decorator factories take arguments by adding an outer function layer
  • functools.lru_cache is the standard library's caching decorator
  • Class-based decorators implement __call__ to be callable like functions
  • Popular frameworks like Flask, FastAPI, and Django use decorators extensively for routing and middleware

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro