Skip to content

    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:

    basico/cap07_pydantic_detalhe.pylinhas 10 a 43
    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())
    
    Output
    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:

    basico/cap07_pydantic_detalhe.pylinhas 45 a 47
    ruim = {**valido, "endereco": {"cidade": "Delhi", "cep": "123"}}
    erro = cliente.post("/usuarios", json=ruim).json()["detail"][0]
    print(erro["loc"], erro["type"])
    
    Output
    ['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":

    basico/cap07_pydantic_detalhe.pylinhas 52 a 69
    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"])
    
    Output
    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:

    basico/cap07_pydantic_detalhe.pylinhas 74 a 80
    class Post(BaseModel):
        tags: list[str] = Field(default_factory=list)
    
    
    a, b = Post(), Post()
    a.tags.append("novo")
    print(a.tags, b.tags)
    
    Output
    ['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:

    basico/cap07_pydantic_detalhe.pylinhas 85 a 109
    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"])
    
    Output
    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:

    basico/cap07_pydantic_detalhe.pylinhas 114 a 126
    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"}))
    
    Output
    [{'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.