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.

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

python
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 to

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

De desugaring-regel: Wanneer je @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 wrapper in 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_cache en vergelijkbare tools gebruiken de identiteit van de functie voor cache-sleutels — zonder wraps kun je verrassende cache-botsingen krijgen.
python
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.'  ← preserved

functools.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:

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

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

python
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:

python
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:

python
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 ValueError

Een @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:

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

python
@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
python
# 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.
Traceback-tip: Wanneer een gedecoreerde functie een uitzondering gooit, toont de traceback elke wrapper in de aanroepstapel. Als je @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.