Decorators
📖 Concept
Decorators are one of Python's most powerful metaprogramming tools. A decorator is a callable that takes a function (or class) and returns a modified version of it. They enable clean separation of cross-cutting concerns like logging, authentication, caching, and validation without polluting business logic.
How decorators work under the hood:
@my_decorator
def func():
pass
# is syntactic sugar for:
func = my_decorator(func)
Key concepts:
| Concept | Description |
|---|---|
| Function decorator | Takes a function, returns a wrapped function |
| Class decorator | Takes a class, returns a modified class |
@functools.wraps |
Preserves the original function's __name__, __doc__, and __module__ |
| Parameterized decorator | A decorator factory that accepts arguments and returns the actual decorator |
| Stacking | Multiple decorators applied bottom-up: the closest to def runs first |
Decorator execution order with stacking:
@decorator_a # Applied SECOND (outermost)
@decorator_b # Applied FIRST (innermost)
def func():
pass
# Equivalent to: func = decorator_a(decorator_b(func))
Why @functools.wraps matters: Without it, the decorated function loses its identity — func.__name__ returns the wrapper's name, help(func) shows the wrapper's docstring, and debugging tools report incorrect names. Always use @wraps in production decorators.
Class decorators modify or replace an entire class. Common uses include adding methods, registering classes in a plugin system, or wrapping all methods with instrumentation. Unlike metaclasses, class decorators are simpler and more explicit.
💻 Code Example
1# ============================================================2# Basic function decorator with @wraps3# ============================================================4import functools5import time6import logging7from typing import Any, Callable, TypeVar89logger = logging.getLogger(__name__)1011F = TypeVar("F", bound=Callable[..., Any])121314def timer(func: F) -> F:15 """Measure and log execution time of a function."""16 @functools.wraps(func)17 def wrapper(*args, **kwargs):18 start = time.perf_counter()19 result = func(*args, **kwargs)20 elapsed = time.perf_counter() - start21 logger.info(f"{func.__name__} took {elapsed:.4f}s")22 return result23 return wrapper242526@timer27def slow_computation(n: int) -> int:28 """Compute the sum of squares up to n."""29 return sum(i * i for i in range(n))303132result = slow_computation(1_000_000)33print(slow_computation.__name__) # "slow_computation" (preserved by @wraps)343536# ============================================================37# Parameterized decorator (decorator factory)38# ============================================================39def retry(max_attempts: int = 3, delay: float = 1.0, exceptions=(Exception,)):40 """Retry a function on failure with configurable attempts and delay."""41 def decorator(func: F) -> F:42 @functools.wraps(func)43 def wrapper(*args, **kwargs):44 last_exception = None45 for attempt in range(1, max_attempts + 1):46 try:47 return func(*args, **kwargs)48 except exceptions as e:49 last_exception = e50 logger.warning(51 f"{func.__name__} attempt {attempt}/{max_attempts} "52 f"failed: {e}"53 )54 if attempt < max_attempts:55 time.sleep(delay)56 raise last_exception57 return wrapper58 return decorator596061@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))62def fetch_data(url: str) -> dict:63 """Fetch data from an external API."""64 import random65 if random.random() < 0.7:66 raise ConnectionError("Server unavailable")67 return {"status": "ok", "url": url}686970# ============================================================71# Stacking decorators72# ============================================================73def validate_positive(func):74 """Ensure all numeric arguments are positive."""75 @functools.wraps(func)76 def wrapper(*args, **kwargs):77 for arg in args:78 if isinstance(arg, (int, float)) and arg < 0:79 raise ValueError(f"Negative argument: {arg}")80 for key, val in kwargs.items():81 if isinstance(val, (int, float)) and val < 0:82 raise ValueError(f"Negative keyword argument {key}={val}")83 return func(*args, **kwargs)84 return wrapper858687@timer # Outer: measures total time including validation88@validate_positive # Inner: validates first, then runs function89def calculate_price(base: float, tax_rate: float) -> float:90 """Calculate total price with tax."""91 return base * (1 + tax_rate)929394price = calculate_price(100.0, 0.08) # $108.00959697# ============================================================98# Class decorator99# ============================================================100def singleton(cls):101 """Ensure only one instance of a class exists."""102 instances = {}103104 @functools.wraps(cls, updated=[])105 def get_instance(*args, **kwargs):106 if cls not in instances:107 instances[cls] = cls(*args, **kwargs)108 return instances[cls]109 return get_instance110111112@singleton113class DatabaseConnection:114 def __init__(self, host: str = "localhost", port: int = 5432):115 self.host = host116 self.port = port117 print(f"Connecting to {host}:{port}")118119 def query(self, sql: str) -> str:120 return f"Executed: {sql}"121122123db1 = DatabaseConnection("prod-db", 5432) # prints "Connecting to prod-db:5432"124db2 = DatabaseConnection("other-host", 3306) # No print! Returns same instance125print(db1 is db2) # True126127128# ============================================================129# Decorator that works with or without arguments130# ============================================================131def cache(func=None, *, maxsize=128):132 """LRU cache decorator that works with or without parentheses.133134 Usage:135 @cache # No arguments136 @cache() # Empty arguments137 @cache(maxsize=256) # With arguments138 """139 def decorator(fn):140 return functools.lru_cache(maxsize=maxsize)(fn)141142 if func is not None:143 # Called without arguments: @cache144 return decorator(func)145 # Called with arguments: @cache(maxsize=256)146 return decorator147148149@cache150def fibonacci(n: int) -> int:151 if n < 2:152 return n153 return fibonacci(n - 1) + fibonacci(n - 2)154155156@cache(maxsize=256)157def factorial(n: int) -> int:158 if n <= 1:159 return 1160 return n * factorial(n - 1)161162163# ============================================================164# Method decorator with descriptor protocol awareness165# ============================================================166def audit_method(func):167 """Log method calls with class name and arguments."""168 @functools.wraps(func)169 def wrapper(self, *args, **kwargs):170 cls_name = self.__class__.__name__171 logger.info(f"{cls_name}.{func.__name__} called with args={args}")172 result = func(self, *args, **kwargs)173 logger.info(f"{cls_name}.{func.__name__} returned {result!r}")174 return result175 return wrapper176177178class OrderService:179 @audit_method180 def place_order(self, item: str, quantity: int) -> dict:181 return {"item": item, "quantity": quantity, "status": "placed"}182183 @audit_method184 def cancel_order(self, order_id: str) -> dict:185 return {"order_id": order_id, "status": "cancelled"}
🏋️ Practice Exercise
Exercises:
Write a
@debugdecorator that prints the function name, all arguments (positional and keyword), and the return value every time the function is called. Use@functools.wrapsto preserve metadata.Create a parameterized
@rate_limit(calls=5, period=60)decorator that raisesRuntimeErrorif the decorated function is called more thancallstimes withinperiodseconds. Track call timestamps in a list.Build a
@validate_typesdecorator that reads function annotations and raisesTypeErrorif any argument's type doesn't match its annotation. Handle*argsand**kwargsgracefully.Implement a
@memoizedecorator from scratch (nofunctools.lru_cache) that caches results in a dictionary. Add a.cache_clear()method to the wrapper function and a.cache_info()method that returns hits and misses.Write a class decorator
@auto_reprthat automatically generates a__repr__method based on__init__parameters usinginspect.signature. The generated repr should look likeClassName(param1=value1, param2=value2).Stack three decorators on one function:
@timer,@retry(max_attempts=3), and@validate_positive. Predict and verify the order of execution.
⚠️ Common Mistakes
Forgetting
@functools.wraps(func)on the inner wrapper. Without it,func.__name__,__doc__, and__module__are replaced by the wrapper's metadata, breaking debugging, logging, and documentation tools.Confusing a decorator with a decorator factory.
@retry(no parentheses) passes the function as the first argument;@retry()(with parentheses) calls the factory first, then passes the function to the returned decorator. Mixing these up causesTypeError.Mutating shared mutable state in decorator closures without understanding scope. For example, using a plain list for caching works per-function, but if the decorator is applied to multiple functions, each gets its own closure — unless you accidentally reference a shared object.
Decorating methods without accounting for
self. A generic decorator using*args, **kwargsworks fine, but if you explicitly name parameters, forgettingselffor instance methods causesTypeError: missing 1 required positional argument.Applying decorators in the wrong order when stacking. The decorator closest to the function definition runs first (innermost).
@authabove@timermeans timing includes auth overhead; reversing them means auth wraps the already-timed function.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Decorators