Python Decorators: The Complete Guide for 2026
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
- Always use
@functools.wrapsto preserve identity and enable__wrapped__access. - Use
*args, **kwargsin the wrapper — never hardcode parameters. - Always return the result:
return func(*args, **kwargs). - Keep decorators focused — one decorator, one responsibility. Stack them.
- Prefer decorator factories for configurability, even with no arguments yet.
- Type with
ParamSpecso IDE autocompletion works through decoration. - Handle async by checking
asyncio.iscoroutinefunction(). - Use built-ins first —
@lru_cache,@cached_property,@singledispatch,@dataclass. - Document behavior clearly in the docstring.
- 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.
Related Resources
- Python Virtual Environments Guide — set up isolated Python environments for your projects
- Pydantic Complete Guide — data validation used alongside decorators in FastAPI
- FastAPI Complete Guide — decorators in action in a modern async web framework
- Python Asyncio Complete Guide — async/await for writing async-aware decorators