Destino Dev

Blog

Pydantic v2: migrando modelos, validators custom e padrões de performance

Por John Pires · 12 de março de 2026

Problema real que resolve

Contexto prático

Em projetos com FastAPI, scripts de ETL ou microserviços de validação, modelos de dados são o contrato entre camadas. Pydantic é usado para garantir esse contrato. A migração para Pydantic v2 muda a API de validators e introduz novas opções de performance. Desenvolvedores com 1–3 anos de experiência frequentemente enfrentam três problemas práticos: validar formatos recebidos de clientes, serializar de forma consistente e manter latência/uso de memória aceitáveis em lotes grandes.

Problema concreto

Considere um endpoint que recebe payloads em lote (10k–100k objetos) e usa validação antes de persistir. A versão padrão (v1) era simples, mas ao migrar para v2 você precisa reescrever validators, adaptar Config e explorar modos de construção para evitar overheads. O desafio: migrar sem quebrar contratos, manter mensagens de erro úteis e reduzir o custo de validação para itens confiáveis.

Este artigo mostra: como migrar validators do estilo v1 para v2, como escrever validators custom que são seguros e rápidos, e padrões de performance — incluindo uso de model_construct, slots e validação em lote.

Abordagem ingênua vs abordagem otimizada

Abordagem ingênua

Abordagem típica após upgrade: trocar importações e adaptar os nomes superficiais (ex.: .dict() → model_dump()) sem repensar o fluxo. Validação continua sendo feita item a item com model_validate, sem uso de model_construct quando o dado vem de fontes confiáveis. Isso leva a alta latência e consumo de memória em processamento em lote.

Abordagem otimizada

A abordagem otimizada usa três pilares:

  • Reescrever validators com @field_validator e @model_validator — usando modos antes/depois quando necessário
  • Separar caminhos: validar dados externos (model_validate) e construir objetos internos confiáveis com model_construct
  • Ativar slots ou usar model_dump(..., mode="json") conforme necessário, e medir com microbenchmarks

Essa abordagem reduz overhead quando o payload já foi previamente filtrado (por exemplo, uma fila confiável), e mantém validação rígida na borda (entrada HTTP).

Implementação com código real

Exemplo: modelo v1 (para referência)

Antes da migração (v1) você poderia ter algo como:

from pydantic import BaseModel, validator
from typing import List

class Address(BaseModel):
    street: str
    zipcode: str

class UserV1(BaseModel):
    id: int
    name: str
    email: str
    age: int | None
    addresses: List[Address]

    @validator('email')
    def email_must_have_at(cls, v):
        if '@' not in v:
            raise ValueError('invalid email')
        return v.lower()

Esse código funcionava, mas precisa ser reescrito para Pydantic v2 — validators mudaram de API e você quer aproveitar model_construct e slots.

Modelo migrado para Pydantic v2 com validators custom

Aqui está a versão migrada e melhorada, com exemplos de validators 'before' (pré-processamento) e 'after' (pós-processamento), e utilização de ConfigDict para opções.

from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
from typing import List

class Address(BaseModel):
    model_config = ConfigDict(extra='forbid', frozen=False)
    street: str
    zipcode: str

class User(BaseModel):
    model_config = ConfigDict(extra='forbid', slots=True)

    id: int
    name: str = Field(min_length=1)
    email: str
    age: int | None = None
    addresses: List[Address] = []

    # Validação feita antes da coerção de tipos (ex.: corrigir strings JSON)
    @field_validator('email', mode='before')
    def normalize_email_before(cls, v):
        # aceita bytes ou str
        if isinstance(v, bytes):
            v = v.decode('utf-8')
        return v

    # Validação principal do campo (depois da coerção)
    @field_validator('email')
    def check_email(cls, v):
        if '@' not in v:
            raise ValueError('invalid email')
        return v.lower()

    # Validação de várias fields juntas
    @model_validator(mode='after')
    def cross_field_checks(self):
        if self.age is not None and self.age < 0:
            raise ValueError('age must be non-negative')
        return self

O que esse bloco faz: define validators separados por fase, evita decodificação repetida e usa slots para reduzir uso de memória por instância. A presença de model_validator permite checks que dependem de múltiplos campos.

Serialização custom com @field_serializer

Às vezes precisamos serializar um campo de forma específica (ex.: datetimes em ISO). Em v2 usamos @field_serializer.

from datetime import datetime
from pydantic import field_serializer

class Event(BaseModel):
    model_config = ConfigDict(extra='forbid')
    name: str
    ts: datetime

    @field_serializer('ts')
    def ts_iso(self, v: datetime, _info):
        return v.isoformat() + 'Z'

# Uso
evt = Event.model_validate({'name': 'deploy', 'ts': '2023-01-01T12:00:00'})
print(evt.model_dump())  # ts aparece em ISO com 'Z'

Após esse bloco: event.model_dump() fornecerá o timestamp serializado como string ISO, útil para APIs ou logs.

Testes e validação

Testes unitários para validators

Testes são cruciais ao migrar. Abaixo, exemplos de testes com pytest que cobrem caminhos válidos e inválidos, incluindo uso de model_construct para cenários internos onde a validação não é necessária.

import pytest
from pydantic import ValidationError

def test_email_valid():
    u = User.model_validate({'id': 1, 'name': 'Ana', 'email': 'ANA@EX.com'})
    assert u.email == 'ana@ex.com'

def test_email_invalid():
    with pytest.raises(ValidationError):
        User.model_validate({'id': 2, 'name': 'Bob', 'email': 'no-at'})

def test_construct_fast_path():
    # quando dados já são validados por upstream
    u = User.model_construct(id=3, name='Fast', email='fast@test')
    assert u.name == 'Fast'

Esses testes garantem que tanto o caminho de validação (model_validate) quanto o caminho confiável (model_construct) se comportem como esperado.

Benchmark simples

Microbenchmark para comparar model_validate vs model_construct com um payload de 10000 itens.

import time

def make_payload(n):
    return [{'id': i, 'name': f'user{i}', 'email': f'u{i}@example.com'} for i in range(n)]

N = 10000
payload = make_payload(N)

# Validated path
t0 = time.perf_counter()
validated = [User.model_validate(item) for item in payload]
t1 = time.perf_counter()

# Construct path (sem validação)
t2 = time.perf_counter()
constructed = [User.model_construct(**item) for item in payload]
t3 = time.perf_counter()

print('validate:', t1 - t0, 'construct:', t3 - t2)

Resultado esperado: model_construct é significativamente mais rápido. Use esse padrão quando os dados são confiáveis (ex.: provenientes de uma fila interna onde já foram validados).

Considerações de produção

Erros comuns e armadilhas

Erros frequentes na migração incluem:

  • Não atualizar validators para @field_validator / @model_validator e manter assinaturas antigas;
  • Usar model_construct em dados não confiáveis — isso ignora validação e pode introduzir inconsistências;
  • Esquecer de ajustar model_config (ex.: extra='ignore' vs 'forbid'), o que muda o comportamento em produção;
  • Ignorar impacto das mudanças de serialização (.dict() -> model_dump) em clientes consumindo API.

Dicas práticas

Use model_validate na borda (HTTP/CLI) e model_construct internamente quando a origem dos dados for garantida. Meça antes de otimizar — a micro-otimização sem dados pode quebrar segurança.

Outras dicas:

  1. Ative slots=True em modelos de alto volume para reduzir overhead por objeto.
  2. Use Field(..., max_length=...) e tipos estritos para delegar validação ao core e reduzir custo de validators custom.
  3. Prefira validators com mode='before' para normalização barata (ex.: decodificação bytes).

Erros comuns, armadilhas e dicas práticas

Perda de mensagens de erro legíveis

Migrações podem alterar a forma como mensagens de ValidationError são compostas. Em alguns casos, você deve manter testes que assertam substring das mensagens, não a string completa, para evitar falsos negativos em mudanças não críticas de formatação.

Aliases e nomes de campo

Se você usava alias em v1, verifique populate_by_name e aliases no model_config. Em v2, use ConfigDict(populate_by_name=True) se quiser aceitar nomes do Python e aliases do cliente.

Performance vs segurança

Uma armadilha comum é usar model_construct sem logs/monitoração. Se optar por essa via, registre contadores para quantos objetos foram construídos pelo caminho "sem validação" e ocasionalmente faça sampling para validar integridade dos dados.

Conclusão prática

Migração para Pydantic v2 exige reescrever validators, reaprender alguns métodos (model_validate, model_construct, model_dump) e entender trade-offs. O padrão que funciona na prática:

  • Valide na borda: HTTP/CLI usando model_validate
  • Normalize com field_validator(mode='before') e faça checks finais com model_validator(mode='after')
  • Para performance em lote, use model_construct e slots quando os dados forem confiáveis

Implementar esses padrões reduz latência em cargas altas e mantém segurança na entrada. Sempre combine microbenchmarks com métricas de produção (latência, erro, memory RSS) antes de mudar o caminho de construção para model_construct.

Checklist rápido para migrar um projeto:

  1. Listar modelos e validators atuais (v1) e priorizar os de maior custo/uso
  2. Reescrever validators com @field_validator/@model_validator
  3. Executar testes automatizados e comparar contratos de API (model_dump vs .dict)
  4. Adicionar benchmarks e métricas; habilitar model_construct apenas com monitoração

Com essas práticas, migração e otimização ficam previsíveis e controláveis.

Apêndice: checklist de migração e exemplos rápidos

Checklist

  • Converter @validator para @field_validator com assinatura compatível
  • Confirmar model_dump usages e ajustar consumidores
  • Adicionar model_config = ConfigDict(slots=True/extra='forbid') quando necessário
  • Introduzir model_construct com sampling e métricas

Exemplo rápido: resumo do fluxo recomendado

Código abaixo resume o fluxo recomendado em um handler HTTP: validar req → processar → construir objetos internos sem validação quando possível.

def handle_batch(payloads):
    # borda: valida cada item rigorosamente
    validated = [User.model_validate(p) for p in payloads]

    # downstream: construir registros internos rapidamente
    internal = [InternalUser.model_construct(**u.model_dump()) for u in validated]

    # persistir ou processar
    save_batch(internal)

Depois desse bloco: você tem um fluxo seguro e também pontos onde é possível economizar CPU sem comprometer a validação de entrada.

Se precisar, posso gerar um diff automatizado que converta validators v1 para v2 em um código-base específico — envie um arquivo ou exemplos de validators para eu transformar.

Assuntos: Python, pydantic, Performance, validation

Leia também