Decorators

0/5 in this phase0/54 across the roadmap

📖 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

codeTap to expand ⛶
1# ============================================================
2# Basic function decorator with @wraps
3# ============================================================
4import functools
5import time
6import logging
7from typing import Any, Callable, TypeVar
8
9logger = logging.getLogger(__name__)
10
11F = TypeVar("F", bound=Callable[..., Any])
12
13
14def 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() - start
21 logger.info(f"{func.__name__} took {elapsed:.4f}s")
22 return result
23 return wrapper
24
25
26@timer
27def slow_computation(n: int) -> int:
28 """Compute the sum of squares up to n."""
29 return sum(i * i for i in range(n))
30
31
32result = slow_computation(1_000_000)
33print(slow_computation.__name__) # "slow_computation" (preserved by @wraps)
34
35
36# ============================================================
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 = None
45 for attempt in range(1, max_attempts + 1):
46 try:
47 return func(*args, **kwargs)
48 except exceptions as e:
49 last_exception = e
50 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_exception
57 return wrapper
58 return decorator
59
60
61@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 random
65 if random.random() < 0.7:
66 raise ConnectionError("Server unavailable")
67 return {"status": "ok", "url": url}
68
69
70# ============================================================
71# Stacking decorators
72# ============================================================
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 wrapper
85
86
87@timer # Outer: measures total time including validation
88@validate_positive # Inner: validates first, then runs function
89def calculate_price(base: float, tax_rate: float) -> float:
90 """Calculate total price with tax."""
91 return base * (1 + tax_rate)
92
93
94price = calculate_price(100.0, 0.08) # $108.00
95
96
97# ============================================================
98# Class decorator
99# ============================================================
100def singleton(cls):
101 """Ensure only one instance of a class exists."""
102 instances = {}
103
104 @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_instance
110
111
112@singleton
113class DatabaseConnection:
114 def __init__(self, host: str = "localhost", port: int = 5432):
115 self.host = host
116 self.port = port
117 print(f"Connecting to {host}:{port}")
118
119 def query(self, sql: str) -> str:
120 return f"Executed: {sql}"
121
122
123db1 = DatabaseConnection("prod-db", 5432) # prints "Connecting to prod-db:5432"
124db2 = DatabaseConnection("other-host", 3306) # No print! Returns same instance
125print(db1 is db2) # True
126
127
128# ============================================================
129# Decorator that works with or without arguments
130# ============================================================
131def cache(func=None, *, maxsize=128):
132 """LRU cache decorator that works with or without parentheses.
133
134 Usage:
135 @cache # No arguments
136 @cache() # Empty arguments
137 @cache(maxsize=256) # With arguments
138 """
139 def decorator(fn):
140 return functools.lru_cache(maxsize=maxsize)(fn)
141
142 if func is not None:
143 # Called without arguments: @cache
144 return decorator(func)
145 # Called with arguments: @cache(maxsize=256)
146 return decorator
147
148
149@cache
150def fibonacci(n: int) -> int:
151 if n < 2:
152 return n
153 return fibonacci(n - 1) + fibonacci(n - 2)
154
155
156@cache(maxsize=256)
157def factorial(n: int) -> int:
158 if n <= 1:
159 return 1
160 return n * factorial(n - 1)
161
162
163# ============================================================
164# Method decorator with descriptor protocol awareness
165# ============================================================
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 result
175 return wrapper
176
177
178class OrderService:
179 @audit_method
180 def place_order(self, item: str, quantity: int) -> dict:
181 return {"item": item, "quantity": quantity, "status": "placed"}
182
183 @audit_method
184 def cancel_order(self, order_id: str) -> dict:
185 return {"order_id": order_id, "status": "cancelled"}

🏋️ Practice Exercise

Exercises:

  1. Write a @debug decorator that prints the function name, all arguments (positional and keyword), and the return value every time the function is called. Use @functools.wraps to preserve metadata.

  2. Create a parameterized @rate_limit(calls=5, period=60) decorator that raises RuntimeError if the decorated function is called more than calls times within period seconds. Track call timestamps in a list.

  3. Build a @validate_types decorator that reads function annotations and raises TypeError if any argument's type doesn't match its annotation. Handle *args and **kwargs gracefully.

  4. Implement a @memoize decorator from scratch (no functools.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.

  5. Write a class decorator @auto_repr that automatically generates a __repr__ method based on __init__ parameters using inspect.signature. The generated repr should look like ClassName(param1=value1, param2=value2).

  6. 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 causes TypeError.

  • 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, **kwargs works fine, but if you explicitly name parameters, forgetting self for instance methods causes TypeError: missing 1 required positional argument.

  • Applying decorators in the wrong order when stacking. The decorator closest to the function definition runs first (innermost). @auth above @timer means timing includes auth overhead; reversing them means auth wraps the already-timed function.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Decorators