Decoradores en Python explicados — De cero a usarlos como un pro

Si llevas un tiempo programando Python, tarde o temprano te encuentras con una línea tipo @algo justo encima de una función. Y si nunca te lo ha explicado nadie en condiciones, la primera reacción es: “¿esto qué hace exactamente?”.
Los decoradores tienen fama de tema avanzado, pero en realidad son una idea muy simple disfrazada de sintaxis rara. Una vez los entiendes a fondo, los ves por todos lados: en Flask (@app.route), en pytest (@pytest.fixture), en clases (@property, @staticmethod), en logging, caching, autenticación…
Esta entrada te lleva de cero a entenderlos a fondo. No “saber usarlos” — entender qué pasa por debajo, que es lo único que te permite escribir los tuyos sin copiar de Stack Overflow.
Contenido
- 1 La idea en 30 segundos
- 2 Pre-requisito: las funciones son objetos
- 3 Tu primer decorador (sin azúcar)
- 4 Y ahora con la sintaxis @
- 5 Por qué *args y **kwargs dentro del wrapper
- 6 El problema del __name__ (y functools.wraps)
- 7 Decoradores con argumentos
- 8 Decoradores apilados
- 9 Casos reales donde verás (y querrás) decoradores
- 10 Decoradores en clases (como decorador)
- 11 Decoradores escritos como clase
- 12 Errores típicos
- 13 Resumen
- 14 Tu siguiente paso
La idea en 30 segundos
Un decorador es una función que toma otra función y devuelve una función (normalmente una versión modificada de la original). El símbolo @ es solo azúcar sintáctico para llamarlo.
Esto…
@mi_decorador
def saludar():
print("Hola")
…es exactamente equivalente a esto:
def saludar():
print("Hola")
saludar = mi_decorador(saludar)
Y ya está. Eso es todo. El resto de la entrada es desarrollar este concepto hasta dominarlo.
Pre-requisito: las funciones son objetos
Para entender decoradores tienes que tener clarísimo que en Python las funciones son objetos de primera clase. Significa tres cosas:
def saludar(nombre):
return f"Hola, {nombre}"
# 1. Las funciones se asignan a variables
mi_funcion = saludar
print(mi_funcion("Ana")) # "Hola, Ana"
# 2. Las funciones se pasan como argumento a otras funciones
def aplicar(funcion, valor):
return funcion(valor)
print(aplicar(saludar, "Pedro")) # "Hola, Pedro"
# 3. Las funciones se devuelven desde otras funciones
def crear_saludador():
def hablar(nombre):
return f"Buenas, {nombre}"
return hablar
mi_saludador = crear_saludador()
print(mi_saludador("Luis")) # "Buenas, Luis"
Si esto te suena raro, tómate 5 minutos. Sin esto, los decoradores no encajan.
Tu primer decorador (sin azúcar)
Vamos a escribir uno sin usar la sintaxis @ para que veas que no hay magia.
Caso: queremos medir cuánto tarda en ejecutarse una función.
import time
def medir_tiempo(funcion):
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = funcion(*args, **kwargs)
fin = time.time()
print(f"{funcion.__name__} tardó {fin - inicio:.4f}s")
return resultado
return wrapper
def trabajar_duro():
time.sleep(1)
return "Hecho"
# Sin @: aplicamos el decorador a mano
trabajar_duro = medir_tiempo(trabajar_duro)
print(trabajar_duro())
# trabajar_duro tardó 1.0010s
# Hecho
Lo que pasa, paso a paso:
medir_tiempoes una función que recibe otra función (funcion) y devuelve una nueva (wrapper).wrapperejecuta la función original metida entre dostime.time(), imprime cuánto tardó y devuelve su resultado.- Al hacer
trabajar_duro = medir_tiempo(trabajar_duro), reemplazamos la función original por la nueva versión envuelta. - Cuando llamamos a
trabajar_duro(), en realidad estamos llamando alwrapper— pero a efectos prácticos parece que es la misma función, solo que ahora mide su tiempo.
📥 Llévate el cheatsheet de Python (gratis)
PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.
Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.
Y ahora con la sintaxis @
Lo mismo, escrito con la sintaxis bonita:
import time
def medir_tiempo(funcion):
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = funcion(*args, **kwargs)
fin = time.time()
print(f"{funcion.__name__} tardó {fin - inicio:.4f}s")
return resultado
return wrapper
@medir_tiempo
def trabajar_duro():
time.sleep(1)
return "Hecho"
print(trabajar_duro())
@medir_tiempo encima de trabajar_duro es literalmente trabajar_duro = medir_tiempo(trabajar_duro). Mismo código, otra cara.
Por qué *args y **kwargs dentro del wrapper
Porque tu decorador no sabe qué argumentos va a recibir la función decorada. Si escribes def wrapper(): (sin parámetros), tu decorador solo funcionará con funciones sin argumentos.
@medir_tiempo
def sumar(a, b):
return a + b
print(sumar(2, 3)) # ¿funciona?
Sí, funciona — porque wrapper(*args, **kwargs) capta cualquier combinación: posicionales, nombrados, los que sean. Y los reenvía con funcion(*args, **kwargs). Es el patrón estándar para decoradores genéricos.
El problema del __name__ (y functools.wraps)
Hay un efecto secundario molesto al decorar:
@medir_tiempo
def trabajar_duro():
"""Hace cosas pesadas."""
time.sleep(1)
print(trabajar_duro.__name__) # "wrapper" ← ¡no es "trabajar_duro"!
print(trabajar_duro.__doc__) # None ← se perdió el docstring
El motivo: la función ahora apunta al wrapper, no a la original. Para herramientas como help(), debugging, o frameworks que inspeccionan funciones, esto es un dolor.
Solución: functools.wraps. Es un decorador (sí, decorador de decorador) que copia el nombre, docstring y metadata de la función original al wrapper:
from functools import wraps
import time
def medir_tiempo(funcion):
@wraps(funcion) # ← esta línea
def wrapper(*args, **kwargs):
inicio = time.time()
resultado = funcion(*args, **kwargs)
print(f"{funcion.__name__} tardó {time.time() - inicio:.4f}s")
return resultado
return wrapper
@medir_tiempo
def trabajar_duro():
"""Hace cosas pesadas."""
time.sleep(1)
print(trabajar_duro.__name__) # "trabajar_duro" ✓
print(trabajar_duro.__doc__) # "Hace cosas pesadas." ✓
💡 Regla: siempre usa
@wraps(funcion)en tus decoradores. No tiene downside y te ahorra dolores de cabeza futuros.
Decoradores con argumentos
Hasta ahora @medir_tiempo sin paréntesis. Pero verás cosas como @app.route("/users") o @retry(intentos=3). Eso es un decorador con argumentos, y necesita una capa más.
La idea: si quieres pasar parámetros a tu decorador, tienes que escribir una función que devuelve un decorador. Es decir: una función que devuelve una función que devuelve una función. Sí, suena absurdo.
Ejemplo: decorador que reintenta la función N veces si falla:
from functools import wraps
import time
def reintentar(intentos=3, espera=1):
def decorador(funcion):
@wraps(funcion)
def wrapper(*args, **kwargs):
for intento in range(1, intentos + 1):
try:
return funcion(*args, **kwargs)
except Exception as e:
print(f"Intento {intento} falló: {e}")
if intento == intentos:
raise
time.sleep(espera)
return wrapper
return decorador
@reintentar(intentos=3, espera=2)
def conectar_api():
# imagina aquí una llamada que falla a veces
...
Tres niveles:
reintentar(intentos=3, espera=2)→ recibe los argumentos del decorador y devuelve…decorador(funcion)→ un decorador que recibe la función a decorar y devuelve…wrapper(*args, **kwargs)→ la función envuelta que se llama de verdad.
Cuando escribes @reintentar(intentos=3), Python ejecuta reintentar(intentos=3) (que devuelve el decorador) y luego aplica ese decorador a la función. Sintaxis confusa, idea simple.
Decoradores apilados
Puedes poner varios @ encima de una función. Se aplican de abajo hacia arriba:
@medir_tiempo
@reintentar(intentos=3)
def conectar():
...
Equivale a conectar = medir_tiempo(reintentar(intentos=3)(conectar)).
Es decir: primero envuelves con reintentar, después envuelves el resultado con medir_tiempo. Orden importa: en este ejemplo, medir_tiempo mide el tiempo total incluyendo los reintentos. Si pusieras @reintentar arriba, mediría cada intento por separado.
Casos reales donde verás (y querrás) decoradores
1. Logging automático
from functools import wraps
import logging
logger = logging.getLogger(__name__)
def log_llamada(funcion):
@wraps(funcion)
def wrapper(*args, **kwargs):
logger.info(f"Llamando a {funcion.__name__} con args={args}, kwargs={kwargs}")
return funcion(*args, **kwargs)
return wrapper
@log_llamada
def crear_usuario(nombre, email):
...
2. Caché simple (memoization)
Para no recomputar lo mismo dos veces:
from functools import wraps
def cachear(funcion):
cache = {}
@wraps(funcion)
def wrapper(*args):
if args not in cache:
cache[args] = funcion(*args)
return cache[args]
return wrapper
@cachear
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # Instantáneo. Sin cache, tarda minutos.
⚡ En la práctica usa
functools.lru_cache— el decorador de la librería estándar hace esto y mejor. Pero entender el código de arriba te enseña qué hace por dentro.
3. Validación / autenticación (estilo Flask)
from functools import wraps
def requiere_admin(funcion):
@wraps(funcion)
def wrapper(usuario, *args, **kwargs):
if not usuario.es_admin:
raise PermissionError("Solo admins pueden hacer esto")
return funcion(usuario, *args, **kwargs)
return wrapper
@requiere_admin
def borrar_usuario(usuario, id_a_borrar):
...
4. Decoradores de librerías típicas
@app.route("/users/<id>") # Flask: registra la URL
@pytest.fixture # Pytest: marca un fixture
@dataclass # Standard: convierte clase en dataclass
@property # Standard: convierte método en propiedad
@staticmethod # Standard: método estático
@cached_property # Standard: propiedad cacheada
Todos siguen exactamente las reglas que has visto.
Decoradores en clases (como decorador)
También puedes decorar métodos de clases. Funciona igual; solo recuerda que el primer argumento del wrapper será self:
from functools import wraps
def log_metodo(funcion):
@wraps(funcion)
def wrapper(self, *args, **kwargs):
print(f"Llamando {self.__class__.__name__}.{funcion.__name__}")
return funcion(self, *args, **kwargs)
return wrapper
class Usuario:
def __init__(self, nombre):
self.nombre = nombre
@log_metodo
def saludar(self):
return f"Hola, soy {self.nombre}"
u = Usuario("Ana")
u.saludar()
# Llamando Usuario.saludar
Decoradores escritos como clase
Avanzado, pero útil saber que existe. Una clase con __call__ también puede actuar como decorador:
class ContadorLlamadas:
def __init__(self, funcion):
self.funcion = funcion
self.llamadas = 0
def __call__(self, *args, **kwargs):
self.llamadas += 1
print(f"{self.funcion.__name__} llamada #{self.llamadas}")
return self.funcion(*args, **kwargs)
@ContadorLlamadas
def saludar():
print("Hola")
saludar() # saludar llamada #1
saludar() # saludar llamada #2
print(saludar.llamadas) # 2
Útil cuando el decorador necesita mantener estado complejo. Para casos simples, una función con closure (la versión clásica) es más legible.
Errores típicos
# 1. Olvidar return en el wrapper
def malo(funcion):
@wraps(funcion)
def wrapper(*args, **kwargs):
funcion(*args, **kwargs) # ← FALTA return
return wrapper
# Ahora todas las funciones decoradas devuelven None aunque retornaran algo.
# 2. Olvidar que reintentar(3) y reintentar son cosas distintas
@reintentar # ← MAL: pasa la función a reintentar como si fuera "intentos"
def algo(): ...
@reintentar() # ← BIEN: ejecuta reintentar() para obtener el decorador
def algo(): ...
# 3. Aplicar dos decoradores y confundirse con el orden
@a
@b
def f(): ...
# Equivale a a(b(f)), no b(a(f)). Lee de abajo hacia arriba.
Resumen
| Concepto | Regla |
|---|---|
| Decorador básico | Función que toma una función y devuelve otra |
Sintaxis @ | Azúcar para f = decorador(f) |
| Pasar args genéricos | def wrapper(*args, **kwargs): ... |
| Conservar metadata | Siempre @functools.wraps(funcion) |
| Decorador con args | Tres niveles: def deco(arg): def real(f): def wrapper: ... |
| Apilados | Se aplican de abajo hacia arriba |
| Clase como decorador | Implementa __call__ |
¿Te ha valido esto?
Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.
Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.
Tu siguiente paso
Si has llegado hasta aquí, los decoradores ya no son magia para ti. Ahora viene la parte importante: escribir los tuyos en código real. La próxima vez que veas que estás repitiendo lógica al inicio o al final de varias funciones (logging, validación, caché, medición de tiempo, manejo de errores) — ahí tienes un decorador esperando.
Si quieres aprender Python desde la base, con funciones, clases, módulos y todo lo necesario para construir proyectos reales en los que estos patrones tienen sentido, en El Pythonista lo enseño paso a paso.
Un abrazo,
Oscar
¿Quieres aprender Python en orden, no a saltos?
Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 37+ horas de vídeo, 734 actividades y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.
37+ horas · 734 actividades · Proyecto real · Acceso de por vida · 14 días de garantía
