Python Decorators — The Complete Notebook
Comprehensive guide on Python Decorators — The Complete Notebook.
Python Decorators
1. Overview#
A decorator is a function that takes another function (or class) and extends its behavior without modifying its source code. Decorators are possible because Python functions are first-class objects — they can be passed around, returned, and wrapped like any other value. This is one of the most powerful patterns in the language, used everywhere from web frameworks (@app.route) to testing (@pytest.fixture) to caching (@lru_cache).
@my_decoratorabove a function is pure syntax sugar formy_function = my_decorator(my_function).
2. Building a Decorator From Scratch#
2.1 The Core Idea#
🐍 PythonInteractive WebAssemblydef shout(func):
def wrapper():
result = func()
return result.upper()
return wrapper
def greet():
return "hello"
greet = shout(greet) # manual decoration
print(greet()) # HELLO
2.2 Using @ Syntax#
🐍 PythonInteractive WebAssemblydef shout(func):
def wrapper():
result = func()
return result.upper()
return wrapper
@shout
def greet():
return "hello"
print(greet()) # HELLO — identical result, cleaner syntax
2.3 Handling Arguments with *args/**kwargs#
The wrapped function might take any number of arguments — the wrapper needs to accept and forward all of them.
🐍 PythonInteractive WebAssemblydef shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name, greeting="hello"):
return f"{greeting}, {name}"
print(greet("Kamal")) # HELLO, KAMAL
print(greet("Kamal", greeting="hi")) # HI, KAMAL
3. Preserving Metadata with functools.wraps#
Without @functools.wraps, the wrapped function loses its original name, docstring, and other metadata — which breaks introspection, debugging, and documentation tools.
🐍 PythonInteractive WebAssemblyimport functools
def shout(func):
@functools.wraps(func) # copies __name__, __doc__, etc. from func to wrapper
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
"""Return a greeting for the given name."""
return f"hello, {name}"
print(greet.__name__) # greet (would be "wrapper" without functools.wraps)
print(greet.__doc__) # Return a greeting for the given name.
4. Practical, Real-World Decorators#
4.1 Timing#
🐍 PythonInteractive WebAssemblyimport time
import functools
def timed(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
@timed
def slow_computation(n):
return sum(i ** 2 for i in range(n))
slow_computation(1_000_000)
4.2 Logging#
🐍 PythonInteractive WebAssemblyimport functools
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@logged
def add(a, b):
return a + b
add(3, 4)
4.3 Retry on Failure#
🐍 PythonInteractive WebAssemblyimport functools
import time
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:
print(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=2)
def fetch_data(url):
# simulated flaky network call
import random
if random.random() < 0.7:
raise ConnectionError("Network timeout")
return f"Data from {url}"
4.4 Caching with functools.lru_cache#
Python's standard library ships a production-ready memoization decorator.
🐍 PythonInteractive WebAssemblyimport functools
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(35)) # fast — repeated calls are cached
print(fibonacci.cache_info()) # CacheInfo(hits=..., misses=..., maxsize=128, currsize=...)
5. Decorators That Take Arguments#
A decorator with its own arguments (like @retry(max_attempts=3) above) needs an extra layer of nesting: a function that returns a decorator, which returns a wrapper.
🐍 PythonInteractive WebAssemblyimport functools
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(times=3)
def roll_dice():
import random
return random.randint(1, 6)
print(roll_dice()) # e.g. [4, 1, 6]
Why three levels of nesting?
| Layer | Role |
|---|---|
repeat(times) | Takes the decorator's own arguments, returns the real decorator |
decorator(func) | Takes the target function, returns the wrapper |
wrapper(*args, **kwargs) | Runs when the decorated function is actually called |
6. Class-Based Decorators#
Any object with a __call__ method can act as a decorator — useful when the decorator needs to hold more complex state.
🐍 PythonInteractive WebAssemblyimport functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} has been called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello()
say_hello()
print(say_hello.call_count) # 2
7. Stacking Multiple Decorators#
Decorators apply bottom-up — the one closest to the function runs first, wrapping it, then the next one wraps that result.
🐍 PythonInteractive WebAssemblydef bold(func):
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def get_text():
return "Hello"
print(get_text()) # <b><i>Hello</i></b>
Order matters — swapping the stack order changes the output:
🐍 PythonInteractive WebAssembly@italic
@bold
def get_text_v2():
return "Hello"
print(get_text_v2()) # <i><b>Hello</b></i>
8. Built-in Decorators You'll See Constantly#
8.1 @staticmethod and @classmethod#
🐍 PythonInteractive WebAssemblyclass Temperature:
def __init__(self, celsius):
self.celsius = celsius
@staticmethod
def is_freezing(celsius):
# doesn't need access to instance (self) or class (cls)
return celsius <= 0
@classmethod
def from_fahrenheit(cls, fahrenheit):
# receives the class itself, useful for alternate constructors
celsius = (fahrenheit - 32) * 5 / 9
return cls(celsius)
t = Temperature.from_fahrenheit(98.6)
print(round(t.celsius, 1)) # 37.0
print(Temperature.is_freezing(-5)) # True
8.2 @property#
🐍 PythonInteractive WebAssemblyclass Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.area) # looks like an attribute, computed like a method
9. Summary & Best Practices Checklist#
- Always use
functools.wraps(orfunctools.update_wrapperfor classes) inside a decorator. - Make wrappers accept
*args, **kwargsunless you deliberately want to restrict the signature. - Reach for
functools.lru_cache/functools.cachebefore writing a custom caching decorator. - Remember stacked decorators apply bottom-up — order changes behavior.
- Use a parameterized decorator (
@retry(max_attempts=3)) when the decorator itself needs configuration. - Use a class-based decorator when you need to track state across calls (like a call counter).
- Keep decorators focused on cross-cutting concerns (logging, timing, caching, auth) — not core business logic.
Python Decorators & Metaprogramming Checkpoint
Finished studying this notebook?
Mark this guide as completed to update your course progress roadmap.