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 casoint(raw).except ValueError: se ejecuta cuandoint()no puede parsear la cadena.else: se ejecuta cuandotryno lanza una excepción. El código del “camino feliz” debe ir aquí en lugar de meterse entry.finally: siempre se ejecuta, incluso conreturno 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 Exceptionen los “límites” de la aplicación: bucle worker, manejador HTTP de nivel superior. Cuando lo uses, siempre registra el log. - Nunca uses
except BaseExceptionoexcept: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:
- El código que llama puede capturar
PaymentErrorpara manejar todos los errores de pago, o capturarInsufficientFundsErrorpor separado si es necesario. - 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. - 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 deBaseException. El nombre de la clase debe terminar enError, 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 alogger.error(..., exc_info=True). Esta función solo debe llamarse dentro deexcept.raisedesnudo relanza exactamente la excepción actual y preserva el traceback. No escribasraise exc, porque el traceback agregará una línea apuntando a este mismo lugar.- Los errores conocidos y esperados (
InsufficientFundsError) solo necesitanwarning, 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 contrastr(exception).exc_info.valuees el objeto de excepción real. Esto te permite verificar propiedades comoamount,__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
tryestrecho, coloca el código siguiente enelse, limpia confinallyowith. - Captura el tipo de excepción más específico. Solo captura
Exceptionen 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 excpara preservar la causa original, yraisedesnudo para relanzar. - Usa
ExceptionGroupyexcept*cuando ocurren múltiples errores simultáneamente. - Escribe tests con
pytest.raisespara 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.