Skip to main content

Decorators - Wrapping Callables at Engineering Depth

Reading time: ~35 minutes | Level: Intermediate → Engineering

Before reading further, predict every output:

def decorator(func):
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result
return wrapper

@decorator
def greet(name):
return f"Hello, {name}"

print(greet.__name__) # ?
print(greet("Alice")) # ?
Show Answer

Output:

wrapper
before
after
Hello, Alice

greet.__name__ is "wrapper", not "greet". The @decorator syntax replaces greet with wrapper. The wrapper function has its own __name__ attribute set to "wrapper" - the original function's metadata is lost.

greet("Alice") prints "before", then "after", and the return value "Hello, Alice" is printed by the outer print().

This metadata loss is not cosmetic. It breaks help(greet), inspect.signature(greet), logging that uses func.__name__, and test frameworks that report by function name. The fix is functools.wraps - and in production code it is non-optional.

Now consider: @app.get("/") in FastAPI, @pytest.fixture, @property, @classmethod, @lru_cache - these are all decorators. Understanding decorators at this depth means understanding a fundamental Python design pattern that appears in virtually every non-trivial Python codebase.

What You Will Learn

  • What a decorator is: a callable that takes a callable and returns a callable
  • The @ syntax desugared and why it is just assignment
  • functools.wraps - why it is non-optional in production code
  • Decorators with arguments: the three-level nesting pattern (decorator factory)
  • Class-based decorators: __init__ + __call__, when to prefer them
  • Stacking decorators: application order and call order
  • Production patterns: timing, retry with exponential backoff, caching, rate limiting
  • How FastAPI and Flask route decorators work under the hood
  • Import-time execution: why decorator side effects run once per module load

Prerequisites

  • Lesson 03: Generators and yield - generator functions return iterators
  • Lesson 04: Iterator Protocol - closures and enclosing scope familiarity
  • Comfortable writing classes with __init__ and __call__

Part 1 - What a Decorator Is

Functions as First-Class Objects

Before decorators, understand the foundation: in Python, functions are objects. They can be passed as arguments, returned from functions, and assigned to variables:

def add(a, b):
return a + b

# Assign to a variable
operation = add
print(operation(2, 3)) # 5

# Pass as an argument
def apply(func, x, y):
return func(x, y)

print(apply(add, 10, 20)) # 30

# Return from a function
def make_adder(n):
def adder(x):
return x + n
return adder # returning a function

add5 = make_adder(5)
print(add5(10)) # 15

A decorator is a function that takes a callable, wraps it, and returns a new callable:

def my_decorator(func):
def wrapper(*args, **kwargs):
# Do something before
result = func(*args, **kwargs)
# Do something after
return result
return wrapper

The @ Syntax Desugared

The @ syntax is pure syntactic sugar:

@my_decorator
def greet(name):
return f"Hello, {name}"

is exactly equivalent to:

def greet(name):
return f"Hello, {name}"

greet = my_decorator(greet) # greet is now wrapper

The decorator runs once at definition time (when the @ line executes, not when greet() is called). After decoration, greet refers to the wrapper function returned by my_decorator.

Part 2 - functools.wraps - Non-Optional in Production

The Problem

Without functools.wraps, the wrapper replaces the original function's metadata:

def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

@my_decorator
def calculate(x: int, y: int) -> int:
"""Adds two integers and returns the result."""
return x + y

print(calculate.__name__) # 'wrapper' ← wrong
print(calculate.__doc__) # None ← docstring lost

import inspect
print(inspect.signature(calculate)) # (*args, **kwargs) ← wrong signature!

This breaks:

  • help(calculate) - shows wrapper's empty docstring
  • inspect.signature(calculate) - shows *args, **kwargs instead of the real signature
  • Logging using func.__name__ - logs "wrapper" not "calculate"
  • Test frameworks - may report by function name
  • FastAPI/Flask - use __name__ to detect duplicate route names

The Fix: functools.wraps

import functools

def my_decorator(func):
@functools.wraps(func) # copies __name__, __doc__, __annotations__, __dict__, __module__, __qualname__
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

@my_decorator
def calculate(x: int, y: int) -> int:
"""Adds two integers and returns the result."""
return x + y

print(calculate.__name__) # 'calculate' ← preserved
print(calculate.__doc__) # 'Adds two...' ← preserved
print(calculate.__wrapped__) # <function calculate> ← added by wraps

import inspect
print(inspect.signature(calculate)) # (x: int, y: int) -> int ← preserved

functools.wraps also adds __wrapped__, pointing to the original unwrapped function. This lets inspect.unwrap() peel back decoration layers.

Decoration Time vs Call Time

What functools.wraps Preserves

:::warning Forgetting functools.wraps Breaks More Than You Think The consequences of omitting functools.wraps are not just cosmetic. In real production systems:

  • FastAPI uses __name__ to register routes; two decorated endpoints with the same __name__ = "wrapper" cause a conflict error
  • pytest uses __name__ in test discovery and failure reporting
  • logging using func.__name__ logs "wrapper" for every decorated function - log analysis becomes impossible
  • sphinx documentation generation uses __doc__ - no docstring means no docs
  • inspect.signature() returns (*args, **kwargs) instead of real parameters - breaks IDE tooling and runtime argument validation

Always use functools.wraps. No exceptions. :::

:::tip Always Use *args, **kwargs in Wrapper to Preserve Flexibility The wrapper should pass through all arguments unchanged. Using *args, **kwargs ensures the wrapper works for any function regardless of its signature:

import functools

def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs): # catch-all: works for any signature
return func(*args, **kwargs)
return wrapper

@my_decorator
def f(a, b, c=10, *, keyword_only=False):
...

# Works with any combination of arguments
f(1, 2)
f(1, 2, c=30)
f(1, b=2, keyword_only=True)

If you hardcode specific parameters in the wrapper, the decorator only works for functions with that exact signature. :::

Part 3 - Decorators with Arguments (Decorator Factory)

The Three-Level Nesting Pattern

A plain decorator is called once with the function as its argument. A decorator with arguments requires an extra level: the outermost function receives the arguments and returns the actual decorator:

import functools

# Level 1: factory - called with decorator arguments
def repeat(times: int):
# Level 2: actual decorator - called with the function
def decorator(func):
# Level 3: wrapper - called when the function is invoked
@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 say(message: str) -> None:
print(message)

say("hello")
# hello
# hello
# hello

The desugaring makes the three levels explicit:

# @repeat(times=3) def say: is exactly:
say = repeat(times=3)(say)
# Step 1: repeat(times=3) → returns decorator (Level 2 function)
# Step 2: decorator(say) → returns wrapper (Level 3 function)
# Step 3: say = wrapper

Production: Retry with Exponential Backoff

import functools
import time
import random
import logging

logger = logging.getLogger(__name__)


def retry(
max_attempts: int = 3,
exceptions: tuple = (Exception,),
base_delay: float = 0.5,
backoff_factor: float = 2.0,
jitter: bool = True,
):
"""
Retry a function on failure with exponential backoff.

Args:
max_attempts: Maximum number of attempts (including the first call)
exceptions: Tuple of exception types that trigger a retry
base_delay: Initial delay in seconds before the first retry
backoff_factor: Multiply delay by this factor on each retry
jitter: Add random jitter to prevent thundering herd
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
delay = base_delay
last_exception = None

for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_attempts:
logger.error(
f"{func.__name__} failed after {max_attempts} attempts: {e}"
)
raise

sleep_time = delay
if jitter:
sleep_time += random.uniform(0, delay * 0.1)

logger.warning(
f"{func.__name__} attempt {attempt}/{max_attempts} failed: {e}. "
f"Retrying in {sleep_time:.2f}s"
)
time.sleep(sleep_time)
delay *= backoff_factor

return wrapper
return decorator


@retry(max_attempts=4, exceptions=(ConnectionError, TimeoutError), base_delay=0.1)
def fetch_data(url: str) -> dict:
"""Fetches data from a URL. Retries on connection errors with backoff."""
if random.random() < 0.5:
raise ConnectionError(f"Could not connect to {url}")
return {"url": url, "data": "..."}

Part 4 - Class-Based Decorators

__init__ + __call__

A decorator can also be a class that stores state in __init__ and acts as a callable via __call__:

import functools
import time


class TimingDecorator:
"""
A class-based decorator that measures and records execution time.
Exposes call history as a public attribute for inspection.
"""

def __init__(self, func):
functools.update_wrapper(self, func) # equivalent to @functools.wraps
self._func = func
self.call_count = 0
self.total_time = 0.0
self.timings: list[float] = []

def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self._func(*args, **kwargs)
elapsed = time.perf_counter() - start

self.call_count += 1
self.total_time += elapsed
self.timings.append(elapsed)

return result

@property
def average_time(self) -> float:
if not self.timings:
return 0.0
return self.total_time / self.call_count

def reset(self) -> None:
self.call_count = 0
self.total_time = 0.0
self.timings.clear()

def __repr__(self) -> str:
return (
f"TimingDecorator({self._func.__name__!r}, "
f"calls={self.call_count}, avg={self.average_time:.4f}s)"
)


@TimingDecorator
def compute(n: int) -> int:
"""Sum 0..n-1."""
return sum(range(n))


compute(10_000)
compute(100_000)
compute(1_000_000)

print(compute.call_count) # 3
print(f"{compute.average_time:.6f}s")
print(repr(compute))
# TimingDecorator('compute', calls=3, avg=0.012000s)

When to Use Class-Based vs Function-Based

Use a class-based decorator when:

  • You need to expose public methods or properties (like compute.call_count, compute.reset())
  • You need __repr__ for debugging the decorated function itself
  • You want the decorator to be subclassable or overridable
  • The state management grows complex enough to benefit from a class

Use a function-based decorator for everything else - they are simpler and require less boilerplate.

Part 5 - Stacking Decorators

Application Order: Bottom-Up; Call Order: Top-Down

When you stack decorators, they apply from bottom to top (innermost first) at decoration time. At call time, execution flows top-down through the wrapper stack:

import functools

def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("bold: start")
result = func(*args, **kwargs)
print("bold: end")
return result
return wrapper

def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("italic: start")
result = func(*args, **kwargs)
print("italic: end")
return result
return wrapper

@bold
@italic
def render(text):
print(f" render: {text}")
return text

render("hello")

Output:

bold: start
italic: start
render: hello
italic: end
bold: end

The desugaring:

# @bold @italic def render:
# Step 1 (bottom): render = italic(render) → render is italic's wrapper
# Step 2 (top): render = bold(render) → render is bold's wrapper, calling italic's wrapper

Stacking Order Matters

Order determines which logic runs first. In authentication + rate limiting:

@require_auth # outermost: auth check runs first - reject unauthenticated before rate check
@rate_limit(100) # innermost: rate check only runs for authenticated users
def get_resource():
...

# vs.

@rate_limit(100) # outermost: counts unauthenticated requests against rate limit
@require_auth # innermost: runs only after rate limit passes
def get_resource():
...

Real-World Stacking in FastAPI

@app.get("/users/{user_id}") # outermost: route registration
@require_auth # middle: authentication check
@rate_limit(max_calls=100) # innermost: rate limiting (runs just before the handler)
async def get_user(user_id: int):
...

# Decoration order (bottom-up):
# 1. rate_limit wraps get_user
# 2. require_auth wraps the rate-limited version
# 3. app.get registers the fully-wrapped function and returns it unchanged

# Call order (top-down):
# app.get dispatches → require_auth → rate_limit → get_user

Part 6 - Production Decorator Patterns

Timing Decorator with Threshold Warning

import functools
import time
import logging

logger = logging.getLogger(__name__)


def timed(func=None, *, threshold_ms: float = 0.0):
"""
Log execution time. Optionally warn when execution exceeds threshold_ms.

Supports both @timed and @timed(threshold_ms=100) usage.
"""
# Handle both @timed and @timed(threshold_ms=100)
if func is None:
return functools.partial(timed, threshold_ms=threshold_ms)

@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
if threshold_ms > 0 and elapsed_ms > threshold_ms:
logger.warning(
f"{func.__name__} took {elapsed_ms:.1f}ms "
f"(threshold: {threshold_ms}ms)"
)
else:
logger.debug(f"{func.__name__} took {elapsed_ms:.1f}ms")

return wrapper


@timed
def fast_operation():
return sum(range(1_000))


@timed(threshold_ms=50)
def slow_operation():
time.sleep(0.1)
return "done"

Memoization Cache

import functools


def memoize(func):
"""Cache results of a function based on its arguments."""
cache: dict = {}

@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]

wrapper.cache = cache # expose for inspection
wrapper.cache_clear = cache.clear

return wrapper


@memoize
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(35)) # fast - no repeated computation
print(len(fibonacci.cache)) # 36 cached values
fibonacci.cache_clear()

For production use, prefer functools.lru_cache or functools.cache (Python 3.9+), which handle thread safety and edge cases.

Rate Limiter

import functools
import time
from collections import deque


def rate_limit(max_calls: int, period: float = 1.0):
"""Limit a function to max_calls calls within period seconds."""
def decorator(func):
call_times: deque = deque()

@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()

# Remove calls outside the sliding window
while call_times and now - call_times[0] >= period:
call_times.popleft()

if len(call_times) >= max_calls:
raise RuntimeError(
f"{func.__name__}: rate limit exceeded "
f"({max_calls} calls per {period}s)"
)

call_times.append(now)
return func(*args, **kwargs)

return wrapper
return decorator


@rate_limit(max_calls=5, period=1.0)
def api_call(endpoint: str) -> str:
return f"Response from {endpoint}"

Part 7 - How Framework Decorators Work

FastAPI and Flask Route Decorators Under the Hood

Route decorators like @app.get("/") register functions in a routing table - they add side effects at decoration time and typically return the function unchanged. Here is a stripped-down implementation showing exactly what happens:

import functools
from typing import Callable


class MiniApp:
"""A stripped-down router demonstrating how @app.get works."""

def __init__(self):
self._routes: dict[tuple[str, str], Callable] = {}

def get(self, path: str):
"""Decorator factory - returns a decorator that registers the route."""
def decorator(func: Callable) -> Callable:
route_key = ("GET", path)
if route_key in self._routes:
raise ValueError(
f"Duplicate route GET {path}. "
f"Existing: {self._routes[route_key].__name__}, "
f"New: {func.__name__}"
# Without functools.wraps, func.__name__ would be
# 'wrapper' for all decorated handlers - causing false conflicts!
)
# Side effect: register in routing table at decoration time
self._routes[route_key] = func
# Return function unchanged (or lightly wrapped)
return func
return decorator

def dispatch(self, method: str, path: str, **kwargs):
handler = self._routes.get((method, path))
if handler is None:
return {"error": 404, "message": "Not Found"}
return handler(**kwargs)


app = MiniApp()


@app.get("/users")
def list_users():
return {"users": ["Alice", "Bob"]}


@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"item_id": item_id}


print(app.dispatch("GET", "/users"))
# {'users': ['Alice', 'Bob']}

print(app.dispatch("GET", "/items/{item_id}", item_id=42))
# {'item_id': 42}

:::danger Decorators Run at Import Time - Side Effects Execute Once Per Module Load A decorator's outer code runs when the @ line is processed - which happens when the module is imported. This has critical implications:

import time

def log_registration(func):
# This print runs at import time, not at call time
print(f"Registering: {func.__name__} at {time.time()}")
return func

@log_registration # runs when this module is first imported
def handle_request():
return "ok"

# Consequences:
# - print() in decorator → executes during test collection, slowing test startup
# - Database connections opened in decorator → open before config is loaded
# - Heavy computation in decorator → slows every import of the module

Keep decorator body logic minimal. Move expensive work inside the wrapper so it runs only at call time. :::

:::note @property, @classmethod, @staticmethod Are All Decorators The built-in descriptor decorators are standard decorators implemented in C. They follow the same @name desugaring:

class MyClass:
@property # is: value = property(value)
def value(self): ...

@classmethod # is: create = classmethod(create)
def create(cls): ...

@staticmethod # is: helper = staticmethod(helper)
def helper(): ...

Understanding decorators means understanding how these work - property, classmethod, and staticmethod are callable objects whose __get__ makes them descriptors. The @ syntax is not special syntax for built-ins; it is the same protocol for all decorators. :::

Part 8 - Putting It Together: A Production Decorator Stack

import functools
import time
import logging
import hashlib
import json
from typing import Any

logger = logging.getLogger(__name__)


def validate_types(func):
"""Validate argument types against annotations at runtime."""
import inspect
hints = func.__annotations__
sig = inspect.signature(func)

@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()

for param_name, value in bound.arguments.items():
if param_name in hints and param_name != "return":
expected = hints[param_name]
if not isinstance(value, expected):
raise TypeError(
f"{func.__name__}: parameter '{param_name}' "
f"expected {expected.__name__}, got {type(value).__name__}"
)
return func(*args, **kwargs)

return wrapper


def cache_result(ttl_seconds: float = 60.0):
"""Cache function results with a time-to-live."""
def decorator(func):
_cache: dict[str, tuple[Any, float]] = {}

@functools.wraps(func)
def wrapper(*args, **kwargs):
key = hashlib.md5(
json.dumps((args, sorted(kwargs.items())), default=str).encode()
).hexdigest()

if key in _cache:
result, cached_at = _cache[key]
if time.monotonic() - cached_at < ttl_seconds:
logger.debug(f"{func.__name__} cache hit for key {key[:8]}")
return result

result = func(*args, **kwargs)
_cache[key] = (result, time.monotonic())
return result

wrapper.cache = _cache
return wrapper
return decorator


# Stack: validate_types runs innermost (closest to function), cache_result outermost
@cache_result(ttl_seconds=300)
@validate_types
def get_user_profile(user_id: int, include_history: bool = False) -> dict:
"""
Fetch a user profile. Results are cached for 5 minutes.
Types are validated before the cache is checked.
"""
return {"id": user_id, "name": "Alice", "history": [] if not include_history else [1, 2, 3]}


print(get_user_profile(42)) # DB call, cached
print(get_user_profile(42)) # cache hit
print(get_user_profile(42, True)) # different args - new call, cached separately

try:
get_user_profile("42") # TypeError: expected int, got str
except TypeError as e:
print(e)

Common Mistakes

Mistake 1 - Forgetting functools.wraps

# Wrong - metadata lost, breaks frameworks
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

# Right
import functools

def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

Mistake 2 - Calling the Wrapper Instead of Returning It

# Wrong - calls wrapper() immediately during decoration
def outer(func):
def wrapper():
return func()
return wrapper() # BUG: calling wrapper(), not returning it

# Right
def outer(func):
def wrapper():
return func()
return wrapper # return the function object

Mistake 3 - Returning the Wrong Level in a Factory

# Wrong - repeat() returns wrapper (skipping the decorator level)
def repeat(times):
@functools.wraps(func) # NameError: func not in scope
def wrapper(*args, **kwargs):
for _ in range(times): func(*args, **kwargs)
return wrapper # repeat(3) tries to return wrapper - but func is undefined

# Right: three levels
def repeat(times):
def decorator(func): # decorator receives func
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times): func(*args, **kwargs)
return wrapper
return decorator # repeat(3) returns decorator

Mistake 4 - Stacking Order Confusion

# Order: bottom decorator applied first
@A # applied second: A(B(func)) - A is outermost
@B # applied first: B(func) - B is innermost
def func(): ...

# At call time: A's wrapper → B's wrapper → func
# If A = auth check, B = logging:
# auth runs first (before logging) - useful if you want to avoid logging failed auth attempts

Graded Practice Challenges

Level 1 - Predict the Output

Question 1: What does this print?

import functools

def debug(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

@debug
def multiply(a, b, factor=1):
return a * b * factor

multiply(3, 4)
multiply(2, 5, factor=3)
print(multiply.__name__)
Show Answer

Output:

Calling multiply with args=(3, 4), kwargs={}
multiply returned 12
Calling multiply with args=(2, 5), kwargs={'factor': 3}
multiply returned 30
multiply

functools.wraps preserves __name__, so multiply.__name__ is "multiply" not "wrapper". The kwargs dict only contains keyword arguments - positional arguments appear in args.

Question 2: What does this print?

def make_decorator(label):
def decorator(func):
print(f"Decorating {func.__name__!r} with label={label!r}")
def wrapper(*args, **kwargs):
print(f"[{label}] calling {func.__name__!r}")
return func(*args, **kwargs)
return wrapper
return decorator

print("--- before decorating ---")

@make_decorator("A")
@make_decorator("B")
def greet():
print("hello")

print("--- before calling ---")
greet()
Show Answer

Output:

--- before decorating ---
Decorating 'greet' with label='B'
Decorating 'wrapper' with label='A'
--- before calling ---
[A] calling 'wrapper'
[B] calling 'greet'
hello

Decoration happens bottom-up: make_decorator("B") runs first, printing "Decorating 'greet'". It returns a wrapper. Then make_decorator("A") runs on that wrapper - note the name is now "wrapper" because there is no functools.wraps. At call time (top-down): [A] prints first (outermost), then [B], then "hello".

Question 3: What does this print?

import functools

def once(func):
"""Ensures a function is called only once. Returns the first result on all subsequent calls."""
called = False
cached_result = None

@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal called, cached_result
if not called:
cached_result = func(*args, **kwargs)
called = True
return cached_result

return wrapper

@once
def initialize():
print("initializing...")
return 42

print(initialize())
print(initialize())
print(initialize())
Show Answer

Output:

initializing...
42
42
42

The first call executes the original function, prints "initializing...", stores 42, and sets called = True. Subsequent calls skip the function body and return the cached 42. The closure variables called and cached_result persist across calls because they live in the enclosing scope of wrapper.

Question 4: What does this print?

import functools

class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.num_calls = 0

def __call__(self, *args, **kwargs):
self.num_calls += 1
return self.func(*args, **kwargs)

@CountCalls
def add(a, b):
return a + b

print(add(1, 2))
print(add(3, 4))
print(add.num_calls)
print(add.__name__)
Show Answer

Output:

3
7
2
add

CountCalls is a class-based decorator. @CountCalls calls CountCalls(add), creating an instance where self.func = add. Each call to add(...) calls CountCalls.__call__, which increments num_calls and delegates to the original function. functools.update_wrapper(self, func) copies __name__ and other attributes onto the instance, so add.__name__ is "add".

Question 5: What does this print?

import functools

def prefix(text):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"{text}: {result}"
return wrapper
return decorator

@prefix("INFO")
@prefix("LOG")
def message():
return "server started"

print(message())
Show Answer

Output:

INFO: LOG: server started

Decoration order (bottom-up): prefix("LOG") wraps message first. Then prefix("INFO") wraps log_wrapper. At call time (top-down): info_wrapper calls log_wrapper() → which calls message() → returns "server started"log_wrapper returns "LOG: server started"info_wrapper returns "INFO: LOG: server started".

Level 2 - Debug Challenge

Find and fix all bugs:

import functools
import time

def retry(max_attempts, delay=0.1):
def decorator(func):
def wrapper(*args, **kwargs): # bug 1
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts: # bug 2
raise
time.sleep(delay)
return wrapper
return decorator


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:.3f}s")
# bug 3: missing return
return wrapper


@timed
@retry(max_attempts=3, delay=0.0)
def unstable():
import random
if random.random() < 0.5:
raise ValueError("flaky!")
return "success"

print(unstable())
Show Solution

Bugs:

  1. wrapper in retry is missing @functools.wraps(func) - function name and docstring are lost after decoration.

  2. if attempt == max_attempts - this condition is never true. range(max_attempts) yields 0 to max_attempts - 1. The last attempt value is max_attempts - 1, never max_attempts. Should be attempt == max_attempts - 1.

  3. The timed wrapper does not return result. The decorated function's return value is computed and discarded - all timed-decorated functions silently return None.

Fixed version:

import functools
import time

def retry(max_attempts: int, delay: float = 0.1):
def decorator(func):
@functools.wraps(func) # fix 1: add functools.wraps
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1: # fix 2: correct boundary
raise
time.sleep(delay)
return wrapper
return decorator


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:.3f}s")
return result # fix 3: return the result
return wrapper


@timed
@retry(max_attempts=3, delay=0.0)
def unstable():
import random
if random.random() < 0.5:
raise ValueError("flaky!")
return "success"

print(unstable()) # 'success' (after 0–2 retries)

Level 3 - Design Challenge

Design a @validate decorator factory that:

  1. Accepts keyword argument validators: @validate(age=lambda x: x >= 0, name=lambda x: len(x) > 0)
  2. Validates the specified arguments before calling the function
  3. Raises ValueError with a descriptive message when validation fails
  4. Does not affect unmentioned arguments
  5. Preserves the original function's signature and docstring
  6. Raises ValueError at decoration time if a validator key does not match any parameter name (fail fast)
Show Reference Solution
import functools
import inspect
from typing import Any, Callable


def validate(**validators: Callable[[Any], bool]):
"""
Decorator factory that validates specific function arguments.

Usage:
@validate(age=lambda x: x >= 0, name=lambda x: len(x.strip()) > 0)
def create_user(name: str, age: int) -> dict:
...
"""
def decorator(func: Callable) -> Callable:
sig = inspect.signature(func)
param_names = set(sig.parameters)

# Fail fast at decoration time: catch typos in validator keys
unknown = set(validators) - param_names
if unknown:
raise ValueError(
f"@validate: {func.__name__} has no parameters: {unknown}. "
f"Valid parameters: {param_names}"
)

@functools.wraps(func)
def wrapper(*args, **kwargs):
# Bind arguments to parameter names (handles positional + keyword)
try:
bound = sig.bind(*args, **kwargs)
except TypeError as e:
raise TypeError(f"{func.__name__}: {e}") from e

bound.apply_defaults()

# Run each validator
for param_name, validator in validators.items():
if param_name in bound.arguments:
value = bound.arguments[param_name]
if not validator(value):
raise ValueError(
f"{func.__name__}: argument '{param_name}' "
f"failed validation with value {value!r}"
)

return func(*args, **kwargs)

return wrapper
return decorator


# Usage
@validate(
name=lambda x: isinstance(x, str) and len(x.strip()) > 0,
age=lambda x: isinstance(x, int) and 0 <= x <= 150,
email=lambda x: "@" in x and "." in x.split("@")[-1],
)
def create_user(name: str, age: int, email: str) -> dict:
"""Create a new user record with validated inputs."""
return {"name": name, "age": age, "email": email}


print(create_user("Alice", 30, "[email protected]"))
# {'name': 'Alice', 'age': 30, 'email': '[email protected]'}

try:
create_user("Bob", -1, "[email protected]")
except ValueError as e:
print(e)
# create_user: argument 'age' failed validation with value -1

# Metadata is preserved
print(create_user.__name__) # create_user
print(create_user.__doc__) # Create a new user record...
print(inspect.signature(create_user)) # (name: str, age: int, email: str) -> dict

# Typo caught at decoration time
try:
@validate(nme=lambda x: len(x) > 0) # 'nme' is not a parameter
def f(name): ...
except ValueError as e:
print(e)
# @validate: f has no parameters: {'nme'}. Valid parameters: {'name'}

Key Takeaways

  • A decorator is a callable that takes a callable and returns a callable - the @name syntax is shorthand for func = name(func)
  • Decoration happens at import time (when the @ line is processed), not at call time - side effects in decorator bodies execute once per module load
  • functools.wraps is non-optional in production - it preserves __name__, __doc__, __annotations__, and adds __wrapped__ for introspection
  • Without functools.wraps, frameworks (FastAPI, Flask), test runners, logging, and inspect all see "wrapper" as the function name - causing silent and hard-to-diagnose bugs
  • Decorator factories use three-level nesting: outer function receives arguments, middle function receives the callable, inner function is the wrapper
  • Class-based decorators use __init__ to receive the function and __call__ to wrap invocations - prefer them when you need state, public methods, or __repr__
  • Stacking decorators applies bottom-up at decoration time; call order is top-down at runtime - the topmost decorator's wrapper executes first
  • Always use *args, **kwargs in wrappers to preserve signature flexibility for any decorated function
  • @property, @classmethod, @staticmethod, and @functools.lru_cache are all decorators implemented in C - the @ pattern is universal
  • Production decorator patterns - timing, retry with backoff, caching, rate limiting, validation - are direct and common applications of this pattern

What's Next

Lesson 06 covers closures in depth - how Python captures free variables, what CPython's cell objects are, the nonlocal keyword, and the loop variable capture bug. Every decorator creates a closure; understanding closures means understanding how decorators actually work at the CPython level.

© 2026 EngineersOfAI. All rights reserved.