Tratamento de exceções em Python da forma correta
1. Visão geral
Python encoraja o estilo EAFP: faça, se houver erro, trate-o. Por isso, exceções aparecem por toda parte no código Python. Usá-las incorretamente pode levar a erros silenciosos, tracebacks difíceis de ler e logs duplicados.
Este artigo passa pelas técnicas fundamentais: try/except/else/finally, capturar o tipo correto de exception, criar seu próprio sistema de exceções, encadear exceções com raise ... from, logging, ExceptionGroup e como escrever testes. Depois de ler, você terá um conjunto claro de regras para aplicar imediatamente no projeto. Os exemplos usam Python 3.11 ou superior.
2. Estrutura try/except/else/finally
Um bloco de tratamento de exceções completo tem quatro partes:
def parse_quantity(raw: str) -> int:
try:
quantity = int(raw)
except ValueError:
print(f"Quantidade inválida: {raw!r}")
return 0
else:
print("Parse bem-sucedido")
return quantity
finally:
print("Fim do parse")
print(parse_quantity("3"))
print(parse_quantity("ba"))
O papel de cada parte:
try: contém apenas o código que pode lançar uma exceção, aquiint(raw).except ValueError: executa quandoint()não consegue fazer parse da string.else: executa quandotrynão lança exceção. O código do “caminho feliz” deve ir aqui em vez de estar dentro detry.finally: sempre executa, mesmo comreturnou exceção. Esta seção é apropriada para limpeza de recursos.
Resultado:
Parse bem-sucedido
Fim do parse
3
Quantidade inválida: 'ba'
Fim do parse
0
Note que finally executa antes do valor retornado ser impresso. Python executa finally logo antes de sair da função.
2.1. Por que usar else
Compare as duas formas a seguir:
# Não é bom: try muito amplo
try:
quantity = int(raw)
total = quantity * unit_price # e se unit_price for do tipo errado?
except ValueError:
total = 0
Se unit_price for uma string, a multiplicação não lança ValueError. Porém, muitos outros cálculos podem lançar ValueError de um lugar inesperado, e esse erro será confundido com “quantidade inválida”. Mantenha try o mais estreito possível. Coloque o resto em else.
3. Capture exceções específicas, não tudo
O erro mais comum é usar except: simples ou except Exception: e ignorar:
import time
def fetch_order_status(order_id: str) -> str:
try:
time.sleep(10) # simula chamada de API lenta
return "PAID"
except: # muito ruim
return "UNKNOWN"
except: simples captura até KeyboardInterrupt e SystemExit. Quando você pressiona Ctrl+C, o programa não para, mas retorna "UNKNOWN". A hierarquia de exceções do Python é assim:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── KeyError
├── OSError
└── ...
Regras:
- Capture o tipo mais específico que você pode realmente tratar, como
KeyError,TimeoutError. - Use
except Exceptionapenas nas “bordas” da aplicação: loop worker, handler HTTP de nível superior. Quando usar, sempre faça logging. - Nunca use
except BaseExceptionouexcept:a menos que relance imediatamente.
Você pode capturar múltiplos tipos em um ramo com uma tupla:
def read_price(prices: dict[str, str], sku: str) -> float:
try:
return float(prices[sku])
except (KeyError, ValueError) as exc:
print(f"Não consegui ler o preço de {sku}: {type(exc).__name__}")
return 0.0
print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Não consegui ler o preço de B2: ValueError
0.0
Não consegui ler o preço de Z9: KeyError
0.0
4. Defina suas próprias exceções para o domínio
Para negócio específico, crie uma exceção base para o módulo e herde dela:
class PaymentError(Exception):
"""Exceção base do módulo de pagamento."""
class InsufficientFundsError(PaymentError):
def __init__(self, order_id: str, balance: float, amount: float) -> None:
super().__init__(
f"Pedido {order_id}: saldo {balance} insuficiente para pagar {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
Como usar:
service = PaymentService(balance=100)
try:
service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
print(exc)
print("Falta:", exc.amount - exc.balance)
except PaymentError:
print("Outro erro de pagamento")
Pedido ORD-42: saldo 100 insuficiente para pagar 250
Falta: 150
Esta forma de organização tem três vantagens:
- O código que chama pode capturar
PaymentErrorpara tratar qualquer erro de pagamento, ou capturarInsufficientFundsErrorquando necessário. - A exceção traz dados estruturados (
order_id,balance,amount) em vez de apenas uma string. Quem trata não precisa fazer parse da mensagem. - Seus erros ficam separados de erros do sistema como
ValueError, então não são capturados por engano.
Nota: Herde de
Exception, não deBaseException. O nome da classe deve terminar comError, seguindo a convenção PEP 8.
5. Encadeie exceções com raise ... from
Quando você converte um erro de baixo nível em um erro de domínio, guarde a 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("Arquivo de configuração inválido") from exc
load_config("{port: 8080}")
O traceback mostrará ambos os erros:
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: Arquivo de configuração inválido
from exc atribui o erro original ao atributo __cause__. Quem estiver debugando vê imediatamente o erro JSON na coluna exata.
Se você omitir from exc, Python ainda encadeia o erro via __context__, mas a mensagem será “During handling of the above exception, another exception occurred”. Isso implica que o handler seu estava com erro, quando na verdade não estava. Sempre escreva from explicitamente.
Quando o erro original é apenas um detalhe interno sem valor, use from None para ocultá-lo:
def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
try:
return tiers[customer_id]
except KeyError:
raise LookupError(f"Cliente {customer_id} não encontrado") from None
6. Limpe recursos com context manager
Com arquivos, conexões ou locks, with é mais seguro que try/finally escrito manualmente:
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())
O arquivo é sempre fechado, mesmo se o loop lançar exceção.
Quando você realmente quer ignorar um tipo de erro, contextlib.suppress deixa a intenção mais clara que try/except: pass:
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path("cache/orders.tmp").unlink()
Lendo esta linha, alguém entende imediatamente: “delete o arquivo se existir, caso contrário tudo bem”.
7. Faça logging e relance exceções
No bloco except, use logger.exception() para gravar junto com o 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("Pedido %s recusado por falta de fundos", order_id)
return False
except PaymentError:
logger.exception("Erro de pagamento desconhecido para pedido %s", order_id)
raise
return True
Pontos importantes:
logger.exception(...)é equivalente alogger.error(..., exc_info=True). Esta função deve ser chamada apenas dentro deexcept.raisesimples relança exatamente a exceção atual e preserva o traceback. Não escrevaraise exc, senão uma linha apontando para cá será adicionada ao traceback.- Erros previstos e conhecidos (
InsufficientFundsError) precisam apenas dewarning, não de traceback.
Nota: Evite o padrão “log e relança” em todos os níveis. Um erro aparecerá cinco vezes no log e causará ruído. Faça logging apenas onde realmente trata o erro, ou na borda mais externa da aplicação.
8. Trate múltiplos erros ao mesmo tempo com ExceptionGroup
A partir do Python 3.11, você pode lançar múltiplas exceções de uma vez. Isso é útil para validar dados ou rodar múltiplas tarefas em paralelo:
def validate_order(order: dict) -> None:
errors: list[Exception] = []
if order.get("quantity", 0) <= 0:
errors.append(ValueError("quantity deve ser maior que 0"))
if not order.get("customer"):
errors.append(KeyError("customer"))
if errors:
raise ExceptionGroup("Pedido inválido", errors)
try:
validate_order({"quantity": 0})
except* ValueError as group:
for e in group.exceptions:
print("Valor inválido:", e)
except* KeyError as group:
for e in group.exceptions:
print("Campo faltando:", e)
Valor inválido: quantity deve ser maior que 0
Campo faltando: 'customer'
Diferente de except normal, múltiplos ramos except* podem executar. Cada ramo recebe um ExceptionGroup contendo apenas os erros que combinam com o tipo. Se ainda houver erros não tratados por nenhum ramo, eles serão relançados automaticamente.
asyncio.TaskGroup também usa este mecanismo. Quando múltiplas tasks falham, você recebe um ExceptionGroup.
9. Teste comportamento de exceção com pytest
Instale pytest:
pip install pytest
Escreva testes para PaymentService e 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 # saldo não foi deduzido
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"
Explicação:
pytest.raises(...)faz o teste falhar se não há exceção, ou se for do tipo errado.match=recebe uma regex e a compara comstr(exception).exc_info.valueé o objeto exception real. Assim você pode verificar atributos comoamount,__cause__.- O primeiro teste também verifica o estado depois do erro: o saldo deve permanecer inalterado. Este é um detalhe frequentemente esquecido.
Execute os testes:
pytest -q
.. [100%]
2 passed in 0.02s
10. Erros comuns
Engolir exceções silenciosamente.
try:
service.charge(order_id, amount)
except Exception:
pass # o pedido "bem-sucedido" mas o dinheiro não foi descontado
Este tipo de erro é quase impossível de debugar. Se realmente precisa ignorar, use suppress com um tipo específico de exceção e deixe um comentário sobre o porquê.
return em finally.
def risky() -> str:
try:
raise RuntimeError("dados perdidos")
finally:
return "ok" # a exceção desaparece!
return, break ou continue dentro de finally cancelam a exceção que está se propagando. A partir do Python 3.14, o interpretador emite SyntaxWarning para este caso. Nunca escreva assim.
Lançar Exception genérica. raise Exception("erro") força quem chama a capturar Exception, ou seja, capturar praticamente tudo mais. Use uma exceção apropriada já existente (ValueError, TypeError) ou uma exceção de domínio.
Use exceções para fluxo normal em loops críticos. EAFP é o estilo Pythonic. Mas se o caso de “erro” acontece a maior parte do tempo, dict.get() ou um simples if é tanto mais rápido quanto mais claro:
# Melhor quando a chave frequentemente está ausente
discount = discounts.get(customer_id, 0)
Use assert para validar entrada. assert é desativado quando rodando python -O. Use raise ValueError(...) para dados que vêm de fora.
11. Conclusão
Tratar exceções corretamente em Python gira em torno de alguns princípios simples:
- Mantenha
tryestreito, mova o código em sequência paraelse, limpe comfinallyouwith. - Capture o tipo de exceção mais específico. Use
except Exceptionapenas nas bordas da aplicação e sempre faça logging. - Crie seu próprio sistema de exceções para o domínio, carregando dados estruturados.
- Use
raise ... from excpara preservar a causa original, eraisesimples para relançar. - Use
ExceptionGroupeexcept*quando múltiplos erros podem acontecer ao mesmo tempo. - Escreva testes com
pytest.raisespara verificar tanto o tipo de erro quanto o estado após o erro.
Aplicando essas regras, seus tracebacks serão mais legíveis, seus logs mais limpos, e seus erros não desaparecerão silenciosamente.