Python Decorators: A Complete Guide
Decorators are one of Python’s most powerful features. At their core, decorators are functions that modify the behavior of other functions or classes without changing their source code. They enable aspect-oriented programming patterns — logging, timing, caching, authorization, validation — in a clean, reusable way.
Python’s @decorator syntax is syntactic sugar for function = decorator(function). Understanding this equivalence is the key to understanding how decorators work under the hood.
Function Decorators
The simplest decorator wraps a function to add pre- or post-processing:
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
print(add(3, 4))
# Calling add
# add returned 7
# 7The @logger syntax is equivalent to add = logger(add). The wrapper function replaces the original add, captures all arguments, calls the original, and logs the call. The closure retains access to func even after logger returns.
Preserving Metadata with functools.wraps
A wrapped function loses its original name, docstring, and signature:
print(add.__name__) # "wrapper" (not "add")Use functools.wraps to copy metadata from the original function:
import functools
def logger(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # "add"
print(add.__doc__) # "Add two numbers."Always use @functools.wraps on your wrapper functions. Without it, debugging becomes harder because stack traces and introspection tools show “wrapper” instead of the original function name.
Measuring Decorator Overhead
Decorators add function call overhead to every invocation of the wrapped function. For performance-critical code called millions of times, this overhead matters. The functools.wraps decorator is essential — without it, the decorated function loses its original __name__, __doc__, and __module__. Use functools.lru_cache for memoization and functools.singledispatch for type-based dispatch.
Decorators with Arguments
Sometimes a decorator needs configuration:
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!This is a decorator factory: repeat(times=3) returns the actual decorator, which then receives greet. The syntax @repeat(times=3) is equivalent to greet = repeat(times=3)(greet).
Optional Arguments Pattern
For decorators that work with or without arguments, check whether the argument is a function or a configuration value:
def debug(func=None, *, verbose=False):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if verbose:
print(f"DEBUG: {fn.__name__}({args}, {kwargs})")
return fn(*args, **kwargs)
return wrapper
if func is not None:
return decorator(func) # used as @debug (no args)
return decorator # used as @debug(verbose=True)Chaining Decorators
Decorators stack from bottom to top (closest to the function applies first):
@decorator_a
@decorator_b
@decorator_c
def my_function():
pass
# Equivalent to:
# my_function = decorator_a(decorator_b(decorator_c(my_function)))When chaining, the bottom decorator runs first. This matters when the order of operations is important — for example, authentication should wrap authorization because authorization depends on an authenticated identity.
Class Decorators
Decorators can also modify classes:
def singleton(cls):
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Database:
def __init__(self):
print("Creating database connection")
db1 = Database() # Creating database connection
db2 = Database() # no output — returns existing instance
print(db1 is db2) # TrueClass decorators are useful for registering classes, adding methods, or enforcing patterns like singletons without metaclass complexity.
Real-World Decorator Examples
Timing Decorator
import time
def timer(func):
@functools.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:.4f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(0.5)
return "done"
slow_function() # slow_function took 0.5001sRetry Decorator
import time
import functools
def retry(max_attempts=3, delay=1):
def decorator(func):
@functools.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: {e}. Retrying...")
time.sleep(delay)
return None
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
# network call that may fail
passCache Decorator
import functools
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # fast — cached intermediate resultsFor production caching, use functools.lru_cache which has a size limit, TTL, and statistics:
@functools.lru_cache(maxsize=128)
def expensive_computation(n):
return n ** nValidation Decorator
def validate_types(**type_hints):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Simple positional argument validation
for (name, expected_type), arg in zip(type_hints.items(), args):
if not isinstance(arg, expected_type):
raise TypeError(
f"{name} must be {expected_type.__name__}, got {type(arg).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_types(x=int, y=int)
def divide(x, y):
return x / y
divide(10, 2) # 5.0
divide("10", 2) # TypeError: x must be int, got strAuthorization Decorator
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Assume user info is available in context
user = get_current_user()
if role not in user.roles:
raise PermissionError(f"User lacks {role} role")
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user_id):
print(f"Deleting user {user_id}")Class-based Decorators
Decorators can also be implemented as classes with __call__. Class-based decorators are useful when you need to maintain complex state or provide methods on the decorated object. The __init__ method receives the function, and __call__ handles the invocation. This pattern appears in Django’s @login_required and Celery’s @task decorators.
Decorators in the Standard Library
Python’s standard library includes several built-in decorators:
# @property — make a method look like an attribute
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self):
return 3.14159 * self._radius ** 2
@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
# @staticmethod / @classmethod
class Utils:
@staticmethod
def is_valid_email(email):
return "@" in email
@classmethod
def from_config(cls, config):
return cls(config["name"], config["age"])
# @dataclass
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: floatDecorators in Standard Library Beyond Basics
Python’s standard library includes several decorators beyond @property and @staticmethod. @functools.cache and @functools.lru_cache provide memoization with automatic cache invalidation. @functools.singledispatch enables single-dispatch generic functions where behavior depends on the first argument’s type. @contextlib.contextmanager converts a generator function into a context manager without defining a class. @dataclass from the dataclasses module automatically generates __init__, __repr__, __eq__, and __hash__ methods. @total_ordering from functools fills in missing comparison operators when you define __eq__ and one other. These standard decorators solve common problems and demonstrate idiomatic decorator patterns.
Summary
Decorators are a cornerstone of idiomatic Python. They allow you to wrap functions and classes with reusable behavior — logging, timing, caching, validation, authorization — without modifying the wrapped code. The @functools.wraps decorator preserves metadata on wrapped functions. Decorator factories accept arguments for configurable behavior. Chaining applies decorators from bottom to top. Class decorators modify classes without inheritance. Together, these patterns enable clean separation of cross-cutting concerns from core business logic.
Related: Learn about Python file handling and error handling.
Decorator Type Annotations
Type annotations on decorators require care because the wrapper function signature differs from the original. Use ParamSpec and TypeVar from typing (Python 3.10+) to preserve type information: P = ParamSpec('P'); R = TypeVar('R'). A properly typed decorator factory: def logger(func: Callable[P, R]) -> Callable[P, R]. This ensures mypy and Pyright correctly infer the decorated function’s parameter types and return type, making decorated functions type-safe throughout your codebase. Without these annotations, decorated functions lose their type information.
Best Practices for Decorator Design
Keep decorators single-purpose — each decorator should do one thing well and compose cleanly with others. Document the decorator’s behavior including any side effects and performance implications. Prefer functools.wraps always to preserve function metadata. For decorators that accept arguments, implement the optional-arguments pattern to allow both @decorator and @decorator(args) syntax. Test decorators independently with stub functions before using them in production code.
FAQ
What is the difference between a decorator and a decorator factory?
A decorator is a function that takes a function and returns a modified function. A decorator factory is a function that takes configuration arguments and returns a decorator. Use @decorator for simple cases and @factory(args) when you need configuration parameters.
Can I apply multiple decorators to one function?
Yes — stack them with @decorator_a above @decorator_b above the function definition. They apply from bottom to top: the bottommost runs first, then the next, and so on. The order matters when decorators have side effects or dependencies.
How do I test decorated functions?
Test the original function directly by accessing it through __wrapped__ if you used functools.wraps. Or test the decorated version and verify the behavioral modification. Unit test the decorator in isolation with a mock function. For complex decorators, test both the decorator and the decorated behavior.
Are decorators thread-safe?
Standard decorators using local function scope are generally safe. Decorators with shared mutable state (counters, caches) need locks if used concurrently. functools.lru_cache is thread-safe. For custom stateful decorators in multi-threaded code, add threading.Lock protection around state mutations.