Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 009 / 100

Python decorators explained: what developers should know

Wrap functions to add behavior without changing them.

Expona Admin8 min read

You've likely used decorators in Python without fully understanding how they work under the hood—they appear everywhere in frameworks like Flask and Django. Yet when you need to write your own, the syntax feels like magic, and the real power of decorators remains elusive. Python decorators explained: what developers should know means understanding that decorators are simply functions that wrap other functions to add behavior, and once you grasp that core concept, you'll write cleaner, more maintainable code across your backend services.

A decorator takes a function and returns a function

At its heart, a decorator is a higher-order function—it accepts a function as an argument and returns a new function that wraps the original. This wrapper can execute code before calling the original function, after it returns, or both. The elegance lies in how Python's @ syntax lets us apply this pattern with a single line.

Consider a basic example. Without decorators, if you wanted to log every call to a function, you'd modify the function itself or create a wrapper at every call site. With decorators, you centralize that logic:

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

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

print(greet("Alice"))
# Output:
# Calling greet
# Hello, Alice
# Finished greet

When we use @simple_decorator, Python transforms it into greet = simple_decorator(greet). The original greet function is replaced by the wrapper function, which maintains the same interface (*args, **kwargs) so callers don't notice the difference.

This abstraction is powerful because it separates concerns. The core logic of greet remains unchanged; the logging behavior is injected from outside. In production systems handling payment processing or user authentication, this separation keeps code readable and testable.

Use functools.wraps to keep metadata

There's a hidden gotcha in the decorator above: if you inspect the decorated function, it reports the wrong name and docstring. Check this:

python
print(greet.__name__)  # Output: wrapper (should be greet)
print(greet.__doc__)   # Output: None (docstring is lost)

This breaks introspection and can confuse debugging tools, testing frameworks, and API documentation generators. Python provides functools.wraps to solve this by copying metadata from the original function to the wrapper:

python
from functools import wraps

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

@better_decorator
def greet(name):
    """Greet a person by name."""
    return f"Hello, {name}"

print(greet.__name__)  # Output: greet (correct)
print(greet.__doc__)   # Output: Greet a person by name. (preserved)

In real backend work, tools like FastAPI's documentation auto-generation, or introspection-heavy frameworks, rely on this metadata being correct. Without @wraps, your API docs become misleading and debugging becomes harder. Always include it—it's a one-line habit that saves hours of troubleshooting.

Great for logging, timing, retries and auth

Decorators shine in real-world backend scenarios where you want to wrap functions with common behaviors. Let's walk through practical examples you'll encounter in production systems.

Logging decorator

In a ride-hailing or e-commerce backend, you want to log inputs and outputs of critical functions without cluttering the business logic:

python
import logging
from functools import wraps

logger = logging.getLogger(__name__)

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logger.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        try:
            result = func(*args, **kwargs)
            logger.info(f"{func.__name__} returned {result}")
            return result
        except Exception as e:
            logger.error(f"{func.__name__} raised {type(e).__name__}: {e}")
            raise
    return wrapper

@log_calls
def process_payment(user_id, amount):
    # Business logic here
    return {"status": "success", "transaction_id": 12345}

Timing decorator

When you need to monitor performance of database queries or API calls:

python
import time
from functools import wraps

def timing(func):
    @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:.3f}s")
        return result
    return wrapper

@timing
def fetch_user_profile(user_id):
    # Database query that takes time
    time.sleep(0.5)
    return {"id": user_id, "name": "Alice"}

Retry decorator

For resilient services dealing with flaky external APIs or temporary network issues:

python
import time
from functools import wraps

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @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, retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=2)
def call_external_service():
    # Simulated API call
    import random
    if random.random() < 0.7:
        raise ConnectionError("Service unavailable")
    return {"data": "success"}

Auth decorator

For protecting endpoints in a SaaS platform or CRM backend:

python
from functools import wraps
from typing import Callable, Any

def require_auth(func: Callable) -> Callable:
    @wraps(func)
    def wrapper(*args, request=None, **kwargs) -> Any:
        if request is None or not hasattr(request, "user") or request.user is None:
            raise PermissionError("Authentication required")
        return func(*args, request=request, **kwargs)
    return wrapper

@require_auth
def get_user_data(user_id: int, request=None):
    # Only callable if request.user is authenticated
    return {"user_id": user_id, "data": "sensitive"}

Each of these decorators solves a cross-cutting concern—a behavior that applies across many functions but isn't part of their core logic. By factoring it into a decorator, you maintain the single responsibility principle and make your codebase easier to modify, test, and audit.

Frameworks use them for routing

Web frameworks like Flask and FastAPI lean heavily on decorators for routing and middleware. Understanding how they work under the hood helps you use them more effectively:

python
from flask import Flask

app = Flask(__name__)

@app.route("/users/", methods=["GET"])
def get_user(user_id):
    return {"id": user_id, "name": "Alice"}

@app.route("/orders", methods=["POST"])
def create_order():
    return {"order_id": 1, "status": "created"}

Behind the scenes, @app.route is a decorator that registers the function with Flask's routing table. When a request arrives matching that path and method, Flask looks up the registered function and calls it.

FastAPI uses decorators similarly but goes further, combining routing with automatic request validation and response documentation:

python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    name: str
    email: str

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"id": user_id, "name": "Alice"}

@app.post("/users")
async def create_user(user: User):
    return {"id": 1, "name": user.name, "email": user.email}

The decorator extracts type hints to validate inputs, generate OpenAPI documentation, and serialize responses. This is powerful: one decorator line replaces dozens of manual validation lines. But power comes with responsibility—you need to understand what the decorator is doing to debug issues effectively.

In your own backends, you'll often write decorators for common patterns: enforcing rate limits, adding request IDs for tracing, transforming responses, handling exceptions gracefully. The key is recognizing when you're repeating a wrapper pattern across multiple functions—that's your signal to extract it into a decorator.

Common mistakes

Forgetting functools.wraps: This causes metadata loss and breaks tools that rely on introspection. It's a quick fix, but the impact is subtle and shows up later in production.

Not handling *args and **kwargs: If your decorator only accepts specific parameters, it will fail when the wrapped function signature changes or when used in unexpected ways. Always use *args, **kwargs unless you have a specific reason not to.

Raising exceptions in decorators: If your decorator raises an exception before calling the wrapped function, the original exception context is lost. Wrap the call in try-except and re-raise with clarity about what went wrong.

Applying multiple decorators in the wrong order: Decorators are applied bottom-to-top. If you stack @auth above @log_calls, auth runs first (closest to the original function), then logging. The order matters for logic and performance.

python
# Auth is checked first, then timing is measured
@timing
@require_auth
def protected_endpoint():
    pass

Creating state in decorators: If your decorator maintains mutable state (like a counter or cache), be careful with thread safety. In production systems with concurrent requests, shared state can cause race conditions.

Key takeaways

  • A decorator is a function that takes a function and returns a modified version, using *args, **kwargs to preserve the original signature.
  • Always use @functools.wraps to preserve the original function's metadata—it's a one-line habit that prevents debugging nightmares.
  • Decorators excel at cross-cutting concerns like logging, timing, retries, and authentication, keeping business logic clean and reusable.
  • Web frameworks use decorators for routing and middleware, but understanding the pattern helps you write better decorators yourself.
  • Stacking decorators works, but order matters—decorators are applied bottom-to-top, closest to the function first.
  • Be cautious with state in decorators; thread safety becomes critical in production systems handling concurrent requests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles