Logging en Python — Deja de usar `print` para depurar

Si llevas tiempo programando, probablemente sigues llenando el código de print(...) para depurar. Y luego los borras antes del commit. Y luego los vuelves a poner cuando hay un bug. Y vuelta a empezar.
Esto funciona para juguetes, pero a la mínima que tu código entra en algo más serio (script en cron, API en producción, web en Flask, automatización que corre cada noche), print se queda corto. No tiene niveles, no tiene timestamps, no se redirige fácil, no se silencia, no se filtra, y no separa “info útil” de “ruido”.
La buena noticia: Python trae logging en la stdlib, viene de fábrica con prácticamente todo lo que necesitas, y configurarlo bien para un proyecto te lleva 5 minutos. En esta entrada te enseño los conceptos clave, configuraciones realistas, los errores típicos al empezar y por qué nunca volverás al print para depurar.
Contenido
Por qué print no escala
Tres motivos prácticos:
- No tiene niveles. Todo se imprime. Si el script imprime 200 cosas útiles cuando va bien, no ves los errores entre el ruido.
- No se redirige limpiamente. Si quieres logs en fichero + en consola simultáneamente, te toca duplicar todo y pegar
with open(..., "a") as f:en cada print. - Se queda en el código. Olvidas un
print("aquí 1")y va a producción. Felicidades.
El módulo logging resuelve los tres. Tienes 5 niveles (DEBUG, INFO, WARNING, ERROR, CRITICAL), puedes configurar dónde van los logs (consola, fichero, syslog, email, Sentry…), filtras por nivel, formateas con timestamps y nombre de módulo, y silencias todo de una sola vez subiendo el nivel global.
Setup mínimo que ya marca diferencia
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
logger.debug("Esto no se ve (nivel está en INFO)")
logger.info("Esto sí")
logger.warning("Algo raro pasó")
logger.error("Esto sí preocupa")
logger.critical("Esto está ardiendo")
Output:
2026-04-19 15:30:00,123 [INFO] __main__: Esto sí
2026-04-19 15:30:00,124 [WARNING] __main__: Algo raro pasó
2026-04-19 15:30:00,125 [ERROR] __main__: Esto sí preocupa
2026-04-19 15:30:00,126 [CRITICAL] __main__: Esto está ardiendo
Tres minutos de configuración y ya tienes timestamp, nivel, nombre de módulo y mensaje. Cualquier print queda en evidencia.
💡 Patrón pro: un
loggerpor módulo, congetLogger(__name__). Así cada log lleva el nombre del fichero que lo emitió y puedes filtrar/silenciar por módulo después.
Los 5 niveles de logging y cuándo usarlos
| Nivel | Cuándo |
|---|---|
DEBUG | Detalles internos para diagnóstico. Solo en desarrollo. |
INFO | Eventos normales que confirman que la app va bien (arranque, request servida). |
WARNING | Algo extraño pero no rompe (uso de API deprecada, fallback). |
ERROR | Falló algo y la operación no se completó, pero la app sigue. |
CRITICAL | Fallo grave: la app no puede continuar. |
El nivel del logger filtra: si pones level=INFO, los DEBUG no salen. Si pones level=WARNING, solo ves warnings y arriba.
⚡ Tip-friki: en producción típicamente
INFOoWARNING. En desarrollo,DEBUG. Lo configuras con una variable de entorno y arreglado.
📥 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.
Logger por módulo (el patrón pro)
En vez de un logging.info(...) global, cada fichero tiene su propio logger nombrado:
# en facturacion.py
import logging
logger = logging.getLogger(__name__)
def cobrar(usuario, monto):
logger.info("Cobrando %s€ a usuario %s", monto, usuario.id)
...
logger.warning("Tarjeta caducada en 30 días para %s", usuario.id)
# en main.py
import logging
logging.basicConfig(level=logging.INFO)
# Silenciar logs de un módulo concreto
logging.getLogger("requests").setLevel(logging.WARNING)
getLogger(__name__) te da un logger cuyo nombre es el del módulo (mi_paquete.facturacion). Ventajas:
- Cada log identifica de dónde viene.
- Puedes silenciar/elevar nivel por módulo: típicamente bajas tu propio código a DEBUG y subes a WARNING los de librerías ruidosas como
requests,urllib3,boto3.
💡 Notación lazy:
logger.info("X = %s", x)es mejor quelogger.info(f"X = {x}")cuando hay logs de DEBUG en hot paths. La librería solo construye el string si el nivel está activo. Con f-strings, siempre se construye.
Formato más completo
format admite muchos atributos. Los más útiles:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
Atributos:
| Placeholder | Qué muestra |
|---|---|
%(asctime)s | Timestamp legible |
%(levelname)s | Nivel: INFO, WARNING… |
%(name)s | Nombre del logger (__name__) |
%(funcName)s | Función que emitió el log |
%(lineno)d | Línea del fichero |
%(message)s | El mensaje en sí |
%(pathname)s | Ruta completa del fichero |
%(levelname)-8s con -8 te alinea el nivel a 8 caracteres (queda más legible cuando alternas INFO/WARNING/ERROR).
Loggear a fichero
basicConfig admite filename:
logging.basicConfig(
filename="app.log",
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
Para tenerlo a la vez en fichero y en consola, ya necesitas handlers:
import logging
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# Handler 1: consola con INFO+
consola = logging.StreamHandler()
consola.setLevel(logging.INFO)
consola.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
# Handler 2: fichero con DEBUG+
archivo = logging.FileHandler("app.log")
archivo.setLevel(logging.DEBUG)
archivo.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
logger.addHandler(consola)
logger.addHandler(archivo)
Resultado: en consola ves solo INFO/WARNING/ERROR (limpio). En fichero, todo desde DEBUG (forensic).
Casos reales típicos
Logear excepciones con su traceback
try:
procesar(datos)
except Exception:
logger.exception("Fallo al procesar")
logger.exception() se usa dentro de except. Loggea como ERROR e incluye el traceback completo. Es el patrón estándar cuando capturas para no romper la app pero quieres dejar constancia.
Rotación de ficheros para que no crezca infinito
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log", maxBytes=5_000_000, backupCount=3
)
Cuando app.log llega a 5 MB, lo renombra a app.log.1 (y el viejo .1 pasa a .2, etc.) y empieza un fichero nuevo. Mantiene 3 backups. Sin esto, en producción, tu fichero de logs te llena el disco.
💡 ¿Vas a deployar a un VPS? Mira scripts y
if __name__para entry points limpios yscripts-python-automatizarpara automatización con cron.
Configurar desde fichero/dict (proyecto serio)
Para proyectos medianos en adelante, en vez de configurar en el main.py, usas un dict de configuración:
import logging.config
CONFIG = {
"version": 1,
"formatters": {
"default": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
},
},
"handlers": {
"consola": {"class": "logging.StreamHandler", "formatter": "default"},
"archivo": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "app.log",
"maxBytes": 5_000_000,
"backupCount": 3,
"formatter": "default",
},
},
"loggers": {
"": {"handlers": ["consola", "archivo"], "level": "INFO"},
"requests": {"level": "WARNING"},
},
}
logging.config.dictConfig(CONFIG)
Esto es lo más estándar en proyectos profesionales. El dict puede venir de un YAML/JSON externo si quieres.
Logs estructurados para analizar después
Si tu app va a producción y quieres analizar los logs (con Datadog, Loki, ELK…), pásalos a JSON. Hay librerías (python-json-logger) o lo haces a mano:
import json
import logging
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": self.formatTime(record),
"level": record.levelname,
"module": record.name,
"msg": record.getMessage(),
})
Cada línea de log es un JSON parseable. Mucho más útil que un string libre.
Errores típicos al usar logging
# 1. Usar `print` en producción
print("Error: usuario no encontrado") # ❌
logger.error("Usuario no encontrado: id=%s", id) # ✓
# 2. f-strings en logs ruidosos
logger.debug(f"Estado: {calcular_estado_caro()}")
# ❌ se ejecuta calcular_estado_caro SIEMPRE, aunque DEBUG esté off
logger.debug("Estado: %s", calcular_estado_caro())
# ⚠️ igual, se ejecuta antes de pasarlo. Para casos caros:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Estado: %s", calcular_estado_caro()) # ✓ se evalúa solo si toca
# 3. Loggear desde un script lanzado solo con prints "porque ya tengo logs"
print("INFO: arrancando") # ❌ está bien sólo si tu script realmente no necesita logs
# Si vas a tener bugs en producción → usa logging desde el inicio.
# 4. Capturar excepción y loggear sin traceback
try:
procesar()
except Exception as e:
logger.error(f"Error: {e}") # ❌ pierdes el traceback
# Mejor:
logger.exception("Error procesando") # ✓ incluye traceback
# 5. logger.basicConfig() después de loggear ya
logger = logging.getLogger(__name__)
logger.info("antes") # crea config por defecto
logging.basicConfig(...) # ❌ ya no surte efecto en muchos casos
# Configura ANTES de loggear nada.
# 6. Pegar logger.info a un nivel muy alto sin saberlo
logging.basicConfig(level=logging.WARNING)
logger.info("nada se ve") # ❌ filtrado por nivel
Resumen
| Concepto | Regla |
|---|---|
| Logger por módulo | logger = logging.getLogger(__name__) arriba de cada fichero |
| Niveles | DEBUG / INFO / WARNING / ERROR / CRITICAL — filtra por nivel global |
| Setup mínimo | logging.basicConfig(level=..., format=...) |
| Mensaje con args | logger.info("X=%s", x) (lazy, evita f-string en hot paths) |
| Excepciones | logger.exception("...") dentro del except (incluye traceback) |
| Consola + fichero | Handlers separados con sus niveles y formatos |
| Rotación | RotatingFileHandler para que el fichero no crezca infinito |
| Producción | dictConfig(...) con un dict por entorno |
| Lo más importante | Configura una vez al inicio y usa loggers nombrados en todos lados |
¿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í, ya no vas a volver al print. La próxima vez que arranques un script que vaya a vivir más de un día, copias el setup mínimo, defines tu logger, y a otra cosa. Y cuando llegue la hora de meter rotación, fichero, JSON estructurado o Sentry — tienes la base para crecer sin reescribir.
Si quieres aprender Python desde la base hasta proyectos reales con producción, despliegue, observabilidad y automatización, 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
