Python Decorators: The Complete Guide for 2026

February 12, 2026 · 22 min read

Decorators are one of Python's most powerful and elegant features, yet they remain a source of confusion for many developers. At their core, decorators let you modify or extend the behavior of functions and classes without changing their source code. They are the backbone of every major Python framework — Flask uses @route, FastAPI uses @app.get, pytest uses @fixture, and dataclasses uses @dataclass. This guide covers everything from first-class functions and closures through async decorators and ParamSpec type hints.

1. What Are Decorators? First-Class Functions and Closures

In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions. A closure is an inner function that remembers variables from the enclosing scope even after the outer function returns. These two concepts are the foundation of decorators:

def make_greeter(greeting):
    def greeter(name):            # Inner function (closure)
        return f"{greeting}, {name}!"  # Closes over 'greeting'
    return greeter                 # Return the function itself

hi = make_greeter("Hi")
print(hi("Alice"))  # Hi, Alice!
print(hi("Bob"))    # Hi, Bob!

2. Simple Function Decorators

A decorator is a function that takes another function and returns a modified version. The @ syntax is syntactic sugar:

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

@my_decorator
def add(a, b):
    return a + b

# Equivalent to: add = my_decorator(add)
print(add(3, 5))
# Calling add
# add returned 8
# 8

3. @wraps and Preserving Metadata

Without functools.wraps, the decorated function loses its identity — __name__, __doc__, and other attributes are replaced by the wrapper's. Always use @wraps:

from functools import wraps

def my_decorator(func):
    @wraps(func)  # Copies __name__, __doc__, __module__, __qualname__, __annotations__
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    """Add two numbers together."""
    return a + b

print(add.__name__)     # "add" -- correct!
print(add.__doc__)      # "Add two numbers together."
print(add.__wrapped__)  # <function add at 0x...> -- access the original

4. Decorators with Arguments

When you need to pass arguments to a decorator, use a decorator factory — a function that returns a decorator (three nested layers):

from functools import wraps

def repeat(n):
    """Decorator factory: call the function n times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say(message):
    print(message)

say("hello")  # prints "hello" three times

# Optional-arguments pattern: works as @deco or @deco(prefix="X")
def flexible(func=None, *, prefix="LOG"):
    def decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            print(f"[{prefix}] {f.__name__}")
            return f(*args, **kwargs)
        return wrapper
    return decorator(func) if func else decorator

5. Class-Based Decorators

Any callable can be a decorator, including classes with __call__. Class-based decorators are ideal for maintaining state between calls:

from functools import wraps

class CountCalls:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"{self.func.__name__} called {self.call_count} time(s)")
        return self.func(*args, **kwargs)

@CountCalls
def process(data):
    return data.upper()

process("hello")  # process called 1 time(s)
process("world")  # process called 2 time(s)
print(process.call_count)  # 2

6. Decorating Classes

Decorators can also modify classes. A class decorator receives the class, modifies it, and returns it. Python's @dataclass is the most famous example:

def add_repr(cls):
    """Auto-generate __repr__ from __init__ parameters."""
    import inspect
    params = list(inspect.signature(cls.__init__).parameters.keys())[1:]
    def __repr__(self):
        values = ", ".join(f"{p}={getattr(self, p)!r}" for p in params)
        return f"{cls.__name__}({values})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

print(Point(3, 7))  # Point(x=3, y=7)

7. Stacking Multiple Decorators

Multiple decorators are applied bottom-up but executed top-down:

@bold       # 2nd: wraps the italic-wrapped function
@italic     # 1st: wraps greet
def greet(name):
    return f"Hello, {name}"

# Equivalent to: greet = bold(italic(greet))
print(greet("Alice"))  # <b><i>Hello, Alice</i></b>

8. Common Patterns: @timer, @retry, @cache, @validate, @log

@timer

import time
from functools import wraps

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

@timer
def slow_op():
    time.sleep(1)
    return "done"

slow_op()  # slow_op took 1.0012s

@retry

def retry(max_attempts=3, delay=1, exceptions=(Exception,)):
    def decorator(func):
        @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:
                        time.sleep(delay * attempt)
            raise last_exc
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
    import random
    if random.random() < 0.7:
        raise ConnectionError("refused")
    return {"status": "ok"}

@cache

def cache(func):
    memo = {}
    @wraps(func)
    def wrapper(*args):
        if args not in memo:
            memo[args] = func(*args)
        return memo[args]
    wrapper.cache = memo
    wrapper.cache_clear = memo.clear
    return wrapper

@cache
def fibonacci(n):
    if n < 2: return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # 354224848179261915075 -- instant

@validate

def validate_types(**type_hints):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            bound = inspect.signature(func).bind(*args, **kwargs)
            for param, expected in type_hints.items():
                if param in bound.arguments and not isinstance(bound.arguments[param], expected):
                    raise TypeError(f"{param} must be {expected.__name__}, got {type(bound.arguments[param]).__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_types(name=str, age=int)
def create_user(name, age):
    return {"name": name, "age": age}

create_user("Alice", 30)   # Works
create_user("Alice", "30") # TypeError: age must be int, got str

@log

import logging

def log(level=logging.INFO):
    def decorator(func):
        logger = logging.getLogger(func.__module__)
        @wraps(func)
        def wrapper(*args, **kwargs):
            sig = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
            logger.log(level, f"Calling {func.__name__}({sig})")
            result = func(*args, **kwargs)
            logger.log(level, f"{func.__name__} returned {result!r}")
            return result
        return wrapper
    return decorator

9. Built-in Decorators

@property — Computed Attributes

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0: raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
print(c.area)   # 78.5398 -- accessed like an attribute
c.radius = -1   # ValueError

@staticmethod and @classmethod

class DateParser:
    @staticmethod
    def is_valid_year(year):
        """No access to class or instance -- pure utility."""
        return 1900 <= year <= 2100

    @classmethod
    def from_timestamp(cls, ts):
        """Has access to class, can create instances."""
        from datetime import datetime
        return cls(datetime.fromtimestamp(ts).strftime("%Y-%m-%d"))

@abstractmethod

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self): pass

    @abstractmethod
    def perimeter(self): pass

# Shape() raises TypeError -- must implement all abstract methods

10. functools Decorators

@lru_cache — Memoization with LRU Eviction

from functools import lru_cache

@lru_cache(maxsize=128)
def expensive_computation(n):
    print(f"Computing {n}...")
    return sum(i * i for i in range(n))

expensive_computation(1000)  # Computing 1000... (computed)
expensive_computation(1000)  # (returned from cache, no print)
print(expensive_computation.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)

@cached_property — One-Time Computed Attributes

from functools import cached_property

class DataAnalyzer:
    def __init__(self, data):
        self.data = data

    @cached_property
    def statistics(self):
        """Computed once, then cached as instance attribute."""
        return {"mean": sum(self.data) / len(self.data), "min": min(self.data), "max": max(self.data)}

analyzer = DataAnalyzer([1, 2, 3, 4, 5])
print(analyzer.statistics)  # Computed once
print(analyzer.statistics)  # Returned from cache

@singledispatch — Function Overloading by Type

from functools import singledispatch

@singledispatch
def format_value(value):
    return str(value)

@format_value.register(int)
def _(value): return f"{value:,}"

@format_value.register(float)
def _(value): return f"{value:.2f}"

@format_value.register(list)
def _(value): return " | ".join(format_value(v) for v in value)

print(format_value(1000000))        # 1,000,000
print(format_value(3.14159))        # 3.14
print(format_value([1, 2.5, "hi"])) # 1 | 2.50 | hi

@total_ordering — Auto-Generate Comparison Methods

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor, patch):
        self.major, self.minor, self.patch = major, minor, patch
    def __eq__(self, other):
        return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
    def __lt__(self, other):
        return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

# Generates __le__, __gt__, __ge__ from __eq__ + __lt__
print(Version(1, 2, 3) < Version(1, 3, 0))   # True
print(Version(1, 2, 3) >= Version(1, 3, 0))  # False

11. Decorators in Web Frameworks

Flask @route

from flask import Flask, jsonify
app = Flask(__name__)

@app.route("/api/users", methods=["GET"])
def get_users():
    return jsonify([{"id": 1, "name": "Alice"}])

# @app.route registers the function in app.url_map

FastAPI @app.get with Type Validation

from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()

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

@app.post("/users", status_code=201)
async def create_user(user: UserCreate):
    return {"id": 1, **user.model_dump()}

@app.get("/users/{user_id}")
async def read_user(user_id: int, db=Depends(get_db)):
    return db.query(User).get(user_id)

12. Async Decorators

When decorating async functions, the wrapper must also be async. A universal approach handles both sync and async:

import asyncio, time
from functools import wraps

def universal_timer(func):
    """Works with both sync and async functions."""
    if asyncio.iscoroutinefunction(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = await func(*args, **kwargs)
            print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
            return result
    else:
        @wraps(func)
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
            return result
    return wrapper

@universal_timer
def sync_task(): time.sleep(0.1)

@universal_timer
async def async_task(): await asyncio.sleep(0.1)

13. Decorator Factories

Factories create families of configurable decorators. Here is a registry pattern for plugin systems:

class Registry:
    def __init__(self):
        self._handlers = {}

    def register(self, name):
        def decorator(func):
            self._handlers[name] = func
            return func
        return decorator

    def dispatch(self, name, *args, **kwargs):
        return self._handlers[name](*args, **kwargs)

commands = Registry()

@commands.register("greet")
def greet_cmd(name): return f"Hello, {name}!"

@commands.register("add")
def add_cmd(a, b): return a + b

print(commands.dispatch("greet", "Alice"))  # Hello, Alice!
print(commands.dispatch("add", 3, 5))       # 8

14. Type Hints with Decorators (ParamSpec, Concatenate)

Python 3.10 introduced ParamSpec (PEP 612) to type decorators correctly:

from typing import TypeVar, Callable, ParamSpec, Concatenate
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def logged(func: Callable[P, R]) -> Callable[P, R]:
    """IDE sees original function signature through the decorator."""
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a: int, b: int) -> int:
    return a + b

# Type checker knows: add(a: int, b: int) -> int

# Use Concatenate when the decorator injects parameters:
def with_user(func: Callable[Concatenate[str, P], R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(get_current_user(), *args, **kwargs)
    return wrapper

@with_user
def greet(user: str, msg: str) -> str:
    return f"{user}: {msg}"

greet("hello")  # user injected, type checker sees greet(msg: str) -> str

15. Testing Decorated Functions

# Strategy 1: Access original via __wrapped__ (requires @wraps)
original = fetch_data.__wrapped__
result = original("http://example.com")

# Strategy 2: Mock the decorator before import
import unittest.mock as mock
def noop(*a, **kw):
    return a[0] if len(a) == 1 and callable(a[0]) else lambda f: f

with mock.patch("myapp.decorators.retry", noop):
    from myapp.service import fetch_data  # undecorated

# Strategy 3: Test the decorator itself
def test_retry_logic():
    call_count = 0
    @retry(max_attempts=3, delay=0)
    def flaky():
        nonlocal call_count
        call_count += 1
        if call_count < 3: raise ConnectionError("fail")
        return "success"

    assert flaky() == "success"
    assert call_count == 3

16. Common Mistakes and Debugging

# Mistake 1: Forgetting to call the factory
@repeat      # WRONG -- missing parentheses
@repeat(3)   # CORRECT

# Mistake 2: Not returning the result
def bad(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)  # Missing return!
    return wrapper

# Mistake 3: Mutable default shared across all decorated functions
def bad_cache(func, cache={}):  # WRONG -- shared dict
    ...
def good_cache(func):
    cache = {}  # CORRECT -- each function gets its own cache
    ...

# Mistake 4: Not using *args, **kwargs (breaks methods)
# Always use *args, **kwargs -- it captures 'self' for methods

# Debugging: inspect the chain
print(my_func.__name__)      # Should show original name
print(my_func.__wrapped__)   # Next layer down
print(my_func.__closure__)   # Closure cell objects

17. Best Practices

  1. Always use @functools.wraps to preserve identity and enable __wrapped__ access.
  2. Use *args, **kwargs in the wrapper — never hardcode parameters.
  3. Always return the result: return func(*args, **kwargs).
  4. Keep decorators focused — one decorator, one responsibility. Stack them.
  5. Prefer decorator factories for configurability, even with no arguments yet.
  6. Type with ParamSpec so IDE autocompletion works through decoration.
  7. Handle async by checking asyncio.iscoroutinefunction().
  8. Use built-ins first@lru_cache, @cached_property, @singledispatch, @dataclass.
  9. Document behavior clearly in the docstring.
  10. Test decorators independently from the functions they wrap.

Frequently Asked Questions

What is the difference between a decorator and a decorator factory?

A decorator takes a function and returns a new function. A decorator factory returns a decorator, adding a layer for configuration. @retry(max_attempts=3) calls the factory retry() which returns the actual decorator. Factories require three nested functions: outer (config), middle (accepts function), inner (wrapper).

Why should I always use @functools.wraps?

Without it, the decorated function loses __name__, __doc__, __module__, and __qualname__. This breaks help(), debuggers, and serialization. @wraps copies these attributes and adds __wrapped__ for accessing the original — essential for testing.

Can I decorate async functions with a regular decorator?

If the decorator does not call the function (like registering it), yes. If it calls the function, the wrapper must await it. Use asyncio.iscoroutinefunction(func) to handle both sync and async transparently.

How do I access the original undecorated function?

If the decorator uses @wraps, call func.__wrapped__ to get the original. This lets you test core logic without decorator behavior. Without @wraps, monkeypatch the decorator as a no-op before importing the module.

What is ParamSpec and how does it improve typing?

ParamSpec (Python 3.10, PEP 612) captures the full parameter signature. Type Callable[P, R] -> Callable[P, R] to preserve parameter types through decoration, enabling IDE autocompletion and type checking on decorated functions.

Conclusion

Decorators are not magic — they are functions that take functions and return functions, powered by closures. Start with @wraps and *args, **kwargs. Build practical patterns like @timer, @retry, and @cache. Use the built-in decorators before writing custom ones. When you need advanced features like async decorators and ParamSpec, you will have a solid foundation.

The decorator pattern is what makes Python frameworks feel magical. Understanding it transforms you from a framework consumer into someone who can build frameworks.

⚙ Keep learning: Explore our FastAPI Complete Guide to see decorators in a real framework, validate data with the Pydantic Complete Guide, and manage async code with the Python Asyncio Guide.

Related Resources

Related Resources

Python Virtual Environments
Set up isolated environments with venv, pipenv, and Poetry
Pydantic Complete Guide
Data validation and settings management for Python
FastAPI Complete Guide
Build high-performance Python APIs with decorator-based routing
Python Asyncio Guide
Master async/await for building async-aware decorators
Python Beginners Guide
Learn Python fundamentals from variables to functions
Python Data Structures
Dicts, lists, sets, and tuples used in decorator patterns