Intermediate
12 min read
#Python#Decorators#Functional Programming#Metaprogramming

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_decorator above a function is pure syntax sugar for my_function = my_decorator(my_function).


2. Building a Decorator From Scratch#

2.1 The Core Idea#

🐍 Python
def 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#

🐍 Python
def 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.

🐍 Python
def 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.

🐍 Python
import 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#

🐍 Python
import 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#

🐍 Python
import 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#

🐍 Python
import 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.

🐍 Python
import 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.

🐍 Python
import 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?

LayerRole
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.

🐍 Python
import 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.

🐍 Python
def 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:

🐍 Python
@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#

🐍 Python
class 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#

🐍 Python
class 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 (or functools.update_wrapper for classes) inside a decorator.
  • Make wrappers accept *args, **kwargs unless you deliberately want to restrict the signature.
  • Reach for functools.lru_cache/functools.cache before 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.
Knowledge Checkpoint

Python Decorators & Metaprogramming Checkpoint

Q1.Why should you always apply `@functools.wraps(func)` on inner decorator wrapper functions?
ATo speed up execution time through Cython compilation.
BTo preserve the original function's name (`__name__`), docstring (`__doc__`), and signature annotations.
CTo force the wrapped function to execute asynchronously.
DTo automatically handle uncaught exceptions.
Q2.How many levels of nested functions are required to implement a decorator that accepts custom configuration arguments (e.g. `@retry(max_attempts=3)`)?
A1 level
B2 levels
C3 levels
D4 levels
Q3.When is a decorator function executed in Python?
AEvery time the decorated function is invoked at runtime.
BOnly once when the module/function definition is first loaded and imported.
CWhen the Python garbage collector runs.
DWhen the script exits.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.