Als je Flask of FastAPI meer dan een week hebt gebruikt, heb je al decorators gebruikt — @app.route,
@login_required, @pytest.mark.parametrize. Ze voelen de eerste keer als magie, en dan
legt iemand uit wat er echt gebeurt en valt het kwartje meteen. Een decorator is gewoon een functie die
een andere functie omhult. De @-syntaxis is pure syntactische suiker — @my_decorator boven
een functiedefinitie is precies gelijk aan het schrijven van func = my_decorator(func) erna. Dat is het
hele geheim. De rest zijn gewoon patronen gebouwd op dat ene idee. Dit artikel bouwt het mentale model van
scratch op, dan doorloopt de patronen die je echt zult gebruiken: @functools.wraps, decorators met
argumenten, klassegebaseerde decorators, en een handvol praktijkvoorbeelden waaronder @lru_cache,
@dataclass, en een degelijke @retry met exponentiële backoff.
Functies Zijn Eersteklas Objecten
Voordat decorators logisch aanvoelen, moet je één Python-feit goed begrijpen: functies zijn objecten. Je kunt ze aan variabelen toewijzen, ze als argumenten aan andere functies doorgeven, ze vanuit functies teruggeven, en ze in lijsten of dicts opslaan. Er gebeurt niets bijzonders alleen maar omdat iets een functie is.
def greet(name: str) -> str:
return f"Hello, {name}"
# Assign to a variable — no () means we're not calling it, just referencing it
say_hello = greet
print(say_hello("Alice")) # 'Hello, Alice'
# Pass a function as an argument
def run_twice(fn, value):
return fn(value), fn(value)
run_twice(greet, "Bob") # ('Hello, Bob', 'Hello, Bob')
# Return a function from another function — this is a "factory"
def make_prefixer(prefix: str):
def prefixed_greet(name: str) -> str:
return f"{prefix}, {name}"
return prefixed_greet
morning_hello = make_prefixer("Good morning")
morning_hello("Carol") # 'Good morning, Carol'De binnenste functie prefixed_greet "sluit" over de variabele prefix uit het
omsluitende bereik — zelfs nadat make_prefixer is teruggekeerd, heeft de binnenste functie nog steeds
toegang tot prefix. Dit is een
closure,
en het is het mechanisme dat decorators laat werken. De
Python-documentatie over bereikregels
legt dit in detail uit als je het volledige beeld wilt.
Een Decorator van Scratch Bouwen
Een decorator is een functie die een functie neemt en een (gewoonlijk gewijzigde) functie teruggeeft. Het klassieke eerste voorbeeld is een timingdecorator — het omhult elke functie en logt hoe lang het duurde.
import time
import functools
def timer(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__} finished in {elapsed:.4f}s")
return result
return wrapper
@timer
def fetch_user_records(db, user_id: int):
"""Fetch all records for a given user from the database."""
return db.query("SELECT * FROM records WHERE user_id = ?", user_id)
# Calling fetch_user_records is now: timer(fetch_user_records)(db, 42)
# Which is exactly what @timer desugars toEen paar dingen om op te merken: de wrapper gebruikt *args, **kwargs zodat het elke
combinatie van argumenten kan doorgeven aan de originele functie zonder de handtekening ervan te kennen. Het vangt de
retourwaarde op in result en retourneert die, zodat de omhulde functie nog steeds identiek gedraagt
vanuit het perspectief van de aanroeper — het heeft alleen een extra afdruk-bijwerking. Verwijder de timing, en je hebt het
skelet van bijna elke decorator die je ooit zult schrijven.
@some_decorator boven een
functiedefinitie ziet, vervang het mentaal door fn = some_decorator(fn) direct
na het def-blok. De twee zijn precies gelijkwaardig. Er is geen magie — het is een functieaanroep.Waarom Je @functools.wraps Moet Gebruiken
In het bovenstaande voorbeeld staat er een @functools.wraps(fn)-regel op de wrapper. Dit is
niet optioneel. Zonder het verliest je gedecoreerde functie zijn identiteit — zijn __name__,
__doc__ en __qualname__ attributen worden allemaal vervangen door die van de binnenste
wrapper-functie. Dat veroorzaakt subtiele problemen in een paar echte situaties:
- Docstrings verdwijnen.
help(fetch_user_records)toont de lege docstring van de wrapper in plaats van"Fetch all records for a given user...". - Tracebacks liegen. Wanneer een uitzondering optreedt in de omhulde functie, toont de traceback
wrapperin plaats van de echte functienaam — moeilijk te debuggen. - Introspectie breekt. Tools zoals pytest, het routingssysteem van Flask, en
inspect.signature()vertrouwen allemaal op__name__en__wrapped__. Flask's router gooit een fout als twee routes dezelfde (wrapper)naam delen. functools.lru_cacheen vergelijkbare tools gebruiken de identiteit van de functie voor cache-sleutels — zonderwrapskun je verrassende cache-botsingen krijgen.
import functools
# Without @functools.wraps — broken
def bad_timer(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@bad_timer
def process_batch(batch_id: int):
"""Process a single batch job."""
pass
print(process_batch.__name__) # 'wrapper' ← wrong
print(process_batch.__doc__) # None ← docstring gone
# With @functools.wraps — correct
def good_timer(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@good_timer
def process_batch(batch_id: int):
"""Process a single batch job."""
pass
print(process_batch.__name__) # 'process_batch' ← correct
print(process_batch.__doc__) # 'Process a single batch job.' ← preservedfunctools.wraps
is zelf een decorator — het kopieert __module__, __name__, __qualname__,
__annotations__, __doc__ en stelt __wrapped__ in op de originele functie.
Gebruik het op elke wrapperfunctie, punt. Er is geen reden om dat niet te doen.
Decorators met Argumenten
Een gewone decorator neemt een functie en geeft een functie terug. Een decorator met argumenten heeft één niveau
meer nodig: een functie die de argumenten neemt en een decorator teruggeeft. Dat zijn drie niveaus van nesting,
en dat verward bijna iedereen de eerste keer. Hier is een @retry-decorator die een functie tot
max_attempts keer opnieuw probeert bij uitzondering:
import functools
import time
def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0):
"""
Retry a function on exception with exponential backoff.
Usage:
@retry(max_attempts=5, delay=0.5, backoff=2.0)
def call_external_api(endpoint: str) -> dict:
...
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
current_delay = delay
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except Exception as exc:
last_exception = exc
if attempt < max_attempts:
print(
f"{fn.__name__}: attempt {attempt}/{max_attempts} failed "
f"({exc!r}), retrying in {current_delay:.1f}s..."
)
time.sleep(current_delay)
current_delay *= backoff
else:
print(f"{fn.__name__}: all {max_attempts} attempts failed.")
raise last_exception
return wrapper
return decorator
@retry(max_attempts=4, delay=0.5, backoff=2.0)
def fetch_price_data(ticker: str) -> dict:
"""Fetch stock price data from external API."""
response = requests.get(f"https://api.example.com/prices/{ticker}", timeout=5)
response.raise_for_status()
return response.json()De aanroepketen wanneer Python @retry(max_attempts=4, delay=0.5, backoff=2.0) verwerkt:
eerst wordt retry(max_attempts=4, delay=0.5, backoff=2.0) aangeroepen en retourneert decorator.
Dan wordt decorator(fetch_price_data) aangeroepen en retourneert wrapper. Tot slot
wordt fetch_price_data herbonden aan wrapper. Dus @retry(...) is
fetch_price_data = retry(...)(fetch_price_data) — drie aanroepen, twee lagen omhulling.
Zodra je dat patroon ziet, houden decorator factories op verwarrend te zijn.
Klassegebaseerde Decorators
Je kunt een decorator ook als klasse implementeren door __call__ te definiëren. Dit is handig
wanneer de decorator staat moet bijhouden tussen aanroepen — een aanroepteller, een cache, verbindingspools — omdat
instantievariabelen een meer natuurlijke plek zijn voor die staat dan sluitingsvariabelen.
import functools
import time
class RateLimiter:
"""
Decorator that limits how often a function can be called.
Raises RuntimeError if the function is called within `min_interval` seconds
of the previous call.
"""
def __init__(self, min_interval: float):
self.min_interval = min_interval
self._last_called: float = 0.0
def __call__(self, fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
now = time.monotonic()
since_last = now - self._last_called
if since_last < self.min_interval:
wait = self.min_interval - since_last
raise RuntimeError(
f"{fn.__name__} called too soon — "
f"wait {wait:.2f}s before calling again."
)
self._last_called = now
return fn(*args, **kwargs)
return wrapper
# Usage — one call per 2 seconds
@RateLimiter(min_interval=2.0)
def send_sms_alert(phone: str, message: str) -> None:
"""Send an SMS alert via the gateway API."""
sms_gateway.send(phone, message)@RateLimiter(min_interval=2.0) werkt precies als een decorator factory:
RateLimiter(2.0) construeert een instantie, dan wordt die instantie aangeroepen met
send_sms_alert omdat het __call__ heeft. De instantie slaat
_last_called op als attribuut — geen gedoe met sluitingsvariabelen nodig. Zie
PEP 318 (het originele
decoratorvoorstel) voor de ontwerprationale achter de @-syntaxis, en
PEP 614 voor ontspannen
decoratorgrammatica die in Python 3.9 is geland.
Praktijkdecorators uit de Standaardbibliotheek
Controleer voordat je je eigen schrijft of de stdlib al heeft wat je nodig hebt. Drie decorators in
functools
komen constant voor in productiePython-code.
from functools import lru_cache, cache
import requests
# @cache — unbounded memoisation (Python 3.9+)
# Caches every unique set of arguments forever.
# Good for pure functions with a small domain of inputs.
@cache
def get_country_name(country_code: str) -> str:
"""Look up a country name from ISO 3166 code. Cached after first call."""
response = requests.get(f"https://restcountries.com/v3.1/alpha/{country_code}")
return response.json()[0]["name"]["common"]
get_country_name("DE") # hits the API
get_country_name("DE") # served from cache, no network call
# @lru_cache(maxsize=N) — bounded LRU cache
# Evicts least-recently-used entries once the cache hits `maxsize`.
# Better when the input domain is large and memory is a concern.
@lru_cache(maxsize=256)
def compute_discount(base_price: float, tier: str) -> float:
"""Heavy computation — price varies by tier. Cache the top 256 combinations."""
discount_table = load_discount_table() # expensive DB call
rate = discount_table.get(tier, 0.0)
return round(base_price * (1 - rate), 2)
# Inspect cache performance
print(compute_discount.cache_info())
# CacheInfo(hits=142, misses=14, maxsize=256, currsize=14)@dataclass valt in een andere categorie — het is een klassedecoratordie automatisch
__init__, __repr__ en __eq__ genereert uit je veldannotaties. Het snijdt
een significante hoeveelheid boilerplate weg voor data-houdende klassen:
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
@dataclass
class WebhookEvent:
event_type: str
source_id: int
payload: dict
received_at: datetime = field(default_factory=datetime.utcnow)
retry_count: int = 0
error_message: Optional[str] = None
# @dataclass generates all of this for free:
# - __init__(self, event_type, source_id, payload, received_at=..., retry_count=0, error_message=None)
# - __repr__ that shows all fields
# - __eq__ that compares field-by-field
event = WebhookEvent(event_type="order.created", source_id=9912, payload={"order_id": 44501})
print(event)
# WebhookEvent(event_type='order.created', source_id=9912, payload={...}, retry_count=0, ...)De @property-decorator verandert een methode in een attribuutstijl accessor — aanroepers
lezen user.display_name in plaats van user.get_display_name(). Combineer het met
@property.setter om bij schrijven te valideren:
class UserProfile:
def __init__(self, first_name: str, last_name: str, email: str):
self._first_name = first_name
self._last_name = last_name
self._email = email.strip().lower()
@property
def display_name(self) -> str:
return f"{self._first_name} {self._last_name}"
@property
def email(self) -> str:
return self._email
@email.setter
def email(self, value: str) -> None:
if "@" not in value:
raise ValueError(f"Invalid email address: {value!r}")
self._email = value.strip().lower()
profile = UserProfile("Alice", "Smith", " [email protected] ")
print(profile.display_name) # 'Alice Smith'
print(profile.email) # '[email protected]'
profile.email = "[email protected]" # setter runs validation
profile.email = "not-an-email" # raises ValueErrorEen @require_auth Decorator voor Route Handlers
Authenticatiecontroles in webframeworks zijn een schoolboekgebruiksgeval voor decorators. In plaats van de "is de gebruiker ingelogd?" controle bovenaan elke route handler te dupliceren, schrijf je het eenmalig als decorator en pas je het toe waar nodig. Hier is het patroon, geschreven om met Flask te werken maar overdraagbaar naar elk framework:
import functools
from flask import request, jsonify, g
def require_auth(fn):
"""
Decorator that validates a Bearer token before the route handler runs.
Sets g.current_user on success; returns 401 JSON on failure.
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Missing or malformed Authorization header"}), 401
token = auth_header[len("Bearer "):]
user = verify_token(token) # your token validation logic
if user is None:
return jsonify({"error": "Invalid or expired token"}), 401
g.current_user = user # available to the route handler
return fn(*args, **kwargs)
return wrapper
# Usage — the auth check runs before the handler body
@app.route("/api/v1/reports/<int:report_id>")
@require_auth
def get_report(report_id: int):
report = Report.query.get_or_404(report_id)
if report.owner_id != g.current_user.id:
return jsonify({"error": "Forbidden"}), 403
return jsonify(report.to_dict())Let op de volgorde: @app.route gaat eerst (buitenste), @require_auth gaat
tweede. Dit is belangrijk — zie de volgende sectie. Het patroon breidt natuurlijk uit: je kunt een
@require_role("admin") decorator factory toevoegen die g.current_user.role controleert nadat
@require_auth al heeft geverifieerd dat de gebruiker bestaat.
Decorators Stapelen — Volgorde Doet Ertoe
Wanneer je meerdere decorators stapelt, passen ze van onder naar boven toe (de decorator het dichtst bij de functie past als eerste toe), maar ze voeren uit van boven naar beneden wanneer de functie wordt aangeroepen. Dit verrast mensen.
@decorator_a
@decorator_b
@decorator_c
def my_function():
pass
# This is exactly equivalent to:
my_function = decorator_a(decorator_b(decorator_c(my_function)))
# When my_function() is called:
# 1. decorator_a's wrapper runs first (outermost)
# 2. decorator_b's wrapper runs second
# 3. decorator_c's wrapper runs third (innermost, closest to the real function)
# 4. The real my_function body runs
# 5. Unwinding: decorator_c → decorator_b → decorator_a# Practical example: order matters for @timer and @retry
# If timer is outermost, it measures total time including retry wait periods.
# If retry is outermost, it measures only the final successful call.
@timer # measures: total time across all retry attempts + sleep
@retry(max_attempts=3, delay=1.0)
def sync_with_partner_api(partner_id: int) -> dict:
...
# vs.
@retry(max_attempts=3, delay=1.0) # measures: only the final successful call
@timer
def sync_with_partner_api(partner_id: int) -> dict:
...
# Usually you want @timer outermost — it tells you the real wall-clock cost of the operation.
# Think about what "calling this function" means to the caller, then wrap in that order.@functools.wraps correct hebt gebruikt, zullen de namen
de originele functies weerspiegelen. Als je een zee van wrapper-frames ziet, heeft iemand
@functools.wraps vergeten. De
Real Python decorator primer
heeft een goede walkthrough van hoe gestapelde decorators te debuggen.Samenvatting
Het mentale model om mee te nemen: @decorator is fn = decorator(fn).
Alles anders — decorators met argumenten, klassegebaseerde decorators, gestapelde decorators — is een variatie op
die ene vervanging. Gebruik @functools.wraps op elke binnenste wrapper, stuur altijd
*args, **kwargs door naar de omhulde functie, en retourneer het resultaat. Decorators met argumenten hebben
drie nestingsniveaus nodig: de argumentfunctie, de decorator en de wrapper. Klassegebaseerde decorators zijn de
juiste keuze wanneer je decorator staat moet bijhouden tussen aanroepen.
Voor verdere lectuur: de
Python-glossarium entry over decorators
is kort maar precies. Het originele decoratorvoorstel,
PEP 318, is het lezen waard
voor context waarom de @-syntaxis is gekozen boven alternatieven. Als je decorators gebruikt
in een codebase die ook veel dataverwerking doet — bestanden lezen, records transformeren — passen de patronen
hier natuurlijk samen met wat behandeld wordt in
Python File Handling. En als je decorators gebruikt om
collecties te transformeren of opzoekstructuren te bouwen,
Python List Comprehensions behandelt de data
transformatiekant van dat beeld. Als je gedecoreerde functies JSON-uitvoer teruggeven en je die snel wilt inspecteren,
is de JSON Formatter op deze site handig daarvoor.