Decorators Explained: From Functions to Frameworks
The Copy-Paste Crisis: When Timing Code Invades Twenty Functions
Sofia is three months into her first Python backend role.
🎯 System Scenario & Requirements
System Goal
Sofia is three months into her first Python backend role.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- Sofia is three months into her first Python backend role.
- The team runs a performance review and discovers the data-processing API is slow.
- The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.
Constraints & Assumptions
- Constraints are derived from the rebuilt scenario stages.
⚡ Execution Stages & Decision Trace
Stage 1 — Python Functions as First-Class Citizens: The Foundation Decorators Build On
Before a decorator can work, you need to see functions the way Python sees them — not as named blocks of code but as objects you can pass, store, and return just like integers or strings.
Functions Are Objects You Can Pass Around When Python executes def greet(name): it allocates a function object and binds the name greet to it.
Deep Dive
Scenario context
Sofia is three months into her first Python backend role. The team runs a performance review and discovers the data-processing API is slow. The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.
She does it the obvious way. She copies the same five lines to the top and bottom of each function:
import time
def process_orders(orders):
st…
This walkthrough focuses on **The Copy-Paste Crisis: When Timing Code Invades Twenty Functions**.
Before making a design choice, clarify:
- Sofia is three months into her first Python backend role.
- The team runs a performance review and discovers the data-processing API is slow.
- The tech lead asks her to add timing instrumentation to every endpoint handler — 20 functions spread across four files.
**Concepts in play:** low-level-design-systems, design-patterns-gof, caching, stack-and-queue-data-structures
## Stage 1 — Python Functions as First-Class Citizens: The Foundation Decorators Build On
- Before a decorator can work, you need to see functions the way Python sees them — not as named blocks of code but as objects you can pass, store, and return just like integers or strings.
- Functions Are Objects You Can Pass Around When Python executes def greet(name): it allocates a function object and binds the name greet to it.
- greet is just a label pointing at that object.
- Assigning say hello = greet copies the reference — both names point at the same function object.
*Applies [**low-level-design-systems**](/concept/low-level-design-systems) to the decision above.*
```python
def greet(name):
return f"Hello, {name}!"
# Assign to a variable — no call parentheses
say_hello = greet
print(say_hello("Alice")) # Hello, Alice!
# Store in a list alongside other callables
actions = [greet, str.upper, len]
print([f("world") for f in actions]) # ['Hello, world!', 'WORLD', 5]
def make_multiplier(factor):
def multiply(value):
return value * factor # factor is captured from make_multiplier's scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
# The captured variable lives in __closure__
print(double.__closure__[0].cell_contents) # 2
Observable outcome: Assigning say hello = greet copies the reference — both names point at the same function object.
Stage 2 — Crafting Decorators by Hand: Timer, Logger, Retry, and functools.wraps
- The Minimal Wrapper Pattern Every function-based decorator follows this three-part structure: accept the original function as an argument, define an inner wrapper that adds behavior before and after calling the original…
- The args, kwargs signature in wrapper is essential.
- It means the wrapper accepts any combination of positional and keyword arguments and forwards them transparently to func .
- Without it, the decorator would only work with one specific signature.
def my_decorator(func):
def wrapper(*args, **kwargs):
# before: run code before the original function
result = func(*args, **kwargs)
# after: run code after the original function
return result
return wrapper
def bad_timer(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_timer
def calculate_total(items):
"""Sum all item prices and return the total."""
return sum(item.price for item in items)
print(calculate_total.__name__) # wrapper — wrong!
print(calculate_total.__doc__) # None — lost!
Observable outcome: Without it, the decorator would only work with one specific signature.
Stage 3 — How Python Applies Decorators Under the Hood
- The Internals of Decorator Application Import time versus call time.
- Decorators run at import time — when Python executes the def statement, not when the function is called.
- This distinction has real consequences.
- A module-level @app.route("/") registers the route with Flask the moment the module is imported, not when the first request arrives.
def loud_decorator(func):
print(f"Decorating {func.__name__}") # runs at import time
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}") # runs at call time
return func(*args, **kwargs)
return wrapper
@loud_decorator
def compute():
return 42
# Output when the module is imported (before compute() is called):
# Decorating compute
# Output when compute() is called:
# Calling compute
import inspect
@timer
@retry(max_attempts=2)
def process(data):
return data
# Unwrap to the original — ignores all decorator layers
original = inspect.unwrap(process)
print(original.__name__) # process
Observable outcome: A module-level @app.route("/") registers the route with Flask the moment the module is imported, not when the first request arrives.
Stage 4 — Tracing a Stacked Decorator: Call Order, Execution, and Return
- When you write @retry , @timer , and @log on the same function, understanding which runs first at call time versus return time is not obvious.
- The diagram below traces the exact execution path for a three-decorator stack.
- Read this diagram top to bottom for the call path and bottom to top for the return path.
- retry is outermost: it starts first, receives the result last, and is the only layer that can re-invoke the inner stack on failure.
graph TD
A[Caller invokes fetch_price] --> B[retry wrapper starts]
B --> C[timer wrapper starts]
C --> D[log wrapper starts]
D --> E[original fetch_price executes]
E --> F[log wrapper finishes and returns result]
F --> G[timer records elapsed time and returns result]
G --> H[retry checks for exceptions and returns result]
H --> I[result returned to caller]
@retry(max_attempts=3) # outermost
@timer # middle
@log # innermost
def fetch_price(product_id):
...
# Python internally does this at decoration time:
fetch_price = retry(max_attempts=3)(timer(log(fetch_price)))
Observable outcome: retry is outermost: it starts first, receives the result last, and is the only layer that can re-invoke the inner stack on failure.
Stage 5 — Where Decorators Run the Python Ecosystem
- Decorators are not an advanced niche feature — they are the primary API surface of the most popular Python frameworks.
- Recognizing the pattern makes every framework immediately more readable.
- Flask and FastAPI: Routes Are Decorated Functions Flask's @app.route() is a decorator factory.
- At import time, app.route("/products") returns a decorator that registers list products in the Flask URL map: FastAPI uses the same pattern but also reads the function's type annotations to generate OpenAPI documentatio…
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/products", methods=["GET"])
def list_products():
return jsonify([{"id": 1, "name": "Widget"}])
# Equivalent to:
# list_products = app.route("/products", methods=["GET"])(list_products)
import pytest
@pytest.fixture
def db_connection():
conn = create_test_database()
yield conn # setup done; test runs here
conn.close() # teardown runs after the test
def test_user_creation(db_connection):
# db_connection is injected by pytest — no import needed
user = db_connection.create_user("alice@example.com")
assert user.id is not None
Observable outcome: At import time, app.route("/products") returns a decorator that registers list products in the Flask URL map: FastAPI uses the same pattern but also reads the function's type annotations to generate OpenAPI documentatio…
Stage 6 — Decorator Versus Subclass, Middleware, and Class-Based Wrappers
- Not every cross-cutting concern belongs in a decorator.
- Knowing when to use each approach prevents over-engineering.
- A decorator adds behavior from the outside without requiring the original function to know about it.
- A subclass adds behavior from the inside — the child class controls what changes.
import time
import functools
class RateLimit:
def __init__(self, calls_per_second):
self.calls_per_second = calls_per_second
self._call_times = []
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
window_start = now - 1.0
self._call_times = [t for t in self._call_times if t > window_start]
if len(self._call_times) >= self.calls_per_second:
raise RuntimeError(
f"Rate limit exceeded: {self.calls_per_second} calls/second"
)
self._call_times.append(now)
return func(*args, **kwargs)
return wrapper
rate_limit = RateLimit(calls_per_second=5)
@rate_limit
def send_notification(user_id, message):
print(f"Notifying {user_id}: {message}")
Observable outcome: A subclass adds behavior from the inside — the child class controls what changes.
End-to-end outcome
After working through the stages, you should be able to explain:
- functools.wraps is not optional.
- Every decorator that omits it breaks stack traces, logging, help() , and any tool that reads name or doc .
- TLDR: A decorator is syntactic sugar for func = decorator(func) .
- The @ symbol applies at import time, not call time.
Alternatives considered
The source focuses on one primary design. A second architecture is not compared, so no alternative is invented here.
Failure and degraded modes
The source does not isolate a separate failure mode. Limits that matter for the scenario are called out in the stage decisions above.
