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, aqui int(raw).
  • except ValueError: executa quando int() não consegue fazer parse da string.
  • else: executa quando try não lança exceção. O código do “caminho feliz” deve ir aqui em vez de estar dentro de try.
  • finally: sempre executa, mesmo com return ou 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 Exception apenas nas “bordas” da aplicação: loop worker, handler HTTP de nível superior. Quando usar, sempre faça logging.
  • Nunca use except BaseException ou except: 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:

  1. O código que chama pode capturar PaymentError para tratar qualquer erro de pagamento, ou capturar InsufficientFundsError quando necessário.
  2. 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.
  3. 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 de BaseException. O nome da classe deve terminar com Error, 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 a logger.error(..., exc_info=True). Esta função deve ser chamada apenas dentro de except.
  • raise simples relança exatamente a exceção atual e preserva o traceback. Não escreva raise exc, senão uma linha apontando para cá será adicionada ao traceback.
  • Erros previstos e conhecidos (InsufficientFundsError) precisam apenas de warning, 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 com str(exception).
  • exc_info.value é o objeto exception real. Assim você pode verificar atributos como amount, __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 try estreito, mova o código em sequência para else, limpe com finally ou with.
  • Capture o tipo de exceção mais específico. Use except Exception apenas 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 exc para preservar a causa original, e raise simples para relançar.
  • Use ExceptionGroup e except* quando múltiplos erros podem acontecer ao mesmo tempo.
  • Escreva testes com pytest.raises para 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.

pythonexceptionerror-handlingpytestbest-practices