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.
Table of Contents
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}
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": "..."}
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.
@dataclassis 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 likewrapthandle this transparently. - What is the difference between @property and a regular method?
@propertymakes a method behave like an attribute — you access it without parentheses (obj.namenotobj.name()). It is ideal for computed attributes that should look like data to callers.- Does @functools.cache work with class methods?
- Not directly —
selfmust be hashable, and most class instances are not. Use@functools.cached_propertyfor instance-level memoization, or@lru_cacheon 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.