Pydantic v2: validar dados de entrada em Python

1. Visão geral

Dados de requisições HTTP, arquivos JSON ou message queues frequentemente têm tipos errados, campos faltando ou valores sem sentido. Se você verificar tudo com if/else espalhado pelo código, ele rapidamente fica confuso. Pydantic v2 permite descrever a “forma” dos dados com type hints e depois fazer parse automático, coerção de tipo e reportar erros detalhados.

Este artigo passa pelas principais ferramentas do Pydantic v2: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. No final, vemos como escrever testes com pytest. Depois de ler, você será capaz de construir seus próprios modelos validando entradas de forma segura para sua aplicação.

2. Instalação

Este artigo usa Python 3.11+ e Pydantic 2.x:

pip install "pydantic>=2.7" pytest

Se você precisar do tipo EmailStr, instale também a extensão:

pip install "pydantic[email]"

3. Modelo básico com BaseModel

3.1. Declaração e coerção de tipo

Um modelo é uma classe que herda de BaseModel. Cada atributo com type hint é um field:

from pydantic import BaseModel

class Customer(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>

Você passa id="42" como string, mas Pydantic converte automaticamente para int. Este é o modo lax, o padrão: Pydantic faz coerção de tipo quando a conversão é razoável. O field is_active tem um valor padrão, então não é obrigatório passar.

3.2. Tratando ValidationError

Quando os dados estão errados, Pydantic lança ValidationError e coleta todos os erros de uma vez, não parando no primeiro:

from pydantic import ValidationError

try:
    Customer(id="abc", name="An")
except ValidationError as e:
    print(e.error_count())
    for err in e.errors():
        print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required

Cada erro é um dict contendo loc (caminho do field), type (código de erro estável) e msg (mensagem legível). Ao retornar erros para o cliente, você deve confiar em loc e type. O conteúdo de msg pode mudar entre versões.

4. Restrições de valor com Field e Annotated

Type hints só verificam tipo. Para limitar valores, você usa Field:

from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field

Quantity = Annotated[int, Field(gt=0, le=100)]

class OrderItem(BaseModel):
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    quantity: Quantity
    unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)

item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')

Explicação:

  • pattern força sku a corresponder à regex, exemplo ABC-1234.
  • Quantity é um tipo reutilizável graças a Annotated. Todo field declarado como Quantity deve estar no intervalo (0, 100].
  • unit_price usa Decimal para evitar erros de ponto flutuante. O valor deve ser positivo e ter no máximo 2 casas decimais.

Tente passar dados ruins:

try:
    OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than

Outras restrições úteis: min_length/max_length para strings e listas, ge/lt para números, multiple_of para múltiplos.

5. Validadores personalizados com field_validator

Quando restrições são mais complexas que regex ou faixas, você escreve um validador customizado:

from pydantic import BaseModel, Field, field_validator

class Contact(BaseModel):
    name: str
    phone: str = Field(pattern=r"^0\d{9}$")

    @field_validator("name")
    @classmethod
    def clean_name(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("name não pode estar vazio")
        return v.title()

    @field_validator("phone", mode="before")
    @classmethod
    def normalize_phone(cls, v):
        if isinstance(v, str):
            v = v.replace(" ", "").replace(".", "")
            if v.startswith("+84"):
                v = "0" + v[3:]
        return v

contact = Contact(name="  nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'

Há dois modos importantes:

  • mode="after" (padrão), como em clean_name: executa depois que Pydantic validou o tipo, então v é garantidamente uma str.
  • mode="before", como em normalize_phone: executa antes da validação de tipo e pattern. v pode ser qualquer coisa, então você precisa verificar isinstance. Aqui normalizamos +84 912 345 678 para 0912345678, e então pattern é aplicado.

Validadores devem retornar um valor. O valor retornado é o que fica salvo no field. Para reportar erro, você lança ValueError ou AssertionError. Pydantic o embrulha em um erro value_error, com a mensagem "Value error, name não pode estar vazio".

Nota: A ordem dos decoradores importa muito. @field_validator deve estar acima de @classmethod. Se inverter, você terá um erro confuso ao definir a classe.

6. Validando múltiplos campos simultaneamente com model_validator

Algumas regras envolvem múltiplos fields, por exemplo, a data de check-out deve ser depois da data de check-in. Neste caso, você precisa de model_validator:

from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator

class Booking(BaseModel):
    room_id: int
    check_in: date
    check_out: date

    @model_validator(mode="after")
    def check_dates(self) -> Self:
        if self.check_out <= self.check_in:
            raise ValueError("check_out deve ser após check_in")
        return self

try:
    Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
    err = e.errors()[0]
    print(err["loc"], err["msg"])
() Value error, check_out deve ser após check_in

Com mode="after", o validador recebe uma instância já validada em cada field. Você acessa self.check_in como um atributo normal e deve fazer return self. O erro tem loc vazio () porque pertence ao modelo inteiro, não a um field específico. A string "2026-10-20" também é automaticamente parseada para date.

7. Configurando modelo com ConfigDict

model_config controla o comportamento geral do modelo. Esta é a configuração comum para dados sensíveis como pagamentos:

from pydantic import BaseModel, ConfigDict, ValidationError

class PaymentRequest(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        strict=True,
        str_strip_whitespace=True,
        frozen=True,
    )

    order_id: int
    amount: int
    currency: str

try:
    PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden

O significado de cada opção:

  • extra="forbid": rejeita fields desconhecidos como coupon. O padrão é "ignore", ou seja, Pydantic silenciosamente ignora fields extras. Isso pode esconder erros de digitação no lado do cliente.
  • strict=True: desativa coerção. "1" não será mais aceito como int.
  • str_strip_whitespace=True: remove espaços em branco das duas pontas automaticamente.
  • frozen=True: a instância é imutável. Atribuir payment.amount = 0 lançará erro.

Se você só quer strict para alguns fields, use Field(strict=True) ou tipos como StrictInt, StrictStr.

Outra opção importante é validate_assignment=True. Por padrão, Pydantic não valida quando você reatribui um atributo após criar o objeto. Ativar esta opção mantém o modelo sempre válido.

8. Validando a partir de dict, JSON e tipos arbitrários

Na prática, dados geralmente chegam como dict ou string JSON. Pydantic v2 oferece dois class methods:

raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)

raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)

print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}

Se a entrada é uma string JSON, você deve usar model_validate_json em vez de json.loads seguido de model_validate. Este é mais rápido porque o núcleo Rust do Pydantic faz o parse e validação em uma única passagem. No sentido inverso, model_dump() retorna um dict, enquanto model_dump_json() retorna uma string JSON.

Quando você precisa validar um tipo que não é um modelo, como list[OrderItem], use TypeAdapter:

from pydantic import TypeAdapter

items_adapter = TypeAdapter(list[OrderItem])

items = items_adapter.validate_python([
    {"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
    {"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50

Você deve criar o TypeAdapter uma vez no nível do módulo e reutilizá-lo. A inicialização tem custo de construção de schema, não deve ser repetida a cada requisição.

9. Escrevendo testes com pytest

Validação é lógica de negócio e deve ser testada. Suponha que os modelos acima estejam em um arquivo models.py:

from decimal import Decimal

import pytest
from pydantic import ValidationError

from models import Booking, Contact, OrderItem


def test_order_item_coerces_valid_input():
    item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")

    assert item.quantity == 3
    assert item.unit_price == Decimal("19.90")


@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
    with pytest.raises(ValidationError) as exc_info:
        OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")

    error = exc_info.value.errors()[0]
    assert error["loc"] == ("quantity",)
    assert error["type"] in {"greater_than", "less_than_equal"}


def test_contact_normalizes_phone():
    contact = Contact(name="an", phone="+84 912.345.678")

    assert contact.phone == "0912345678"
    assert contact.name == "An"


def test_booking_rejects_checkout_before_checkin():
    with pytest.raises(ValidationError, match="check_out deve ser após check_in"):
        Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")

Execute os testes:

pytest -q
.....                                                    [100%]
5 passed

Note como os testes fazem assert em loc e type, não comparando o msg literalmente. Assim, testes não quebram quando Pydantic muda a redação da mensagem em uma nova versão. parametrize ajuda você a cobrir ambos os limites 0 e 101 com uma única função.

10. Erros comuns e quando não usar

Usar a API errada da v1. Muita documentação antiga ainda usa sintaxe da v1. Aqui está uma tabela rápida de correspondência:

v1                         v2
@validator                 @field_validator
@root_validator            @model_validator
class Config: ...          model_config = ConfigDict(...)
.dict() / .json()          .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw()  .model_validate() / .model_validate_json()

O código antigo ainda funciona mas emite DeprecationWarning. Você deve migrar completamente para a API nova.

Surpresas com modo lax. Modo lax aceita "yes" como True e 1.0 como 1. Com dados financeiros ou IDs, este comportamento pode ser indesejado. Quando é o caso, ative strict.

Esquecer return no validador. Um field_validator sem return deixa o field receber None. Um model_validator(mode="after") faltando return self também causa erro.

Pular validação sem saber. model_construct() cria uma instância sem passar por validação. Este método é apenas para dados em que você confia absolutamente, por exemplo, leitura direto do seu banco de dados.

Nota: Não use Pydantic para todo objeto interno. Em loops quentes processando milhões de objetos já limpos, o custo de validação é desnecessário. Nesses casos, dataclass ou NamedTuple são mais apropriados. Coloque Pydantic na borda do seu sistema: APIs, arquivos de configuração, mensagens de outros sistemas.

11. Conclusão

Pydantic v2 transforma validação de dados de entrada em declaração em vez de imperativo. Você descreve tipos com type hints, adiciona restrições com Field e Annotated, escreve regras customizadas com field_validator e model_validator. Comportamento geral como proibir fields extras ou desativar coerção é controlado via ConfigDict.

Três pontos para lembrar: use model_validate_json para entrada JSON, ative extra="forbid" com dados importantes, e teste validação baseando-se em loc/type em vez de conteúdo de mensagem. Quando aplicado corretamente, na borda do seu sistema, Pydantic garante que o núcleo da sua aplicação só trabalha com dados limpos e bem-tipados.

pythonpydanticvalidationpytest