Pydantic v2: o guia para validar dados em Python

Pydantic v2: o guia para validar dados em Python

Se você já escreveu Python por mais de alguns meses, provavelmente já passou por isso: uma API recebe um JSON, um script lê um .env, um consumidor de fila processa uma mensagem — e, em algum momento, alguém esquece de checar se o campo idade realmente veio como número, ou se o email não está vazio. O programa não quebra na hora. Ele quebra três camadas depois, num lugar completamente diferente do código, e você passa a tarde inteira com um debugger tentando entender como um None foi parar onde deveria haver uma string.

Esse é o problema que o Pydantic resolve. E é sobre ele — na sua versão mais recente, a 2 — que vamos falar aqui, com calma, do conceito básico até os recursos que só devs mais experientes costumam explorar.

O problema da validação de dados em Python

Python é uma linguagem de tipagem dinâmica. Isso é ótimo para prototipar rápido, mas tem um preço: nada impede que uma função pensada para receber um int receba uma string, um None ou um objeto qualquer. O interpretador só vai reclamar quando aquele valor errado for efetivamente usado — e aí o erro aparece longe da causa raiz.

A PEP 484 tentou amenizar isso introduzindo type hints. Você pode escrever:

def criar_usuario(nome: str, idade: int) -> None:
    ...

Só que aqui vai um detalhe importante: type hints, por padrão, não validam nada em tempo de execução. Eles são só uma anotação para o seu editor, para ferramentas como mypy, e para quem lê o código depois. Se você chamar criar_usuario("John Smith", "vinte e cinco"), o Python vai deixar passar numa boa — o erro só vai aparecer (se aparecer) quando idade for usado como número mais adiante.

É exatamente essa lacuna — entre "eu documentei o tipo esperado" e "eu garanto que o tipo recebido está correto" — que o Pydantic preenche.

O que é o Pydantic?

O Pydantic é uma biblioteca de validação e parsing de dados que usa as próprias type hints do Python como fonte de verdade. Em vez de você escrever if not isinstance(idade, int): raise ValueError(...) para cada campo, você declara um modelo:

from pydantic import BaseModel

class Usuario(BaseModel):
    nome: str
    idade: int

E o Pydantic cuida do resto: converte tipos compatíveis, rejeita o que não faz sentido, e devolve um erro claro e estruturado quando algo está errado. A ideia central é simples de enunciar, mas poderosa na prática: o type hint deixa de ser apenas documentação e passa a ser um contrato que é verificado em tempo real.

Um pouco de contexto histórico

O Pydantic começou como uma biblioteca relativamente nichada, mas ganhou tração enorme a partir do momento em que o FastAPI — hoje um dos frameworks web mais usados do ecossistema Python — o adotou como peça central para validar requisições, respostas e gerar documentação OpenAPI automaticamente. Quem aprendia FastAPI acabava aprendendo Pydantic junto, e isso acelerou muito sua adoção.

A grande virada, porém, foi a versão 2. O time reescreveu o núcleo de validação em Rust — o chamado pydantic-core — e manteve a API Python por cima. O resultado é uma biblioteca que parece a mesma para quem usa, mas que por baixo dos panos é ordens de magnitude mais rápida que a v1, que era escrita inteiramente em Python puro.

Como o Pydantic funciona por dentro (na prática)

Vale entender o fluxo, porque isso ajuda a debugar quando algo não sai como esperado. Quando você chama Usuario(nome="John Smith", idade="25"), acontece o seguinte:

  1. Parsing: o Pydantic recebe os dados brutos (um dicionário, um JSON, kwargs).
  2. Coerção de tipos: se o valor recebido for compatível, ele é convertido — a string "25" vira o inteiro 25.
  3. Validação: cada campo passa pelas regras declaradas (tipo, restrições do Field, validadores customizados).
  4. Construção do objeto: se tudo passar, uma instância do modelo é criada, imutável em sua estrutura de tipos.
  5. Serialização (quando necessário): o objeto pode ser convertido de volta para dicionário ou JSON, com regras próprias de formatação.

Esse é o pipeline que sustenta praticamente tudo que vem a seguir.

Instalando

A instalação é direta:

pip install pydantic

Alguns validadores especiais dependem de pacotes extras. Para validar e-mails, por exemplo, você precisa de:

pip install "pydantic[email]"

Primeiro exemplo, passo a passo

from pydantic import BaseModel

class Usuario(BaseModel):
    nome: str
    idade: int
    ativo: bool = True

usuario = Usuario(nome="John Smith", idade="30")
print(usuario)
# nome='John Smith' idade=30 ativo=True

Repare em dois detalhes:

  • Passamos idade="30" como string, e o Pydantic converteu automaticamente para int. Essa coerção é intencional — ela reflete cenários reais, como dados vindos de formulários HTML ou query strings, que chegam sempre como texto.
  • O campo ativo tem um valor padrão (True), então não precisa ser informado.

Se enviarmos algo que não pode ser convertido, o erro aparece de forma clara:

Usuario(nome="John Smith", idade="trinta")
# pydantic.ValidationError: 1 validation error for Usuario
# idade
#   Input should be a valid integer, unable to parse string as an integer

Tipos suportados

O Pydantic reconhece nativamente praticamente todo o vocabulário de tipos do Python, incluindo os módulos padrão da biblioteca:

  • Tipos primitivos: str, int, float, bool, Decimal
  • Data e hora: datetime, date, time
  • UUID, Path
  • Rede: IPv4Address, IPv6Address
  • Enum e Literal
  • Estruturas: list, set, tuple, dict
  • Combinações com Optional e Union

Isso significa que você raramente precisa "reinventar" um validador para algo comum — o tipo certo, sozinho, já carrega boa parte da validação.

Exemplos práticos de validação do dia a dia

Vamos ver como isso se traduz em casos reais que aparecem o tempo todo em sistemas brasileiros.

E-mail e URL, usando os tipos especiais do Pydantic:

from pydantic import BaseModel, EmailStr, HttpUrl

class Contato(BaseModel):
    email: EmailStr
    site: HttpUrl

CPF, com validação personalizada (o Pydantic não valida CPF nativamente, mas oferece o gancho certo para isso via field_validator):

from pydantic import BaseModel, field_validator

class Cliente(BaseModel):
    cpf: str

    @field_validator("cpf")
    @classmethod
    def validar_cpf(cls, valor: str) -> str:
        digitos = "".join(filter(str.isdigit, valor))
        if len(digitos) != 11:
            raise ValueError("CPF deve conter 11 dígitos")
        return digitos

Valor monetário com Decimal — e aqui vale um destaque de boas práticas: nunca use float para dinheiro, pois ele introduz erros de arredondamento por natureza. Decimal é a escolha correta:

from decimal import Decimal
from pydantic import BaseModel

class Pedido(BaseModel):
    valor_total: Decimal

Data de nascimento com idade mínima, combinando tipo nativo e validador:

from datetime import date
from pydantic import BaseModel, field_validator

class Cadastro(BaseModel):
    nascimento: date

    @field_validator("nascimento")
    @classmethod
    def maior_de_idade(cls, valor: date) -> date:
        idade = (date.today() - valor).days // 365
        if idade < 18:
            raise ValueError("cadastro permitido apenas para maiores de 18 anos")
        return valor

O padrão que se repete em todos esses exemplos: o tipo cuida do formato básico, e o field_validator cuida da regra de negócio específica.

Refinando campos com Field

Quando o tipo sozinho não é suficiente, o Field permite adicionar restrições, metadados e valores padrão dinâmicos:

from pydantic import BaseModel, Field

class Produto(BaseModel):
    nome: str = Field(..., min_length=3, max_length=100)
    preco: float = Field(..., gt=0, description="Preço em reais, deve ser positivo")
    quantidade: int = Field(default=0, ge=0)
    codigo: str = Field(..., pattern=r"^[A-Z]{3}-\d{4}$")

Aqui gt (greater than), ge (greater or equal), min_length, max_length e pattern (regex) description substituem dezenas de linhas de if que, de outra forma, você teria que escrever manualmente — e, pior, manter sincronizadas em vários lugares do código.

Validadores personalizados: field_validator e model_validator

Nem toda regra é sobre um único campo isolado. Às vezes a validação depende da relação entre dois ou mais campos — por exemplo, garantir que uma "data de fim" seja posterior à "data de início". Para isso existe o model_validator:

from pydantic import BaseModel, model_validator
from datetime import date

class Reserva(BaseModel):
    inicio: date
    fim: date

    @model_validator(mode="after")
    def checar_periodo(self) -> "Reserva":
        if self.fim <= self.inicio:
            raise ValueError("a data de fim deve ser posterior à data de início")
        return self

O parâmetro mode merece atenção: mode="before" roda antes da coerção de tipos (útil para normalizar dados brutos), e mode="after" roda depois, já com os campos convertidos e prontos — como no exemplo acima.

Tratamento de erros

Quando a validação falha, o Pydantic levanta uma ValidationError — mas, diferente de uma exceção genérica, ela carrega informação estruturada, fácil de transformar em resposta de API:

from pydantic import ValidationError

try:
    Usuario(nome="John Smith", idade="trinta")
except ValidationError as erro:
    print(erro.errors())
    # [{'type': 'int_parsing', 'loc': ('idade',), 'msg': 'Input should be a valid integer', ...}]
    print(erro.json())

O método errors() devolve uma lista de dicionários com o campo (loc), o tipo do erro e a mensagem — perfeito para devolver, por exemplo, um 400 Bad Request detalhado em uma API REST.

Para casos onde a mensagem padrão não é boa o suficiente, dá para criar erros personalizados com PydanticCustomError, definindo código e mensagem parametrizada — algo bastante útil quando o mesmo erro precisa ser exibido de forma diferente em português e em outro idioma, por exemplo.

Serialização

O caminho de volta — de objeto Python para dicionário ou JSON — usa model_dump() e model_dump_json():

usuario = Usuario(nome="John Smith", idade=30)

usuario.model_dump()
# {'nome': 'John Smith', 'idade': 30, 'ativo': True}

usuario.model_dump_json()
# '{"nome":"John Smith","idade":30,"ativo":true}'

usuario.model_dump(exclude={"ativo"})
usuario.model_dump(by_alias=True)

Os parâmetros include, exclude, by_alias e exclude_none dão controle fino sobre o que sai — algo essencial quando, por exemplo, você não quer expor um campo de senha na resposta de uma API, mesmo que ele exista no modelo interno.

O que há de novo na v2

Para quem já usava a v1, alguns nomes e conceitos mudaram. Vale destacar os principais:

  • model_validate: substitui o antigo parse_obj, usado para criar um modelo a partir de um dicionário já existente.
  • TypeAdapter: permite validar e serializar tipos que não são um BaseModel — uma list[int], por exemplo — sem precisar criar uma classe só para isso. É um dos recursos mais úteis e menos divulgados da v2.
  • computed_field: cria campos derivados de outros, calculados automaticamente na serialização.
  • field_serializer: customiza como um campo específico é convertido na hora da serialização.
  • RootModel: para modelos cujo "corpo" é o próprio valor, sem um dicionário de campos nomeados por baixo (por exemplo, uma lista pura).
  • ConfigDict: substitui a antiga classe interna Config, centralizando configurações do modelo.
  • Strict Mode: desliga a coerção automática de tipos quando você quer validação estrita — útil em cenários onde receber "25" no lugar de 25 deveria ser um erro, não uma conversão silenciosa.

Performance: por que a v2 é tão mais rápida

A resposta curta é pydantic-core. A v1 fazia toda a validação em Python puro; a v2 delega essa parte para uma biblioteca escrita em Rust, com a API Python funcionando como uma camada fina por cima. Os benchmarks oficiais do projeto mostram ganhos expressivos — em muitos cenários, a v2 valida dados de 5x a 50x mais rápido que a v1, dependendo da complexidade do modelo.

Comparado a alternativas do ecossistema — dataclasses puro (que não valida nada em tempo de execução), attrs, Marshmallow ou msgspec — vale a régua certa: msgspec costuma vencer em performance bruta por ser mais minimalista, mas o Pydantic entrega um equilíbrio raro entre velocidade, ergonomia da API e um ecossistema de integrações (FastAPI, SQLModel, etc.) que dificilmente qualquer alternativa cobre por completo.

Na prática, para a maioria dos sistemas — APIs, ETLs, microsserviços — a performance do Pydantic v2 deixou de ser um fator limitante. Ela só importa de verdade em cenários de altíssimo volume, tipo processamento de milhões de mensagens por segundo.

Onde o Pydantic realmente brilha

  • APIs REST, especialmente com FastAPI, onde ele valida request, response e ainda gera a documentação OpenAPI automaticamente.
  • Configuração de aplicações, lendo e validando variáveis de ambiente (.env) através do pacote complementar pydantic-settings.
  • Pipelines de ETL e Data Science, garantindo que dados vindos de fontes externas (CSV, JSON, APIs de terceiros) estejam no formato esperado antes de seguir para o processamento.
  • Microsserviços com filas (RabbitMQ, Kafka), validando o payload de cada mensagem antes de processá-la — evitando que uma mensagem malformada derrube um consumidor inteiro.
  • Integração entre sistemas, especialmente quando cada lado usa uma convenção de nomenclatura diferente (camelCase de um lado, snake_case do outro) — resolvido de forma elegante com alias no Field.

Pontos positivos

  • Código de validação drasticamente mais enxuto e legível.
  • Documentação oficial excelente, com exemplos claros.
  • Aproveita type hints nativos — nada de sintaxe proprietária esquisita.
  • Performance sólida graças ao pydantic-core.
  • Ecossistema robusto: FastAPI, SQLModel, pydantic-settings, e mais.
  • Facilita manutenção: a regra de validação vive em um único lugar.

Pontos de atenção

  • Curva de aprendizado real quando você começa a usar recursos mais avançados (Annotated, TypeAdapter, validadores condicionais).
  • Migrar um projeto grande da v1 para a v2 dá trabalho — vários nomes de métodos e comportamentos mudaram.
  • Para scripts pequenos e descartáveis, pode ser overhead desnecessário.
  • Adiciona uma dependência externa ao projeto.
  • A coerção automática de tipos pode surpreender quem espera validação estrita por padrão — mas isso tem solução direta com o Strict Mode.

Boas práticas que valem a pena adotar

  • Prefira sempre Decimal para valores monetários, nunca float.
  • Use Strict Mode quando o contexto exigir validação rígida, sem conversões implícitas.
  • Evite colocar regra de negócio complexa dentro do modelo — o Pydantic deve validar formato e integridade, não orquestrar lógica de domínio inteira.
  • Separe validação de dados (Pydantic) de validação de regras de domínio (sua camada de serviço).
  • Use Annotated para reaproveitar restrições entre vários campos ou modelos, seguindo o espírito da PEP 593:
from typing import Annotated
from pydantic import BaseModel, Field

CPF = Annotated[str, Field(min_length=11, max_length=11)]

class Cliente(BaseModel):
    cpf: CPF

class Fornecedor(BaseModel):
    cpf: CPF

Conclusão

O Pydantic vale a pena sempre que dados externos — de uma API, de um arquivo, de uma fila, de uma variável de ambiente — precisam ser transformados em algo confiável antes de circular pelo resto do sistema. Ele transforma validação, que normalmente é um código chato e repetitivo, em algo declarativo, legível e fácil de manter.

Talvez seja exagero para um script de uso único, rodado uma vez e descartado. Mas em qualquer aplicação que vá crescer, receber contribuições de outras pessoas, ou lidar com dados de fontes que você não controla, o investimento em aprender Pydantic v2 se paga rápido — em bugs que simplesmente deixam de existir, porque nunca chegaram a acontecer.