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.

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'

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.

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

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

La regla de desazucarado: Cada vez que veas @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 wrapper en 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_cache y herramientas similares usan la identidad de la función para claves de caché — sin wraps, puedes obtener colisiones de caché sorprendentes.
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 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:

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()

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.

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

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

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, ...)

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:

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

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

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())

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.

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.
Consejo para stack traces: Cuando una función decorada lanza una excepción, el traceback mostrará cada wrapper en la pila de llamadas. Si usaste @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.