El sistema de logging de Python separa tres responsabilidades que a menudo se confunden: quién emite el mensaje (el logger), cómo se formatea (el formatter), y adónde va (el handler). Entender ese triángulo es lo que separa a quien tiene print() por todas partes de quien tiene observabilidad real en producción.
Un handler es el destino de un registro de log. Un mismo logger puede tener varios handlers activos simultáneamente, y cada uno puede tener su propio nivel mínimo y su propio formatter. El logger evalúa si el mensaje pasa su propio nivel, y luego cada handler hace su propia evaluación independiente. Esto significa que puedes tener DEBUG en consola durante desarrollo y WARNING en archivo en producción sin cambiar una línea del código de aplicación.
Los formatters controlan exactamente qué texto aparece. El string de formato usa el estilo %-style con atributos del LogRecord: %(asctime)s, %(levelname)s, %(name)s, %(message)s son los más comunes, pero hay decenas más: %(filename)s, %(funcName)s, %(lineno)d. El formatter también acepta datefmt para controlar cómo se serializa el timestamp.
El punto delicado es cuándo configurar todo esto. La respuesta es: solo las aplicaciones configuran handlers. Si escribes una librería y llamas a basicConfig() o añades handlers directamente, estás secuestrando la configuración del usuario. Las librerías solo deben obtener loggers con logging.getLogger(__name__) y emitir mensajes; nada más. El NullHandler existe precisamente para eso: añadirlo en tu librería evita el warning “No handlers could be found” sin interferir con quien te consuma.
logging.config.dictConfig() es la forma moderna de configurar todo el sistema desde un único diccionario, que puede venir de un archivo YAML, de variables de entorno procesadas, o estar hardcodeado para tests. Es declarativo, versionable, y te permite ver de un vistazo toda la topología de logging sin rastrear llamadas dispersas por el código.
import logging
import logging.config
import logging.handlers
from pathlib import Path
LOGS_DIR = Path("logs")
LOGS_DIR.mkdir(exist_ok=True)
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False, # no silencies loggers creados antes de dictConfig
"formatters": {
"detailed": {
"format": "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S",
},
"simple": {
"format": "%(levelname)-8s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "simple",
"stream": "ext://sys.stderr",
},
"rotating_file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "INFO",
"formatter": "detailed",
"filename": str(LOGS_DIR / "app.log"),
"maxBytes": 5_242_880, # 5 MB por fichero
"backupCount": 3, # conserva app.log, app.log.1, app.log.2, app.log.3
"encoding": "utf-8",
},
"timed_file": {
"class": "logging.handlers.TimedRotatingFileHandler",
"level": "WARNING",
"formatter": "detailed",
"filename": str(LOGS_DIR / "warnings.log"),
"when": "midnight",
"backupCount": 7, # una semana de histórico
"encoding": "utf-8",
},
},
"loggers": {
"myapp": {
"level": "DEBUG",
"handlers": ["console", "rotating_file", "timed_file"],
"propagate": False, # no reenvíes al root para evitar duplicados
},
"myapp.db": {
"level": "WARNING", # el módulo de DB es verboso; acallamos DEBUG/INFO
"propagate": True, # sube a myapp, que ya tiene los handlers
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}
logging.config.dictConfig(LOGGING_CONFIG)
# --- simulación de código de aplicación ---
log = logging.getLogger("myapp")
db_log = logging.getLogger("myapp.db")
log.debug("Arrancando proceso de importación")
log.info("Conectado a la fuente de datos: %s", "postgresql://prod-db/sales")
log.warning("El campo 'region' está vacío en %d registros", 42)
db_log.debug("SELECT * FROM orders WHERE ...") # silenciado: nivel WARNING en myapp.db
db_log.warning("Consulta lenta: %.2f s", 3.7) # sube a myapp → rotating_file + timed_file + console
Lo que está pasando aquí
La clave del diccionario "version": 1 es obligatoria; es el único valor válido hoy, pero el campo existe para permitir esquemas futuros sin romper compatibilidad. "disable_existing_loggers": False merece atención especial: el valor por defecto es True, lo que silencia cualquier logger creado antes de llamar a dictConfig. En aplicaciones reales, donde los módulos importados crean sus loggers al importarse, eso es un bug silencioso difícil de diagnosticar.
Los formatters están separados de los handlers por diseño. El formatter "detailed" incluye %(lineno)d y el nombre del logger, información valiosa en archivo pero ruidosa en consola, donde "simple" es suficiente. El %-8s en %(levelname)-8s alinea a la izquierda en 8 caracteres, así DEBUG e INFO no hacen que el mensaje salte de columna cuando lees el log.
RotatingFileHandler rota el fichero cuando alcanza maxBytes. Si backupCount=3, mantiene app.log más tres backups numerados, borrando el más antiguo automáticamente. TimedRotatingFileHandler con when="midnight" rota al arrancar el primer registro después de medianoche; el sufijo del fichero rotado sigue el patrón YYYY-MM-DD. Combinar ambos en el mismo sistema es perfectamente válido: uno controla el tamaño, el otro el tiempo.
La jerarquía myapp → myapp.db refleja el árbol de paquetes. myapp.db tiene propagate: True, así que sus mensajes que pasen el nivel WARNING suben a myapp, que los despacha a sus tres handlers. No hay duplicación porque myapp.db no tiene handlers propios. Si en algún momento necesitas cambiar el nivel de verbosidad del módulo de base de datos en producción sin redesplegar, basta con actualizar el diccionario desde configuración externa y llamar a dictConfig de nuevo.
Errores que debes conocer
Error: Poner "disable_existing_loggers": True (o no ponerlo, porque es el valor por defecto) silencia loggers de terceros y tuyos propios creados antes de la llamada a dictConfig, causando que mensajes desaparezcan sin traza.
# ❌ Wrong
import logging
log = logging.getLogger("myapp.tasks") # creado antes de configurar
LOGGING_CONFIG = {
"version": 1,
# disable_existing_loggers no declarado → True por defecto
"loggers": {"myapp": {"level": "DEBUG", "handlers": ["console"]}},
...
}
logging.config.dictConfig(LOGGING_CONFIG)
log.info("esto no llega a ningún lado") # log fue deshabilitado
# ✅ Right
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False, # explícito y correcto
...
}
logging.config.dictConfig(LOGGING_CONFIG)
Declarar explícitamente False preserva el estado de loggers ya instanciados y los reconecta a los handlers recién configurados.
Error: Añadir handlers directamente en una librería en lugar de dejar eso a la aplicación consumidora.
# ❌ Wrong — dentro de un paquete publicable import logging logger = logging.getLogger(__name__) logger.addHandler(logging.StreamHandler()) # el usuario de tu librería no pidió esto logger.setLevel(logging.DEBUG) # ✅ Right import logging logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) # silencia el "no handlers found" sin interferir
NullHandler descarta los mensajes si nadie los recoge, pero en cuanto la aplicación configura sus propios handlers, la propagación los entrega correctamente; tu librería no necesita saber nada de la topología de destino.
Error: Usar interpolación con f-strings en lugar del estilo % del logger, lo que evalúa la interpolación siempre, aunque el mensaje sea filtrado por el nivel.
# ❌ Wrong
logger.debug(f"Objeto serializado: {json.dumps(huge_object)}") # se ejecuta aunque DEBUG esté desactivado
# ✅ Right
logger.debug("Objeto serializado: %s", huge_object) # logging llama a str() solo si el mensaje pasa el nivel
Con el estilo %, logging retrasa la conversión a string hasta saber que el mensaje va a emitirse, lo que puede ser una diferencia de rendimiento significativa en paths críticos con niveles DEBUG desactivados en producción.
N° 161