Saltar al contenido
🐍 Curso de Python
/

Módulo 2 · Lección 18 de 28

Registro con logging

Cómo dejar constancia de lo que hace tu programa, con niveles de detalle que se encienden y se apagan sin tocar el código.

⏱ 20 min de lectura

Todo el mundo depura con print. Metes un print(f"aquí llega con {valor}"), ejecutas, ves qué pasa y lo borras. Funciona mientras el programa cabe en una pantalla.

En cuanto tu herramienta se usa de verdad, los print de depuración fallan por tres motivos a la vez. No se pueden apagar: o los borras uno por uno o los sufres siempre. No dicen cuándo ocurrieron ni de qué parte del programa salieron, así que un fichero con mil líneas de salida no te sirve para reconstruir nada. Y se mezclan con la salida útil, con lo que se cargan las tuberías y las redirecciones que montaste en las lecciones anteriores.

El módulo logging de la biblioteca estándar resuelve los tres.

Los cinco niveles#

La idea central de logging es que cada mensaje lleva una etiqueta de importancia, y tú decides a partir de qué importancia se muestran. Los niveles son cinco y están ordenados:

NivelValorCuándo usarlo
DEBUG10Detalles internos que solo interesan mientras investigas un fallo
INFO20Hitos normales: "he cargado 120 artículos", "conectado al servidor"
WARNING30Algo raro, pero el programa sigue: un dato mal formado que se ignora
ERROR40Una operación ha fallado y no se ha podido completar
CRITICAL50El programa no puede continuar

La duda habitual es entre WARNING y ERROR. La regla que funciona: si el programa va a devolver un código de salida distinto de cero por culpa de eso, es ERROR; si va a terminar bien de todos modos, es WARNING.

Las cinco funciones se llaman igual que los niveles:

log1.py
import logging

logging.debug("Valor intermedio del cálculo: 42")
logging.info("Procesando el fichero clientes.csv")
logging.warning("El fichero tiene 3 filas vacías")
logging.error("No se ha podido abrir pedidos.csv")
logging.critical("Sin espacio en disco, abortando")
Salida en pantalla
WARNING:root:El fichero tiene 3 filas vacías
ERROR:root:No se ha podido abrir pedidos.csv
CRITICAL:root:Sin espacio en disco, abortando

Has escrito cinco mensajes y han salido tres. Sin configurar nada, logging muestra a partir de WARNING y se calla el resto. Ese es exactamente el comportamiento que querías con los print: los mensajes de depuración siguen ahí, en el código, sin molestar.

La configuración rápida#

logging.basicConfig() prepara el registro de una vez. Se llama una sola vez, al principio del programa:

log2.py
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

logging.debug("Valor intermedio del cálculo: 42")
logging.info("Procesando el fichero clientes.csv")
logging.warning("El fichero tiene 3 filas vacías")
logging.error("No se ha podido abrir pedidos.csv")
Salida en pantalla
2026-09-19 14:27:52 [DEBUG] Valor intermedio del cálculo: 42
2026-09-19 14:27:52 [INFO] Procesando el fichero clientes.csv
2026-09-19 14:27:52 [WARNING] El fichero tiene 3 filas vacías
2026-09-19 14:27:52 [ERROR] No se ha podido abrir pedidos.csv

Las fechas que veas serán las de tu ordenador en el momento de ejecutarlo, claro; aquí y en el resto de la lección aparecen las del día en que se escribió.

El formato de los mensajes#

format usa el estilo antiguo de Python, con %(nombre)s, y no f-strings. Estos son los marcadores que de verdad se usan:

MarcadorQué pone
%(asctime)sLa fecha y la hora
%(levelname)sEl nivel: DEBUG, INFO...
%(message)sTu mensaje
%(name)sEl nombre del registrador que lo emitió
%(filename)sEl fichero desde el que se llamó
%(lineno)dLa línea exacta
%(funcName)sLa función desde la que se llamó

Admiten la sintaxis de alineación que ya conoces de las f-strings: %(levelname)-8s rellena el nivel con espacios hasta ocho caracteres, para que las columnas queden cuadradas.

El formato de la fecha#

datefmt usa los códigos de strftime: %Y el año con cuatro cifras, %m el mes, %d el día, %H:%M:%S la hora. Si no lo pones, logging añade los milisegundos con una coma, así: 2026-09-19 14:28:32,722. Para un fichero de registro que vayas a leer tú, el formato corto se agradece; para investigar un problema de rendimiento, los milisegundos son justo lo que necesitas.

A la pantalla y al fichero a la vez#

basicConfig vale para empezar, pero en cuanto quieres dos destinos con reglas distintas hay que bajar un escalón. Las piezas de logging son tres:

  • Un registrador (Logger), que es al que le hablas.
  • Uno o varios manejadores (Handler), que deciden a dónde va cada mensaje.
  • Un formateador (Formatter) por manejador, que decide qué aspecto tiene.

Cada pieza tiene su propio nivel, y el mensaje tiene que pasar los dos filtros: primero el del registrador y después el del manejador.

log3.py
import logging
import sys

registro = logging.getLogger("inventario")
registro.setLevel(logging.DEBUG)

# A la pantalla, solo lo importante y sin fecha, que estorba.
a_pantalla = logging.StreamHandler(sys.stderr)
a_pantalla.setLevel(logging.WARNING)
a_pantalla.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))

# Al fichero, todo y con fecha, para poder investigar después.
a_fichero = logging.FileHandler("inventario.log", encoding="utf-8")
a_fichero.setLevel(logging.DEBUG)
a_fichero.setFormatter(
    logging.Formatter(
        "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
)

registro.addHandler(a_pantalla)
registro.addHandler(a_fichero)

registro.debug("Conectando con la base de datos")
registro.info("Cargados 120 artículos")
registro.warning("El artículo A-45 no tiene precio")
registro.error("No se ha podido guardar el inventario")

En pantalla solo aparecen los dos últimos, cortos y directos:

Salida en pantalla
WARNING: El artículo A-45 no tiene precio
ERROR: No se ha podido guardar el inventario

Y en el fichero han quedado los cuatro, con toda la información:

inventario.log
2026-09-19 14:27:59 DEBUG    inventario:25 Conectando con la base de datos
2026-09-19 14:27:59 INFO     inventario:26 Cargados 120 artículos
2026-09-19 14:27:59 WARNING  inventario:27 El artículo A-45 no tiene precio
2026-09-19 14:27:59 ERROR    inventario:28 No se ha podido guardar el inventario

Esto es lo que quieres en una herramienta real: el usuario ve solo lo que le afecta, y cuando algo falla le pides el fichero de registro y tienes la historia completa.

Conectarlo con la opción -v#

El nivel de detalle no debería estar escrito en el código: debería poder cambiarlo quien usa la herramienta. La convención universal en la terminal es la opción -v, que se puede repetir para pedir más y más detalle.

argparse lo resuelve con action="count", que cuenta cuántas veces aparece la opción:

log_v.py
import argparse
import logging

NIVELES = [logging.WARNING, logging.INFO, logging.DEBUG]


def configurar_registro(cuenta_v: int) -> None:
        """Ajusta el nivel de detalle según cuántas veces se haya puesto -v."""
    nivel = NIVELES[min(cuenta_v, len(NIVELES) - 1)]
    logging.basicConfig(level=nivel, format="%(levelname)s: %(message)s")


def main() -> int:
        analizador = argparse.ArgumentParser(description="Sincroniza el inventario")
    analizador.add_argument(
        "-v", "--verboso",
        action="count",
        default=0,
        help="Más detalle en el registro. Se puede repetir: -v, -vv",
    )
    argumentos = analizador.parse_args()
    configurar_registro(argumentos.verboso)

    logging.debug("Nivel de detalle: %s", argumentos.verboso)
    logging.info("Sincronizando 120 artículos")
    logging.warning("El artículo A-45 no tiene precio")
    return 0


raise SystemExit(main())
bash
python3 log_v.py
Salida en pantalla
WARNING: El artículo A-45 no tiene precio
bash
python3 log_v.py -v
Salida en pantalla
INFO: Sincronizando 120 artículos
WARNING: El artículo A-45 no tiene precio
bash
python3 log_v.py -vv
Salida en pantalla
DEBUG: Nivel de detalle: 2
INFO: Sincronizando 120 artículos
WARNING: El artículo A-45 no tiene precio

El min(cuenta_v, len(NIVELES) - 1) evita el IndexError si a alguien le da por escribir -vvvvv: a partir de dos, ya no hay más detalle que dar. Y por coherencia, muchas herramientas añaden también un -q o --silencioso que sube el nivel a ERROR.

Registrar una excepción entera#

Dentro de un except, logging.exception() escribe el mensaje y la traza completa, sin que tengas que pasarle nada más:

log_exc.py
import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def dividir(a: int, b: int) -> float:
        return a / b


try:
        dividir(10, 0)
except ZeroDivisionError:
        logging.exception("Ha fallado el cálculo del promedio")

print("El programa continúa")
Salida en pantalla
ERROR: Ha fallado el cálculo del promedio
Traceback (most recent call last):
  File "/Users/erwin/curso-python/log_exc.py", line 11, in <module>
    dividir(10, 0)
    ~~~~~~~^^^^^^^
  File "/Users/erwin/curso-python/log_exc.py", line 7, in dividir
    return a / b
           ~~^~~
ZeroDivisionError: division by zero
El programa continúa

Esto resuelve el problema que dejaba abierto la lección de excepciones: hasta ahora, capturar una excepción para que el programa no muriera significaba perder la traza y quedarte con un mensaje pelado. Con logging.exception te quedas las dos cosas, el programa sigue vivo y en el fichero de registro está todo lo necesario para arreglarlo.

Tres detalles importantes:

  • logging.exception solo funciona dentro de un except. Fuera, no hay ninguna excepción activa de la que sacar la traza.
  • Registra siempre con nivel ERROR; no se puede cambiar.
  • Si quieres la traza con otro nivel, usa el parámetro exc_info=True: registro.warning("Fallo recuperable", exc_info=True).

Un registrador por módulo#

Hasta ahora hemos llamado a logging.warning() directamente. Eso usa un registrador especial llamado root, el que salía en WARNING:root:... al principio. Sirve para un programa de un solo fichero y se queda corto en cuanto hay varios.

La buena práctica es que cada módulo cree el suyo con esta línea exacta, al principio del fichero:

python
registro = logging.getLogger(__name__)

__name__ vale el nombre del módulo, así que cada fichero acaba con un registrador que se llama como él. Eso te da dos cosas: cada mensaje dice de dónde salió, y puedes subir o bajar el nivel de un módulo concreto sin tocar los demás.

almacen.py
import logging

registro = logging.getLogger(__name__)


def guardar(articulo: str) -> None:
        registro.info("Guardando %s", articulo)
red.py
import logging

registro = logging.getLogger(__name__)


def descargar(url: str) -> None:
        registro.warning("Reintentando la descarga de %s", url)

El programa principal es el único que configura el registro, y lo hace una sola vez:

principal.py
import logging

import almacen
import red

logging.basicConfig(level=logging.DEBUG, format="%(levelname)-8s %(name)s | %(message)s")

registro = logging.getLogger(__name__)

registro.info("Arrancando la herramienta")
almacen.guardar("A-45")
red.descargar("https://api.ejemplo.es/precios")
logging.getLogger("red").setLevel(logging.ERROR)
red.descargar("https://api.ejemplo.es/stock")
registro.info("Terminado")
Salida en pantalla
INFO     __main__ | Arrancando la herramienta
INFO     almacen | Guardando A-45
WARNING  red | Reintentando la descarga de https://api.ejemplo.es/precios
INFO     __main__ | Terminado

Mira la columna del nombre: __main__ para el fichero que ejecutaste, almacen y red para los módulos. Y fíjate en la segunda llamada a red.descargar: no ha salido nada, porque una línea antes le subimos el nivel a ese módulo concreto. Los demás siguen igual.

Rotar el fichero cuando crece#

Un fichero de registro al que se escribe sin parar acaba ocupando gigabytes. RotatingFileHandler, del submódulo logging.handlers, lo corta por tamaño: cuando el fichero llega al límite, lo renombra y empieza uno nuevo.

log_rota.py
import logging
from logging.handlers import RotatingFileHandler

manejador = RotatingFileHandler(
    "rota.log",
    maxBytes=1024,        # 1 KB, ridículo a propósito para verlo rotar
    backupCount=3,        # conserva rota.log.1, rota.log.2 y rota.log.3
    encoding="utf-8",
)
manejador.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))

registro = logging.getLogger("sincronizacion")
registro.setLevel(logging.INFO)
registro.addHandler(manejador)

for numero in range(1, 101):
        registro.info("Artículo %03d sincronizado correctamente", numero)
bash
python3 log_rota.py
ls -1 rota.log*
Salida en pantalla
rota.log
rota.log.1
rota.log.2
rota.log.3

rota.log siempre es el actual, el que se está escribiendo ahora; rota.log.1 es el anterior, y así hasta el .3. Cuando toca rotar otra vez, el .3 se borra y los demás se desplazan. Con maxBytes=1024 y backupCount=3 nunca se ocuparán más de 4 KB, pase lo que pase y por muchos meses que el programa esté funcionando.

En un programa de verdad los valores razonables son del orden de maxBytes=5_000_000 y backupCount=5: 25 MB como mucho, suficiente para investigar cualquier cosa.