Chapter 7, Basic
Pydantic models in detail
The model is the contract of your data. Constraints, nested models, and validators keep the input clean before your function starts running.
Restrictions and nested models
The Field imposes rules on each field (size, range, text pattern). And a model can contain another, which is very common in real data, like a user with an address:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
class Endereco(BaseModel):
cidade: str
cep: str = Field(pattern=r"^\d{5}-?\d{3}$")
class Usuario(BaseModel):
nome: str = Field(min_length=2, max_length=50)
idade: int = Field(ge=0, le=150)
email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
endereco: Endereco
tags: list[str] = Field(default_factory=list)
@app.post("/usuarios")
def criar_usuario(usuario: Usuario):
return usuario
cliente = TestClient(app)
valido = {
"nome": "Mohit",
"idade": 25,
"email": "mohit@exemplo.com",
"endereco": {"cidade": "Delhi", "cep": "20010-100"},
}
resposta = cliente.post("/usuarios", json=valido)
print(resposta.status_code, resposta.json())
200 {'nome': 'Mohit', 'idade': 25, 'email': 'mohit@exemplo.com', 'endereco': {'cidade': 'Delhi', 'cep': '20010-100'}, 'tags': []}
The response mirrors the nested structure. When the error is inside the internal model, the loc shows the full path to it:
ruim = {**valido, "endereco": {"cidade": "Delhi", "cep": "123"}}
erro = cliente.post("/usuarios", json=ruim).json()["detail"][0]
print(erro["loc"], erro["type"])
['body', 'endereco', 'cep'] string_pattern_mismatch
Extra fields are ignored unless you prohibit them
A common misconception is to think that Pydantic rejects fields that the model does not declare. By default, it silently ignores the extras. To reject them, configure extra="forbid":
from pydantic import ConfigDict, ValidationError
class Aberto(BaseModel):
nome: str
class Fechado(BaseModel):
model_config = ConfigDict(extra="forbid")
nome: str
dados = {"nome": "Ana", "campo_extra": 1}
print(hasattr(Aberto(**dados), "campo_extra"))
try:
Fechado(**dados)
except ValidationError as erro:
print(erro.errors()[0]["type"], erro.errors()[0]["loc"])
False
extra_forbidden ('campo_extra',)
Rejecting extras helps to catch typos from the client (idad instead of idade), which would otherwise go unnoticed.
Mutable defaults are safe in Pydantic
In a regular class, tags = [] as a default is a classic trap, because the list is shared. Pydantic copies the default for each instance, and the default_factory is the explicit way to say this:
class Post(BaseModel):
tags: list[str] = Field(default_factory=list)
a, b = Post(), Post()
a.tags.append("novo")
print(a.tags, b.tags)
['novo'] []
Validators
When a rule doesn't fit in a Field, write a validator. The field_validator acts on a field, and the model_validator acts on the set, useful for rules that compare fields:
from pydantic import field_validator, model_validator
class Cadastro(BaseModel):
nome: str
senha: str = Field(min_length=8)
confirmar_senha: str
@field_validator("nome")
@classmethod
def limpar_nome(cls, valor: str) -> str:
return valor.strip()
@model_validator(mode="after")
def senhas_iguais(self):
if self.senha != self.confirmar_senha:
raise ValueError("as senhas não conferem")
return self
print(Cadastro(nome=" Ana ", senha="segredo123", confirmar_senha="segredo123").nome)
try:
Cadastro(nome="Ana", senha="segredo123", confirmar_senha="outra-coisa")
except ValidationError as erro:
print(erro.errors()[0]["msg"])
Ana
Value error, as senhas não conferem
Examples in the documentation and model output
An example in the model appears in /docs and makes manual testing faster. And the model_dump accepts options to control what comes out:
class Produto(BaseModel):
model_config = ConfigDict(
json_schema_extra={"examples": [{"nome": "Caneta", "preco": 3.5, "estoque": 100}]}
)
nome: str
preco: float
estoque: int = 0
print(Produto.model_json_schema()["examples"])
produto = Produto(nome="Caneta", preco=3.5)
print(produto.model_dump(exclude_unset=True))
print(produto.model_dump(exclude={"estoque"}))
[{'estoque': 100, 'nome': 'Caneta', 'preco': 3.5}]
{'nome': 'Caneta', 'preco': 3.5}
{'nome': 'Caneta', 'preco': 3.5}
The exclude_unset=True returns only what the client provided, and not the defaults. This detail is the basis of the PATCH, which the next chapter uses.
Input model and output model are different things
It is tempting to use the same model for receiving and returning. I avoid it: the input does not have
id(who creates it is the server), and the output does not have a password. Chapter 10 shows how to separate the two.
Exercise 1
A strict product model
Create ProdutoNovo with nome (minimum 2 characters), preco (greater than zero) and estoque (integer, minimum 0, default 0), rejecting extra fields. Check the valid and invalid cases.