Existe um ponto no crescimento de qualquer projeto Python em que os dicionários começam a doer. Não de vez — vai acontecendo aos poucos. Você passa um dict para uma função, a função passa para outra, e em algum momento ninguém mais sabe ao certo quais chaves estão garantidas, qual é o tipo de cada valor, ou o que acontece se uma chave estiver faltando.
| |
Funciona. Ninguém vai questionar em code review. O problema aparece três meses depois, quando alguém passa um pedido sem a chave "nivel" — ou quando você tenta debugar e o repr do dicionário tem quarenta chaves misturadas.
A solução natural é criar uma estrutura de dados. E aí começa a confusão: Python tem pelo menos quatro formas sérias de fazer isso — dataclass, NamedTuple, attrs e pydantic — cada uma com trade-offs reais que raramente aparecem na documentação oficial.
O ponto de partida: NamedTuple
Se o que você precisa é de um objeto imutável que carrega dados e nada mais, NamedTuple resolve com zero dependências externas:
| |
O repr já funciona. Comparação de igualdade também. Desempacotamento funciona como numa tupla normal:
| |
O lado B: porque NamedTuple herda de tuple, a instância é indexável. Ninguém vai escrever pedido[1] de propósito, mas se isso acontecer num bug sutil, não vai dar erro — vai silenciosamente retornar 199.9. E como é imutável, qualquer lógica que precise alterar um campo precisa criar uma nova instância na mão, sem nenhum método de suporte.
O NamedTuple cobre bem o caso de dados de saída que não mudam: coordenadas, resultados de parsing, rows de consulta SQL que você quer nomear. Quando o objeto precisa de comportamento ou mutabilidade, o NamedTuple começa a trabalhar contra você.
dataclass: o padrão razoável para a maioria dos casos
O @dataclass foi adicionado no Python 3.7 exatamente para o caso que o NamedTuple não cobre bem: objetos mutáveis com dados estruturados que podem ter algum comportamento.
| |
O field(default_factory=list) é o detalhe que pega quem vem direto de classe comum: nunca use um objeto mutável como valor padrão direto. O seguinte parece OK mas cria um único objeto list compartilhado entre todas as instâncias:
| |
O @dataclass detecta isso e levanta ValueError na definição da classe. É um dos poucos casos em que o framework te protege do erro antes de ele acontecer.
Com frozen=True, você tem imutabilidade equivalente ao NamedTuple, mas sem o problema de herdar de tuple:
| |
Tentativas de atribuição levantam FrozenInstanceError. O hash é gerado automaticamente, então a instância pode ser usada como chave de dicionário ou em sets.
O @dataclass tem um problema que só aparece com herança. Subclasses que adicionam campos com valores padrão quebram se a classe pai tem campos sem padrão:
| |
É uma limitação estrutural da forma como @dataclass gera __init__. O attrs resolve isso de forma diferente, mas veremos em seguida.
Validação: o que o @dataclass não faz
O @dataclass não valida os campos. Se você declarar valor: float e passar "duzentos reais", não vai dar erro na criação:
| |
Para validação, a saída do @dataclass é __post_init__:
| |
Funciona, mas escala mal. Com cinco campos validados, o __post_init__ fica maior que o resto da classe. É aqui que o attrs começa a fazer sentido.
attrs: quando o @dataclass não basta
O attrs existe desde 2015 — o @dataclass foi inspirado nele. A diferença fundamental é que o attrs foi projetado especificamente para geração de classes de dados, com validação e conversão como cidadãos de primeira classe.
| |
| |
A validação acontece na construção e levanta TypeError ou ValueError com mensagem clara:
| |
Conversão automática também é possível — útil quando os dados vêm de JSON ou de um formulário e os tipos chegam como string:
| |
| |
O problema de herança que o @dataclass tem não existe no attrs: a ordem dos campos na subclasse é controlada explicitamente pelo decorador, sem depender da ordem de definição.
O custo é verbosidade. Para um objeto simples sem validação, @attrs.define é mais código do que @dataclass. A maioria das bases de código não precisa de attrs em todos os lugares — só nos casos em que a validação na criação vale o overhead de legibilidade.
pydantic: validação na borda, coerção como feature
O pydantic é onipresente em projetos Python modernos — se o projeto usa FastAPI, ele já está instalado. A diferença fundamental em relação ao attrs é filosófica: onde o attrs valida e rejeita, o pydantic valida e converte.
| |
| |
Se você passar id="42" e valor="199.90", o pydantic converte para int e float silenciosamente. Isso é coerção por padrão — e é exatamente o comportamento certo na borda da aplicação, em que os dados chegam de fora (corpo de request HTTP, arquivo .env, JSON de uma API externa) sempre como strings.
A mesma coerção que é uma feature na borda é um risco no domínio. Se Pedido é uma entidade interna e alguém passa uma string num campo numérico, você quer saber — não quer que o framework corrija em silêncio e continue.
| |
A validação customizada com @field_validator é ergonômica — mais que o __post_init__ do @dataclass e comparável ao attrs. O pydantic também gera JSON Schema automaticamente a partir do modelo, o que é útil para documentação de API.
O custo: pydantic é a opção mais pesada das quatro. O import é mais lento, a criação de instâncias tem overhead maior que @dataclass, e a coerção automática pode esconder bugs no domínio interno. Para objetos criados em loops críticos de performance, isso aparece no profiler.
O padrão que funciona bem é usar pydantic na deserialização e converter para @dataclass antes de passar para o domínio:
| |
Misturar os dois não é gambiarra — é separação de responsabilidades.
Guia de decisão
A escolha depende de quatro perguntas:
O objeto é imutável e você não precisa de validação? Use NamedTuple. É simples, zero dependências, e o desempacotamento como tupla às vezes é exatamente o que você quer.
O objeto é mutável, pode ter algum comportamento, e validação não é crítica? Use @dataclass. É a escolha padrão para modelos de domínio, DTOs internos, e qualquer objeto em que a validação, se necessária, pode viver num método separado.
Validação na construção é importante, os dados podem vir de fontes não confiáveis, ou a herança está no caminho? Use attrs. É mais verboso, mas essa verbosidade é explícita — cada campo deixa claro o que é válido.
Os dados vêm de fora da aplicação (HTTP, JSON, env)? Use pydantic na deserialização. A coerção automática é uma feature aqui, não um risco. Converta para @dataclass ou attrs antes de passar para o domínio se performance ou rigor de tipos importar.
A tabela resume:
| Característica | NamedTuple | @dataclass | attrs | pydantic |
|---|---|---|---|---|
| Mutável por padrão | não | sim | sim | sim |
| Validação integrada | não | via __post_init__ | sim, declarativa | sim, declarativa |
| Conversão de tipos | não | não | sim, via converter | sim, automática |
| Herança problemática | não | sim | não | não |
| Dependência externa | não | não | sim | sim |
| Indexável como tupla | sim | não | não | não |
| JSON Schema | não | não | não | sim |
| Performance | alta | alta | alta | menor |
O erro mais comum é usar @dataclass em todo lugar por inércia — incluindo nos casos em que NamedTuple bastaria, naqueles em que attrs seria mais honesto sobre o que o objeto precisa, e nos casos em que pydantic já está no projeto e resolve o problema com menos código.
Se você estiver montando uma arquitetura que usa os quatro em camadas diferentes, ou se tiver um caso concreto em que nenhum dos quatro parece caber bem, o assunto continua em @riverfount@bolha.us.
