Si has usado Flask o FastAPI durante más de una semana, ya has usado decoradores — @app.route,
@login_required, @pytest.mark.parametrize. Parecen magia la primera vez, y luego
alguien explica lo que realmente está pasando y hace clic de inmediato. Un decorador es simplemente una función que
envuelve otra función. La sintaxis @ es puro azúcar sintáctico — @my_decorator encima de una
definición de función es exactamente equivalente a escribir func = my_decorator(func) después. Ese es el
único secreto. El resto son simplemente patrones construidos sobre esa única idea. Este artículo construye el modelo mental desde
cero, luego recorre los patrones que realmente usarás: @functools.wraps, decoradores con
argumentos, decoradores basados en clases, y un puñado de ejemplos del mundo real incluyendo @lru_cache,
@dataclass, y un @retry apropiado con backoff exponencial.
Las funciones son objetos de primera clase
Antes de que los decoradores tengan sentido, necesitas tener claro un hecho de Python: las funciones son objetos. Puedes asignarlas a variables, pasarlas como argumentos a otras funciones, retornarlas desde funciones, y almacenarlas en listas o dicts. Nada especial ocurre solo porque algo sea una función.
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'La función interna prefixed_greet "cierra sobre" la variable prefix del
ámbito enclosing — incluso después de que make_prefixer ha retornado, la función interna sigue teniendo acceso
a prefix. Esto es un
closure,
y es el mecanismo que hace funcionar los decoradores. La
documentación de Python sobre reglas de ámbito
explica esto en detalle si quieres el panorama completo.
Construyendo un decorador desde cero
Un decorador es una función que toma una función y retorna una función (generalmente modificada). El primer ejemplo clásico es un decorador de temporización — envuelve cualquier función y registra cuánto tardó en ejecutarse.
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 toAlgunas cosas a notar: el wrapper usa *args, **kwargs para poder reenviar cualquier
combinación de argumentos a la función original sin conocer su firma. Captura el valor de retorno
en result y lo retorna, por lo que la función envuelta sigue comportándose de manera idéntica desde la
perspectiva del llamante — solo tiene un efecto secundario de print adicional. Quita el temporizado, y tienes el
esqueleto de casi cualquier decorador que vayas a escribir.
@some_decorator encima de una
definición de función, sustitúyelo mentalmente por fn = some_decorator(fn) escrito inmediatamente
después del bloque def. Los dos son exactamente equivalentes. No hay magia — es una llamada a función.Por qué debes usar @functools.wraps
En el ejemplo anterior, hay una línea @functools.wraps(fn) en el wrapper. Esto
no es opcional. Sin ella, tu función decorada pierde su identidad — sus atributos __name__,
__doc__, y __qualname__ todos se reemplazan por los de la función interna
wrapper. Eso causa problemas sutiles en algunas situaciones reales:
- Los docstrings desaparecen.
help(fetch_user_records)muestra el docstring vacío del wrapper en lugar de"Fetch all records for a given user...". - Los stack traces mienten. Cuando se lanza una excepción dentro de la función envuelta, el traceback muestra
wrapperen lugar del nombre real de la función — difícil de depurar. - La introspección se rompe. Herramientas como pytest, el sistema de enrutamiento de Flask, e
inspect.signature()dependen de__name__y__wrapped__. El enrutador de Flask lanzará una excepción si dos rutas comparten el mismo nombre (wrapper). functools.lru_cachey herramientas similares usan la identidad de la función para claves de caché — sinwraps, puedes obtener colisiones de caché sorprendentes.
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
es en sí mismo un decorador — copia __module__, __name__, __qualname__,
__annotations__, __doc__, y establece __wrapped__ a la función original.
Úsalo en cada función wrapper, sin excepción. No hay razón para no hacerlo.
Decoradores con argumentos
Un decorador simple toma una función y retorna una función. Un decorador con argumentos necesita un nivel
adicional: una función que toma los argumentos y retorna un decorador. Son tres niveles de anidamiento,
y confunde a casi todos la primera vez que lo ven. Aquí hay un decorador @retry que reintenta
una función hasta max_attempts veces ante una excepción:
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()La cadena de llamadas cuando Python procesa @retry(max_attempts=4, delay=0.5, backoff=2.0):
primero se llama retry(max_attempts=4, delay=0.5, backoff=2.0) y retorna decorator.
Luego se llama decorator(fetch_price_data) y retorna wrapper. Finalmente
fetch_price_data se revincula a wrapper. Así que @retry(...) es
fetch_price_data = retry(...)(fetch_price_data) — tres llamadas, dos niveles de envolvimiento.
Una vez que ves ese patrón, las fábricas de decoradores dejan de ser confusas.
Decoradores basados en clases
También puedes implementar un decorador como una clase definiendo __call__. Esto es útil
cuando el decorador necesita mantener estado entre llamadas — un contador de llamadas, una caché, pools de conexiones — porque
las variables de instancia son un lugar más natural para ese estado que las variables de closure.
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) funciona exactamente como una fábrica de decoradores:
RateLimiter(2.0) construye una instancia, luego esa instancia es llamada con
send_sms_alert porque tiene __call__. La instancia almacena
_last_called como atributo — no se necesita malabarismo con variables de closure. Ver
PEP 318 (la propuesta original de
decorador) para la justificación de diseño detrás de la sintaxis @, y
PEP 614 para la gramática de decorador relajada
que llegó en Python 3.9.
Decoradores del mundo real de la biblioteca estándar
Antes de escribir el tuyo, verifica si la stdlib ya tiene lo que necesitas. Tres decoradores en
functools
aparecen constantemente en el código Python de producción.
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 está en una categoría diferente — es un decorador de clase que auto-genera
__init__, __repr__, y __eq__ a partir de tus anotaciones de campo. Elimina
una cantidad significativa de código repetitivo para clases que contienen datos:
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, ...)El decorador @property convierte un método en un accesor de estilo atributo — los llamantes
leen user.display_name en lugar de user.get_display_name(). Combínalo con
@property.setter para validar al escribir:
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 ValueErrorUn decorador @require_auth para manejadores de rutas
Las verificaciones de autenticación en frameworks web son un caso de uso típico de decoradores. En lugar de duplicar la verificación "¿está el usuario conectado?" en la parte superior de cada manejador de ruta, la escribes una vez como decorador y la aplicas donde se necesite. Aquí está el patrón, escrito para funcionar con Flask pero transferible a cualquier 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())Nota el orden: @app.route va primero (el más externo), @require_auth va
segundo. Esto importa — ver la siguiente sección. El patrón se extiende naturalmente: podrías agregar un
decorador @require_role("admin") que verifique g.current_user.role después de
que @require_auth ya haya verificado que el usuario existe.
Apilar decoradores — El orden importa
Cuando apilas múltiples decoradores, se aplican de abajo hacia arriba (el decorador más cercano a la función se aplica primero), pero se ejecutan de arriba hacia abajo cuando se llama la función. Esto confunde a la gente.
@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 correctamente, los nombres
reflejarán las funciones originales. Si ves un mar de frames wrapper, alguien olvidó
@functools.wraps. El
primer sobre decoradores Python de Real Python
tiene un buen recorrido sobre cómo depurar decoradores apilados.Conclusión
El modelo mental a llevar adelante: @decorator es fn = decorator(fn).
Todo lo demás — decoradores con argumentos, decoradores basados en clases, decoradores apilados — es una variación sobre
esa única sustitución. Usa @functools.wraps en cada wrapper interno, siempre reenvía
*args, **kwargs a la función envuelta, y retorna su resultado. Los decoradores con argumentos necesitan
tres niveles de anidamiento: la función de argumentos, el decorador, y el wrapper. Los decoradores basados en clases son
el enfoque correcto cuando tu decorador necesita mantener estado entre llamadas.
Para más lectura: la
entrada del glosario de Python sobre decoradores
es breve pero precisa. La propuesta original de decoradores,
PEP 318, vale la pena leer
para contexto sobre por qué se eligió la sintaxis @ sobre alternativas. Si usas decoradores
en una base de código que también hace mucho procesamiento de datos — leyendo archivos, transformando registros — los patrones
aquí se combinan naturalmente con lo que se cubre en
Manejo de archivos Python. Y si usas decoradores para
transformar colecciones o construir estructuras de búsqueda,
List Comprehensions de Python cubre el lado de transformación de datos de ese panorama. Si tus funciones decoradas devuelven salida JSON y quieres inspeccionarla rápidamente, el Formateador JSON de este sitio es útil para eso.