Chapter 6, Basic
Request body and POST with Pydantic
The body is the data that the client sends to create or alter something. With a Pydantic model, FastAPI reads the JSON, validates each field, and delivers a ready object to your function.
The request body
In a registration, the form sends name, email, and password. This data goes in the body of the request, in JSON. The verb to create a resource is the POST, and, unlike the GET, it changes something on the server. To describe the body, you declare a class that inherits from BaseModel:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Produto(BaseModel):
nome: str
preco: float
@app.post("/produtos")
def criar_produto(produto: Produto):
return {"mensagem": "Produto criado", "dados": produto}
cliente = TestClient(app)
resposta = cliente.post("/produtos", json={"nome": "Caneta", "preco": 3.5})
print(resposta.status_code, resposta.json())
200 {'mensagem': 'Produto criado', 'dados': {'nome': 'Caneta', 'preco': 3.5}}
The parameter produto: Produto tells FastAPI: "the body of the request is a JSON with this shape". Inside the function, produto is an object, and you access produto.nome and produto.preco.
What happens when the data is wrong
Pydantic checks each field and responds with a 422 that points out the problem. The most common cases are missing field, wrong type, and malformed JSON:
faltando = cliente.post("/produtos", json={"nome": "Caneta"})
print(faltando.status_code, faltando.json()["detail"][0]["loc"], faltando.json()["detail"][0]["type"])
tipo_errado = cliente.post("/produtos", json={"nome": "Caneta", "preco": "abc"})
print(tipo_errado.json()["detail"][0]["msg"])
quebrado = cliente.post("/produtos", content="{nome:", headers={"content-type": "application/json"})
print(quebrado.status_code, quebrado.json()["detail"][0]["type"])
422 ['body', 'preco'] missing
Input should be a valid number, unable to parse string as a number
422 json_invalid
The loc starts with body, which shows that the problem is in the body, followed by the name of the field.
Pydantic converts when it's safe
By default, Pydantic accepts values that can be converted without loss. A price sent as the text "19.9" becomes the decimal 19.9. This is convenient for forms, but when you don't want the conversion, there are strict types:
resposta = cliente.post("/produtos", json={"nome": "Lápis", "preco": "19.9"})
print(resposta.json()["dados"])
{'nome': 'Lápis', 'preco': 19.9}
A very common misunderstanding
You can write a route POST with simple parameters, like def criar(nome: str, idade: int). It works, but these parameters do not go in the body: since they are simple types, FastAPI treats them as query parameters. Only a Pydantic model (or the Body) goes in the body:
@app.post("/pessoas")
def criar_pessoa(nome: str, idade: int):
return {"nome": nome, "idade": idade}
print(cliente.post("/pessoas", params={"nome": "Ana", "idade": 30}).status_code)
resposta = cliente.post("/pessoas", json={"nome": "Ana", "idade": 30})
print(resposta.status_code, resposta.json()["detail"][0]["loc"])
200
422 ['query', 'nome']
The JSON in the body was ignored, and FastAPI complained that it was missing nome and idade in the parameters. Creation data must always go in a model.
Dictionary or model?
Receiving a dict accepts anything, without structure or validation, and you write each check by hand. The model declares the structure once, and gains validation, conversion, and documentation:
dict | Pydantic Model | |
|---|---|---|
| Fields | Free | Declared |
| Validation | Manual | Automatic |
| Documentation | Generic | Describes each field |
| Autocomplete in the editor | No | Yes |
Using the model as an object
The model is a regular class, with useful methods to convert to and from dictionary and JSON:
produto = Produto(nome="Caderno", preco=12)
print(produto)
print(produto.model_dump())
print(produto.model_dump_json())
print(Produto.model_validate({"nome": "Régua", "preco": 4.5}))
nome='Caderno' preco=12.0
{'nome': 'Caderno', 'preco': 12.0}
{"nome":"Caderno","preco":12.0}
nome='Régua' preco=4.5
The preco=12 (integer) became the decimal 12.0, because the field is float.
Exercise 1
Register a user
Create POST /usuarios that receives a model Usuario with nome (text) and idade (integer) and returns {"mensagem": "Usuário criado", "nome": ..., "idade": ...}. Check a valid case and one with age in text.