Tratamento de Exceções e Erros

Tratamento de Exceções e Erros

Exceções em Python, do try básico ao encadeamento com from. Incluindo três falhas silenciosas que os materiais omitem: o return no finally que descarta o erro em andamento, o except sem tipo que engole o Ctrl+C, e o import dentro do try que faz a própria cláusula except quebrar.
Python

• • 21 min de leitura

Todo programa que interage com o mundo real encontra situações inesperadas — arquivos que não existem, conexões que falham, dados no formato errado, divisões por zero. Um programa robusto não apenas funciona no caminho feliz — ele lida graciosamente com falhas. Python oferece um sistema de exceções expressivo e flexível para isso.

O que é uma Exceção?

Uma exceção é um objeto que representa um erro ocorrido durante a execução. Quando Python encontra um problema, ele lança (raises) uma exceção. Se não houver código para tratá-la, o programa encerra com uma mensagem de erro chamada traceback:

numeros = [1, 2, 3]
print(numeros[10])
Traceback (most recent call last):
  File "exemplo.py", line 2, in <module>
    print(numeros[10])
          ~~~~~~~^^^^
IndexError: list index out of range

Repare na linha de til e circunflexo: desde o Python 3.11 o traceback aponta a subexpressão exata que falhou, o que resolve de imediato a dúvida em linhas com várias operações — num a[i] + b[j], ele mostra qual dos dois índices estourou, sem precisar de nenhuma investigação.

O traceback deve ser lido de baixo para cima — a última linha indica o erro; as linhas acima mostram o caminho de chamadas que levou até ele.

Exceções Comuns

# TypeError — operação em tipo errado
"texto" + 42

# ValueError — tipo certo, valor errado
int("abc")

# KeyError — chave inexistente em dicionário
{"a": 1}["b"]

# IndexError — índice fora do intervalo
[][0]

# AttributeError — atributo inexistente
"texto".voar()

# ZeroDivisionError — divisão por zero
10 / 0

# FileNotFoundError — arquivo inexistente
open("nao_existe.txt")

# ImportError — módulo não encontrado
import modulo_inexistente

# NameError — variável não definida
print(variavel_nao_definida)

try / except

A estrutura básica de tratamento:

def dividir(a, b):
    try:
        resultado = a / b
        return resultado
    except ZeroDivisionError:
        print("Erro: divisão por zero.")
        return None


print(dividir(10, 2))   # 5.0
print(dividir(10, 0))   # Erro: divisão por zero. → None

Capturando Múltiplas Exceções

def converter_e_processar(texto, indice):
    try:
        numero   = int(texto)
        lista    = [1, 2, 3, 4, 5]
        elemento = lista[indice]
        return numero + elemento

    except ValueError:
        print(f"'{texto}' não é um número válido.")
    except IndexError:
        print(f"Índice {indice} fora do intervalo.")
    except (TypeError, AttributeError) as e:
        print(f"Erro de tipo ou atributo: {e}")

    return None


print(converter_e_processar("10", 2))   # 13
print(converter_e_processar("abc", 2))  # 'abc' não é um número válido.
print(converter_e_processar("10", 99))  # Índice 99 fora do intervalo.

Evite capturar Exception ou BaseException de forma genérica sem necessidade — isso esconde erros reais e dificulta o diagnóstico.

Há uma diferença entre os três níveis de captura ampla que vale conhecer, porque o pior deles é justamente o mais curto de escrever. O except: sem nada captura tudo, inclusive o que não é erro de programa: KeyboardInterrupt, que é o usuário apertando Ctrl+C, e SystemExit, que é o programa pedindo para terminar.

try:
    processar()      # o usuário aperta Ctrl+C no meio
except:
    print("Ocorreu um erro, continuando...")   # e o programa NÃO para

Um laço com except: pelado no meio fica impossível de interromper pelo teclado, porque cada Ctrl+C é engolido como se fosse uma falha qualquer. O except Exception não tem esse problema — KeyboardInterrupt e SystemExit herdam de BaseException, não de Exception, e por isso passam direto. Se a captura ampla for mesmo necessária, e às vezes é, na fronteira de um servidor que não pode cair por causa de uma requisição, use except Exception, registre o erro com logging.exception, que grava o traceback inteiro, e nunca engula em silêncio.

else e finally

A estrutura completa do tratamento de exceções:

def ler_arquivo(caminho):
    arquivo = None
    try:
        arquivo = open(caminho, "r", encoding="utf-8")
        conteudo = arquivo.read()

    except FileNotFoundError:
        print(f"Arquivo '{caminho}' não encontrado.")
        return None

    except PermissionError:
        print(f"Sem permissão para ler '{caminho}'.")
        return None

    else:
        # Executado apenas se nenhuma exceção ocorreu
        print(f"Arquivo lido com sucesso: {len(conteudo)} caracteres.")
        return conteudo

    finally:
        # Executado SEMPRE — com ou sem exceção
        if arquivo:
            arquivo.close()
            print("Arquivo fechado.")


conteudo = ler_arquivo("dados.txt")

O bloco finally é garantido mesmo se houver return dentro do try — essencial para liberar recursos.

Essa garantia tem um lado perigoso: um return dentro do próprio finally descarta qualquer exceção que estivesse em andamento.

def perigosa():
    try:
        raise ValueError("erro importante que ninguém vai ver")
    finally:
        return "tudo certo"

perigosa()   # devolve 'tudo certo' — a ValueError desapareceu

A exceção não é tratada nem registrada: simplesmente some, e a função devolve um valor de sucesso para um erro que aconteceu. O mesmo vale para break e continue dentro do finally. Como é um engano frequente e difícil de achar, o Python 3.14 passou a emitir SyntaxWarning: 'return' in a 'finally' block ao compilar. A regra prática é simples: o finally serve para liberar recursos, e nada mais — nenhum return, nenhum desvio de fluxo.

O Gerenciador de Contexto: with

A forma mais Pythônica de gerenciar recursos é com with — que chama finally automaticamente:

# Sem with — verboso e frágil
arquivo = open("dados.txt", "r")
try:
    conteudo = arquivo.read()
finally:
    arquivo.close()

# Com with — limpo e seguro
with open("dados.txt", "r", encoding="utf-8") as arquivo:
    conteudo = arquivo.read()
# arquivo.close() é chamado automaticamente aqui

Lançando Exceções: raise

def calcular_raiz(numero):
    if numero < 0:
        raise ValueError(f"Não é possível calcular raiz de número negativo: {numero}")
    return numero ** 0.5


def sacar(saldo, valor):
    if valor <= 0:
        raise ValueError("O valor do saque deve ser positivo.")
    if valor > saldo:
        raise ValueError(f"Saldo insuficiente. Saldo: R${saldo:.2f}, Solicitado: R${valor:.2f}")
    return saldo - valor


try:
    print(calcular_raiz(-4))
except ValueError as e:
    print(f"Erro: {e}")

try:
    novo_saldo = sacar(100, 150)
except ValueError as e:
    print(f"Erro: {e}")

Re-lançando Exceções

import logging

def processar_pagamento(valor):
    try:
        if valor <= 0:
            raise ValueError("Valor inválido.")
        print(f"Pagamento de R${valor:.2f} processado.")

    except ValueError as e:
        logging.error(f"Falha no pagamento: {e}")
        raise   # re-lança a mesma exceção para o chamador tratar


def finalizar_compra(valor):
    try:
        processar_pagamento(valor)
    except ValueError:
        print("Compra cancelada por valor inválido.")


finalizar_compra(-50)

Exceções Personalizadas

Para sistemas maiores, crie sua própria hierarquia de exceções:

class ErroAplicacao(Exception):
    """Exceção base da aplicação."""
    pass


class ErroValidacao(ErroAplicacao):
    """Erro de validação de dados."""

    def __init__(self, campo, mensagem):
        self.campo    = campo
        self.mensagem = mensagem
        super().__init__(f"Validação falhou no campo '{campo}': {mensagem}")


class ErroAutenticacao(ErroAplicacao):
    """Erro de autenticação."""

    def __init__(self, usuario):
        self.usuario = usuario
        super().__init__(f"Autenticação falhou para o usuário '{usuario}'.")


class ErroRecursoNaoEncontrado(ErroAplicacao):
    """Recurso não encontrado."""

    def __init__(self, recurso, identificador):
        self.recurso       = recurso
        self.identificador = identificador
        super().__init__(f"{recurso} com id={identificador} não encontrado.")


def buscar_usuario(usuario_id, banco):
    usuario = banco.get(usuario_id)
    if not usuario:
        raise ErroRecursoNaoEncontrado("Usuário", usuario_id)
    return usuario


def autenticar(usuario, senha):
    if usuario.get("senha") != senha:
        raise ErroAutenticacao(usuario.get("nome"))
    return True


def atualizar_email(usuario, novo_email):
    if "@" not in novo_email:
        raise ErroValidacao("email", "formato inválido")
    usuario["email"] = novo_email


banco = {1: {"nome": "Ana", "senha": "1234", "email": "ana@email.com"}}

try:
    u = buscar_usuario(1, banco)
    autenticar(u, "1234")
    atualizar_email(u, "novo-email-sem-arroba")

except ErroRecursoNaoEncontrado as e:
    print(f"[404] {e}")

except ErroAutenticacao as e:
    print(f"[401] {e}")

except ErroValidacao as e:
    print(f"[422] Campo '{e.campo}': {e.mensagem}")

except ErroAplicacao as e:
    print(f"[500] Erro interno: {e}")

Encadeamento de Exceções

def carregar_config(caminho):
    import json          # no topo: veja a ressalva abaixo
    try:
        with open(caminho) as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ErroAplicacao(f"Configuração não encontrada: {caminho}") from e
    except json.JSONDecodeError as e:
        raise ErroAplicacao(f"Configuração inválida em: {caminho}") from e


try:
    config = carregar_config("config.json")
except ErroAplicacao as e:
    print(f"Erro: {e}")
    print(f"Causa original: {e.__cause__}")

O from e preserva a exceção original como causa — visível no traceback e em __cause__.

A mudança do import json para fora do try não é questão de estilo. Na versão natural de escrever isso, o import fica dentro do bloco, e a cláusula except json.JSONDecodeError passa a depender de um nome que talvez nunca tenha sido criado. As cláusulas except são avaliadas na ordem, uma a uma, até alguma casar — e avaliar json.JSONDecodeError exige que json exista.

# com o import dentro do try, e um erro que não seja FileNotFoundError:
carregar_config("/caminho/que/e/um/diretorio")
# UnboundLocalError: cannot access local variable 'json'
# where it is not associated with a value

O que o usuário recebe não é o erro real, e sim um UnboundLocalError sobre uma variável que ele nem sabia que existia — a exceção verdadeira foi perdida no caminho. O detalhe traiçoeiro é que o caminho feliz e o FileNotFoundError funcionam perfeitamente, porque o primeiro except casa antes de o segundo ser avaliado; só falham os casos que ninguém testou. A regra que evita a classe inteira: tudo de que as cláusulas except precisam tem de estar disponível antes do try.

Exemplo Completo: API de Cadastro

class ErroAplicacao(Exception):
    pass

class ErroValidacao(ErroAplicacao):
    def __init__(self, erros: dict):
        self.erros = erros
        super().__init__(f"Erros de validação: {erros}")

class ErroConflito(ErroAplicacao):
    pass


class CadastroUsuario:
    def __init__(self):
        self._usuarios = {}

    def _validar(self, dados: dict) -> dict:
        erros = {}
        if not dados.get("nome") or len(dados["nome"]) < 2:
            erros["nome"] = "Nome deve ter ao menos 2 caracteres."
        if not dados.get("email") or "@" not in dados["email"]:
            erros["email"] = "E-mail inválido."
        if not dados.get("senha") or len(dados["senha"]) < 6:
            erros["senha"] = "Senha deve ter ao menos 6 caracteres."
        return erros

    def cadastrar(self, dados: dict) -> dict:
        erros = self._validar(dados)
        if erros:
            raise ErroValidacao(erros)

        email = dados["email"]
        if email in self._usuarios:
            raise ErroConflito(f"E-mail '{email}' já cadastrado.")

        usuario = {
            "id":    len(self._usuarios) + 1,
            "nome":  dados["nome"],
            "email": email,
        }
        self._usuarios[email] = usuario
        return usuario


cadastro = CadastroUsuario()

casos = [
    {"nome": "Ana Silva", "email": "ana@email.com", "senha": "segura123"},
    {"nome": "A",         "email": "invalido",       "senha": "123"},
    {"nome": "Ana Silva", "email": "ana@email.com",  "senha": "outrasenha"},
]

for dados in casos:
    try:
        usuario = cadastro.cadastrar(dados)
        print(f"[OK] Usuário criado: {usuario}")

    except ErroValidacao as e:
        print(f"[VALIDAÇÃO] {e.erros}")

    except ErroConflito as e:
        print(f"[CONFLITO] {e}")

Tratar exceção não é evitar que o programa quebre: é decidir, para cada falha possível, quem tem informação suficiente para reagir. Capturar cedo demais esconde o problema de quem poderia resolvê-lo; capturar de forma ampla demais transforma um erro específico em um aviso genérico. Daí as duas regras que sustentam o resto — capture o tipo mais específico que você sabe tratar, e quando precisar traduzir uma exceção para o vocabulário do seu domínio, use raise ... from e, que preserva a causa original em vez de apagá-la. O with resolve a maior parte dos casos de liberação de recurso e dispensa o try/finally escrito à mão.

Três detalhes deste artigo merecem ser lembrados porque falham em silêncio. Um return dentro do finally descarta a exceção em andamento, devolvendo sucesso para um erro que aconteceu — o Python 3.14 passou a avisar disso com um SyntaxWarning, e a regra segura é reservar o finally para liberar recursos e nada mais. O except: sem tipo captura até o Ctrl+C, deixando laços impossíveis de interromper, enquanto except Exception não tem esse problema. E tudo de que as cláusulas except precisam tem de existir antes do try: um módulo importado lá dentro e referenciado numa cláusula produz um UnboundLocalError que substitui o erro verdadeiro, e só nos caminhos que ninguém testou.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma função de importação envolve todo o processamento num try e termina com finally: return resultado, para garantir que sempre devolva alguma coisa. A equipe de dados relata que arquivos corrompidos são importados com sucesso e geram relatórios vazios, sem nenhum erro no log. Explique.

Ver resposta

✓ Resposta: Um return dentro do finally descarta a exceção que estava em andamento. Quando o processamento levanta, digamos, um ValueError por causa de uma linha corrompida, o Python interrompe o try, começa a propagar a exceção e, antes de entregá-la ao chamador, executa o finally. Ao encontrar um return ali, ele conclui que a função terminou normalmente com aquele valor, e a exceção que estava a caminho é simplesmente jogada fora. Não há mensagem, não há registro, não há nada no log — porque nenhuma exceção chegou a escapar da função para alguém registrar. O resultado é exatamente o descrito: a importação relata sucesso, devolve o resultado que ficou pela metade, e o relatório sai vazio. O mesmo acontece com break e continue dentro do finally. Como o engano é frequente e muito difícil de diagnosticar — o sintoma aparece no relatório, dias depois, longe da importação —, o Python 3.14 passou a emitir SyntaxWarning: 'return' in a 'finally' block já na compilação, o que teria apontado o problema sem ninguém precisar investigar. A correção é tirar o return do finally e deixar ali apenas a liberação de recursos, que é para o que ele existe. Se a intenção era mesmo garantir um valor de retorno em caso de falha, isso pertence a um except, que torna a decisão explícita: capturar o erro, registrá-lo com logging.exception para o traceback ficar gravado, e só então devolver o valor padrão. A diferença entre as duas versões é que a segunda deixa rastro, e a primeira apaga a evidência.

Exercício 2

Um serviço de longa duração processa uma fila num laço, com try e except: pelado em volta de cada item, para que um item ruim não derrube o serviço. Os operadores reclamam que não conseguem parar o processo com Ctrl+C — precisam matá-lo à força. Explique a relação e diga o que usar no lugar.

Ver resposta

✓ Resposta: O except: sem tipo captura toda exceção, e nem toda exceção representa um erro do programa. A hierarquia do Python tem BaseException no topo, e abaixo dela dois ramos com propósitos diferentes: Exception, de onde descendem os erros propriamente ditos, e um conjunto de exceções de controle que existem para interromper o programa — KeyboardInterrupt, levantada quando o usuário aperta Ctrl+C, SystemExit, levantada por sys.exit(), e GeneratorExit. O except: pelado pega todas. Cada Ctrl+C dos operadores chega como KeyboardInterrupt dentro do processamento de um item, é capturado pelo bloco genérico, tratado como item com erro, e o laço segue alegremente para o próximo — dando a impressão de que o sinal foi ignorado. Com a fila cheia, a única saída é matar o processo. A correção é trocar por except Exception, que captura os erros de verdade e deixa passar as exceções de controle, permitindo que o Ctrl+C interrompa o laço e encerre o serviço. Vale acrescentar duas práticas que este cenário pede. A primeira é registrar com logging.exception dentro do except, que grava o traceback completo — um serviço que engole erros em silêncio para não cair acaba perdendo itens sem ninguém saber, o que é outro defeito só que mais discreto. A segunda é encerrar de forma limpa: capturar KeyboardInterrupt deliberadamente, em volta do laço inteiro e não de cada item, para terminar o item atual, devolver o que estava em processamento para a fila e sair com estado consistente. Isso é bem diferente de engolir o sinal — é tratá-lo como o que ele é, um pedido de parada.

Exercício 3

Analise a função abaixo. Ela funciona quando o arquivo existe e quando não existe, e falha de forma incompreensível quando o caminho aponta para um diretório. Explique.

def carregar_config(caminho):
    try:
        with open(caminho) as f:
            import json
            return json.load(f)
    except FileNotFoundError as e:
        raise ErroAplicacao("não encontrado") from e
    except json.JSONDecodeError as e:
        raise ErroAplicacao("json inválido") from e
Ver resposta

✓ Resposta: As cláusulas except são avaliadas em sequência quando uma exceção ocorre, e avaliar json.JSONDecodeError exige que o nome json exista naquele momento. Como o import json está dentro do try, ele só é executado se o open tiver dado certo. Isso explica os três comportamentos. Com o arquivo existindo e o JSON válido, tudo roda e nenhuma cláusula é avaliada. Com o arquivo inexistente, é levantado FileNotFoundError, a primeira cláusula casa imediatamente e a segunda nunca chega a ser avaliada — o defeito continua invisível. Com o caminho apontando para um diretório, o open levanta IsADirectoryError; a primeira cláusula é avaliada e não casa; a segunda é avaliada e, ao tentar resolver json, encontra um nome que a função declara como local (porque há um import json no corpo) mas que nunca foi associado a valor. O resultado é UnboundLocalError: cannot access local variable 'json' where it is not associated with a value. O que chega ao usuário, portanto, não é o erro real — é uma mensagem sobre uma variável que ele nem sabia que existia, e o IsADirectoryError original se perde no caminho. O que torna este defeito especialmente ingrato é que ele está nos caminhos que ninguém testa: o feliz funciona, o caso de erro mais óbvio funciona, e só os casos raros quebram. A correção é mover o import para fora do try — de preferência para o topo do módulo, como manda a PEP 8 —, e a regra geral que ela ilustra é que tudo de que as cláusulas except dependem tem de estar disponível antes do bloco. Vale acrescentar que capturar OSError, classe base de FileNotFoundError, IsADirectoryError e PermissionError, teria tratado os três de uma vez, o que costuma ser o desejado ao ler um arquivo.

Exercício 4

Uma camada de acesso a dados captura sqlite3.OperationalError e relança ErroBanco("falha ao consultar"), sem from. Semanas depois, uma falha intermitente em produção é impossível de diagnosticar: o log mostra apenas falha ao consultar. Explique o que se perdeu e a diferença entre raise X from e, raise X dentro de um except, e raise X from None.

Ver resposta

✓ Resposta: Perdeu-se a informação que diria qual foi a falha: se o banco estava travado, se a tabela não existe, se o disco encheu, se a conexão caiu. Todas chegam como OperationalError com mensagens distintas, e a tradução para ErroBanco("falha ao consultar") descartou a mensagem específica e o traceback de onde ela nasceu. Sobre as três formas, a diferença está em dois atributos que o Python mantém na exceção nova. Com raise X from e, o atributo __cause__ recebe a exceção original, e o traceback impresso traz as duas, separadas por The above exception was the direct cause of the following exception — é a forma explícita, que declara traduzi aquilo nisto, e é a correta aqui. Com raise X dentro de um except, o Python não deixa a original sumir: ele preenche __context__ automaticamente, e o traceback mostra as duas com a frase During handling of the above exception, another exception occurred. Ou seja, mesmo sem from, a informação ainda estaria no traceback — o que significa que o problema descrito no enunciado não é da linguagem, e sim de o log gravar apenas str(e) em vez do traceback completo. Com raise X from None, o encadeamento é suprimido de propósito, e a exceção original desaparece do traceback; existe para os casos em que a causa interna é ruído sem valor para quem lê, e deve ser usada com parcimônia, porque é a única das três que realmente apaga evidência. A lição prática tem duas partes: traduzir exceções para o vocabulário do domínio é uma boa prática, desde que sempre com from e; e o registro precisa ser feito com logging.exception ou logging.error(..., exc_info=True), que gravam a cadeia inteira — gravar apenas a mensagem joga fora tudo o que a linguagem se esforçou para preservar.

Exercício 5

Discuta quando criar uma hierarquia de exceções próprias vale a pena e quando é excesso. Em seguida, explique por que as exceções personalizadas deste artigo herdam de uma base comum ErroAplicacao em vez de herdarem diretamente de Exception.

Ver resposta

✓ Resposta: A pergunta que decide é se alguém vai tratar aquele erro de forma diferente dos demais. Exceção existe para ser capturada seletivamente; se todo erro de um módulo é tratado do mesmo jeito — registrado e devolvido como falha genérica —, uma classe própria por tipo de problema não acrescenta nada além de arquivos. Vale a pena quando cada categoria pede uma reação distinta: uma falha de validação vira resposta 422 e volta para o usuário corrigir, um conflito vira 409, um recurso ausente vira 404, uma falha de infraestrutura vira 500 e dispara alerta. Aí os tipos são o que permite escrever essa decisão como código legível, em vez de inspecionar mensagens de texto — que é o que se acaba fazendo quando os tipos não existem, e é frágil, porque a mensagem muda e o tratamento quebra em silêncio. Vale também quando a exceção precisa carregar dados, como a ErroValidacao deste artigo, que leva o dicionário de campos com problema: sem uma classe própria, essa informação teria de ser espremida dentro de uma string e extraída de volta com expressão regular. Sobre a base comum, ela resolve três coisas. Permite que quem chama capture a família inteira com uma cláusula só — except ErroAplicacao pega qualquer erro previsto do domínio, o que é exatamente o que uma fronteira de API quer fazer no último nível. Distingue, na mesma captura, os erros esperados do sistema dos inesperados que vêm de bibliotecas ou de defeitos de programação: os primeiros viram resposta tratada, os segundos precisam virar 500 e alerta, e sem a base não há como separá-los. E ordena as cláusulas de forma natural, já que Python testa na sequência escrita e casa por herança — as específicas primeiro, a base por último, como no exemplo. O excesso a evitar é criar uma classe por mensagem de erro: quando duas exceções são sempre capturadas juntas e tratadas igual, elas são a mesma exceção com textos diferentes.

Comentários

Mais em Python

Consumindo APIs REST com requests e httpx
Consumindo APIs REST com requests e httpx

Consumindo APIs REST em Python com requests e httpx, do GET simples ao cliente…

Laços de Repetição: for e while
Laços de Repetição: for e while

O for do Python não conta: percorre. A diferença parece cosmética e muda o…

Leitura e Escrita de Arquivos
Leitura e Escrita de Arquivos

Arquivos de texto, CSV, JSON e pickle em Python, com pathlib e o with. E os…