Manejo correcto de excepciones en Python

1. Resumen

Python fomenta el estilo EAFP: simplemente intenta, si hay error luego lo manejas. Por lo tanto, las excepciones aparecen por todas partes en el código Python. Usarlo incorrectamente causa que errores se silencien, tracebacks difíciles de leer y logs duplicados.

Este artículo recorre las técnicas fundamentales: try/except/else/finally, capturar el tipo de excepción correcto, definir sistemas de excepciones personalizadas, encadenar excepciones con raise ... from, logging, ExceptionGroup y cómo escribir tests. Después de leerlo, tendrás un conjunto claro de reglas para aplicar directamente en tu proyecto. Los ejemplos usan Python 3.11 en adelante.

2. Estructura try/except/else/finally

Un bloque completo de manejo de excepciones tiene cuatro partes:

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"Cantidad no válida: {raw!r}")
        return 0
    else:
        print("Análisis exitoso")
        return quantity
    finally:
        print("Análisis finalizado")


print(parse_quantity("3"))
print(parse_quantity("ba"))

El rol de cada parte:

  • try: solo contiene el código que podría lanzar una excepción, en este caso int(raw).
  • except ValueError: se ejecuta cuando int() no puede parsear la cadena.
  • else: se ejecuta cuando try no lanza una excepción. El código del “camino feliz” debe ir aquí en lugar de meterse en try.
  • finally: siempre se ejecuta, incluso con return o excepciones. Esta sección es ideal para limpiar recursos.

Resultado:

Análisis exitoso
Análisis finalizado
3
Cantidad no válida: 'ba'
Análisis finalizado
0

Nota que finally se ejecuta antes de que el valor retornado sea impreso. Python ejecuta finally justo antes de salir de la función.

2.1. Por qué usar else

Compara los dos enfoques siguientes:

# No es bueno: try es demasiado amplio
try:
    quantity = int(raw)
    total = quantity * unit_price  # ¿y si unit_price es del tipo incorrecto?
except ValueError:
    total = 0

Si unit_price es una cadena, la multiplicación no lanzará ValueError. Sin embargo, muchas otras operaciones podrían lanzar ValueError desde lugares inesperados, y ese error se confundiría con “cantidad incorrecta”. Mantén try tan estrecho como sea posible. Coloca el resto en else.

3. Captura excepciones específicas, no todo

El error más común es usar except: desnudo o except Exception: e ignorarlo:

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # simular llamada API lenta
        return "PAID"
    except:  # muy malo
        return "UNKNOWN"

except: desnudo captura incluso KeyboardInterrupt y SystemExit. Cuando presionas Ctrl+C, el programa no se detiene sino que devuelve "UNKNOWN". La jerarquía de excepciones en Python se estructura así:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

Reglas:

  • Captura el tipo más específico que realmente puedas manejar, por ejemplo KeyError, TimeoutError.
  • Solo usa except Exception en los “límites” de la aplicación: bucle worker, manejador HTTP de nivel superior. Cuando lo uses, siempre registra el log.
  • Nunca uses except BaseException o except: a menos que relances la excepción inmediatamente.

Puedes capturar múltiples tipos en una rama usando una tupla:

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"No se pudo leer el precio de {sku}: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
No se pudo leer el precio de B2: ValueError
0.0
No se pudo leer el precio de Z9: KeyError
0.0

4. Define excepciones propias para tu dominio

Para tu negocio específico, debes crear una excepción base para tu módulo y luego heredar de ella:

class PaymentError(Exception):
    """Excepción base del módulo de pagos."""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"Orden {order_id}: saldo {balance} insuficiente para {amount}"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

Forma de usar:

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("Diferencia:", exc.amount - exc.balance)
except PaymentError:
    print("Otro error de pago")
Orden ORD-42: saldo 100 insuficiente para 250
Diferencia: 150

Esta organización tiene tres ventajas:

  1. El código que llama puede capturar PaymentError para manejar todos los errores de pago, o capturar InsufficientFundsError por separado si es necesario.
  2. La excepción lleva datos estructurados (order_id, balance, amount) en lugar de solo una cadena. El lugar que la maneja no necesita parsear el mensaje.
  3. Tus errores están separados de los errores del sistema como ValueError, por lo que no se capturan por error.

Nota: Hereda de Exception, no de BaseException. El nombre de la clase debe terminar en Error, según la convención PEP 8.

5. Encadena excepciones con raise ... from

Cuando conviertes un error de bajo nivel en un error de dominio, debes preservar la causa original:

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("Archivo de configuración inválido") from exc


load_config("{port: 8080}")

El traceback mostrará ambos errores:

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: Archivo de configuración inválido

from exc asigna el error original a la propiedad __cause__. El debugger ve inmediatamente en qué columna está el error JSON.

Si omites from exc, Python seguirá encadenando el error mediante __context__, pero el mensaje será “During handling of the above exception, another exception occurred”. Esta oración implica que tu manejador falló, cuando en realidad no es así. Siempre escribe from explícitamente.

Cuando el error original es solo un detalle interno sin utilidad, usa from None para ocultarlo:

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"No se encontró el cliente {customer_id}") from None

6. Limpia recursos con context manager

Para archivos, conexiones o locks, with es más seguro que try/finally escrito a mano:

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

El archivo siempre se cierra, incluso si el bucle lanza una excepción.

Cuando realmente quieres ignorar un tipo de error, contextlib.suppress es más claro que try/except: pass:

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

Leyendo esta línea, otros entienden inmediatamente “elimina el archivo si existe, sino no hagas nada”.

7. Registra y relanza excepciones

Dentro del bloque except, usa logger.exception() para registrar con traceback completo:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("Orden %s rechazada por falta de fondos", order_id)
        return False
    except PaymentError:
        logger.exception("Error de pago desconocido para la orden %s", order_id)
        raise
    return True

Puntos a tener en cuenta:

  • logger.exception(...) es equivalente a logger.error(..., exc_info=True). Esta función solo debe llamarse dentro de except.
  • raise desnudo relanza exactamente la excepción actual y preserva el traceback. No escribas raise exc, porque el traceback agregará una línea apuntando a este mismo lugar.
  • Los errores conocidos y esperados (InsufficientFundsError) solo necesitan warning, sin traceback.

Nota: Evita el patrón “registra y relanza” en cada nivel. Un error aparecerá cinco veces en el log causando ruido. Solo registra donde realmente manejas el error, o en el límite más externo de la aplicación.

8. Maneja múltiples errores simultáneamente con ExceptionGroup

A partir de Python 3.11, puedes lanzar múltiples excepciones a la vez. Esto es útil cuando validas datos o ejecutas múltiples tareas en paralelo:

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantity debe ser mayor que 0"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("Orden inválida", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("Valor incorrecto:", e)
except* KeyError as group:
    for e in group.exceptions:
        print("Campo faltante:", e)
Valor incorrecto: quantity debe ser mayor que 0
Campo faltante: 'customer'

A diferencia de except normal, múltiples ramas except* pueden ejecutarse simultáneamente. Cada rama recibe un ExceptionGroup que contiene solo los errores que coinciden con el tipo. Si hay errores que ninguna rama maneja, se relanzarán automáticamente.

asyncio.TaskGroup también usa este mecanismo. Cuando múltiples tareas secundarias fallan, recibes un ExceptionGroup.

9. Testea excepciones con pytest

Instala pytest:

pip install pytest

Escribe tests para PaymentService y load_config:

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # el saldo no debe reducirse


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

Explicación:

  • pytest.raises(...) hace que el test falle si no hay excepción, o la excepción es del tipo incorrecto.
  • match= toma una regex y coincide contra str(exception).
  • exc_info.value es el objeto de excepción real. Esto te permite verificar propiedades como amount, __cause__.
  • El primer test también verifica el estado después del error: el saldo debe permanecer sin cambios. Esta es la parte que a menudo se olvida.

Ejecuta los tests:

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. Errores comunes

Silenciar excepciones.

try:
    service.charge(order_id, amount)
except Exception:
    pass  # la orden "tiene éxito" aunque no se ha debitado dinero

Este tipo de error es casi imposible de depurar. Si realmente necesitas ignorarlo, usa suppress con un tipo de excepción específico y comenta por qué.

return en finally.

def risky() -> str:
    try:
        raise RuntimeError("pérdida de datos")
    finally:
        return "ok"  # ¡la excepción desaparece!

return, break o continue en finally cancelará la excepción que se está propagando. A partir de Python 3.14, el intérprete emite un SyntaxWarning para esto. Nunca escribas así.

Lanzar Exception genérica. raise Exception("error") obliga a quien llama a capturar Exception, es decir, capturar todo lo demás. Usa la excepción incorporada apropiada (ValueError, TypeError) o una excepción de dominio.

Usar excepciones para flujo normal en bucles críticos. EAFP es el estilo Pythonic. Pero si el caso de “error” ocurre la mayoría del tiempo, dict.get() o una verificación if es tanto más rápido como más claro:

# Mejor cuando la clave frecuentemente no existe
discount = discounts.get(customer_id, 0)

Usar assert para validar entrada. assert se deshabilita cuando ejecutas python -O. Usa raise ValueError(...) para datos provenientes del exterior.

11. Conclusión

El manejo correcto de excepciones en Python gira en torno a algunos principios simples:

  • Mantén try estrecho, coloca el código siguiente en else, limpia con finally o with.
  • Captura el tipo de excepción más específico. Solo captura Exception en los límites de la aplicación y siempre registra.
  • Crea un sistema de excepciones propio para tu dominio, llevando datos estructurados.
  • Usa raise ... from exc para preservar la causa original, y raise desnudo para relanzar.
  • Usa ExceptionGroup y except* cuando ocurren múltiples errores simultáneamente.
  • Escribe tests con pytest.raises para verificar tanto el tipo de error como el estado después del error.

Aplicando estas reglas, tus tracebacks serán más legibles, los logs más limpios, y los errores no se silenciarán accidentalmente.

pythonexceptionerror-handlingpytestbest-practices