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
- Exceções embutidas — https://docs.python.org/3/library/exceptions.html
- Tratamento de erros — tutorial oficial — https://docs.python.org/3/tutorial/errors.html
- PEP 3134 — encadeamento de exceções — https://peps.python.org/pep-3134/
- contextlib — utilitários para gerenciadores de contexto — https://docs.python.org/3/library/contextlib.html
- BEAZLEY, David; JONES, Brian K. Python Cookbook. 3. ed. O'Reilly Media, 2013. Cap. 14 — tratamento avançado de exceções e logging.
- RAMALHO, Luciano. Fluent Python. 2. ed. O'Reilly Media, 2022. Cap. 18 — gerenciadores de contexto e blocos with.
- HUNT, John. Advanced Guide to Python 3 Programming. Springer, 2019. Cap. 6 — exceções e debugging em aplicações reais.
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.