Python Decorators: Complete Guide with Real-World Examples (2026)

Decorators are one of Python's most powerful metaprogramming features. They let you wrap functions or classes to add behavior — logging, timing, caching, retries, rate limiting — without modifying the original code. Once you understand the mechanics, decorators stop feeling like magic and become an indispensable tool in your Python toolkit. This guide starts from the fundamentals and builds up to production-ready patterns.

How Decorators Work

A decorator is just a callable that takes a function and returns a new function. The @decorator syntax is syntactic sugar for func = decorator(func):

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Before {func.__name__}")
        result = func(*args, **kwargs)
        print(f"After {func.__name__}")
        return result
    return wrapper

@my_decorator
def greet(name: str) -> str:
    return f"Hello, {name}!"

# Equivalent to: greet = my_decorator(greet)
print(greet("Alice"))
# Output:
# Before greet
# After greet
# Hello, Alice!

The *args, **kwargs pattern in the wrapper is essential — it forwards all positional and keyword arguments to the original function, making your decorator work with any function signature.

functools.wraps — Preserving Metadata

Without functools.wraps, the wrapped function loses its name, docstring, and type hints — which breaks documentation tools, debuggers, and help():

import functools

def my_decorator(func):
    @functools.wraps(func)   # copies __name__, __doc__, __annotations__, etc.
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(x: int, y: int) -> int:
    """Returns the sum of x and y."""
    return x + y

print(add.__name__)   # 'add'  (without wraps: 'wrapper')
print(add.__doc__)    # 'Returns the sum of x and y.'
print(add.__annotations__)  # {'x': int, 'y': int, 'return': int}
Always use @functools.wraps in every decorator wrapper you write. It takes one second and prevents hours of debugging mysterious failures in frameworks that inspect function metadata.

Decorators with Arguments

When your decorator needs configuration parameters, you add an extra layer of nesting — a factory function that returns the actual decorator:

import functools
import time

def retry(max_attempts: int = 3, delay: float = 1.0, exceptions: tuple = (Exception,)):
    """Decorator factory that retries a function on failure."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    if attempt < max_attempts:
                        print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
                        time.sleep(delay)
                    else:
                        print(f"All {max_attempts} attempts failed.")
            raise last_exc
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_from_api(url: str) -> dict:
    # Simulates a flaky network call
    import random
    if random.random() < 0.7:
        raise ConnectionError("Connection refused")
    return {"data": "ok"}

result = fetch_from_api("https://api.example.com")

Class Decorators

A class can act as a decorator by implementing __call__. This is useful when the decorator needs to maintain state across calls:

import functools
import time
from collections import deque

class RateLimit:
    """Allows at most `calls` calls per `period` seconds."""

    def __init__(self, calls: int, period: float):
        self.calls = calls
        self.period = period
        self.timestamps: deque = deque()

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            # Remove timestamps outside the window
            while self.timestamps and now - self.timestamps[0] > self.period:
                self.timestamps.popleft()
            if len(self.timestamps) >= self.calls:
                raise RuntimeError(
                    f"Rate limit exceeded: {self.calls} calls per {self.period}s"
                )
            self.timestamps.append(now)
            return func(*args, **kwargs)
        return wrapper

@RateLimit(calls=5, period=1.0)
def send_notification(message: str):
    print(f"Sending: {message}")

Practical Decorators: Timing, Logging, Retry, Cache, Rate-Limit

Timing decorator:

import functools
import time
import logging

logger = logging.getLogger(__name__)

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

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

LRU cache (built-in):

from functools import lru_cache, cache

# cache = lru_cache(maxsize=None) — unbounded cache (Python 3.9+)
@cache
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))  # Fast — each value computed once

# lru_cache with size limit
@lru_cache(maxsize=128)
def expensive_query(user_id: int, date: str) -> dict:
    # Results cached by (user_id, date) key
    return {"user_id": user_id, "date": date, "data": "..."}
Note: functools.cache and lru_cache only work with hashable arguments. Don't pass lists, dicts, or other mutable objects as arguments to cached functions.

Stacked Decorators

Stacked decorators are applied bottom-up. The decorator closest to the function definition is applied first:

@timer          # applied third (outermost)
@log_calls      # applied second
@retry(max_attempts=3)  # applied first (innermost, closest to function)
def process_payment(amount: float, currency: str) -> dict:
    """Process a payment through the payment gateway."""
    # ... payment logic
    return {"status": "ok", "amount": amount}

# Equivalent to:
# process_payment = timer(log_calls(retry(max_attempts=3)(process_payment)))

property, classmethod, staticmethod

Python's built-in decorators control how methods behave on classes:

from datetime import date

class Employee:
    def __init__(self, first: str, last: str, birth_year: int):
        self.first = first
        self.last = last
        self.birth_year = birth_year

    @property
    def full_name(self) -> str:
        """Computed attribute — accessed like a field, not a method."""
        return f"{self.first} {self.last}"

    @full_name.setter
    def full_name(self, value: str):
        self.first, self.last = value.split(" ", 1)

    @property
    def age(self) -> int:
        return date.today().year - self.birth_year

    @classmethod
    def from_string(cls, data: str) -> "Employee":
        """Alternative constructor — receives the class, not an instance."""
        first, last, year = data.split("-")
        return cls(first, last, int(year))

    @staticmethod
    def is_valid_name(name: str) -> bool:
        """Utility — no access to class or instance."""
        return len(name.strip()) > 0

emp = Employee.from_string("Alice-Smith-1990")
print(emp.full_name)   # Alice Smith
print(emp.age)         # 36

dataclasses

The @dataclass decorator auto-generates __init__, __repr__, and __eq__ from class annotations:

from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime

@dataclass
class Product:
    name: str
    price: float
    category: str
    tags: list[str] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.utcnow)
    stock: int = 0

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("Price cannot be negative")

@dataclass(frozen=True)   # immutable — instances are hashable
class Point:
    x: float
    y: float

    def distance_to(self, other: "Point") -> float:
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

@dataclass(order=True)   # enables <, >, <=, >= based on field order
class Version:
    major: int
    minor: int
    patch: int

    def __str__(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"

p = Product("Widget", 9.99, "hardware", tags=["sale", "new"])
print(p)
# Product(name='Widget', price=9.99, category='hardware', tags=['sale', 'new'], ...)

Frequently Asked Questions

Can decorators be applied to classes?
Yes. Class decorators receive the class object and return a modified or replacement class. @dataclass is the most common example. You can also use them for registration patterns, singleton enforcement, or adding methods to existing classes.
How do I write a decorator that works for both sync and async functions?
Check asyncio.iscoroutinefunction(func) inside your decorator and return an async wrapper if True, a sync wrapper otherwise. Libraries like wrapt handle this transparently.
What is the difference between @property and a regular method?
@property makes a method behave like an attribute — you access it without parentheses (obj.name not obj.name()). It is ideal for computed attributes that should look like data to callers.
Does @functools.cache work with class methods?
Not directly — self must be hashable, and most class instances are not. Use @functools.cached_property for instance-level memoization, or @lru_cache on a static/class method.
Are decorators slow?
The decoration step itself (at import time) is negligible. The per-call overhead of a well-written wrapper is typically under 1 microsecond — not a concern for almost all applications.