Skip to content

Request Body & Pydantic Models

For POST, PUT, PATCH requests, the client sends data in the body (usually JSON).

To accept and validate it, define a Pydantic model.

A first POST endpoint

# main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    name: str
    age: int
    email: str

@app.post("/users")
def create_user(user: User):
    return {"created": user, "message": f"Welcome, {user.name}!"}

Run it. Then test in the docs:

  1. Open http://127.0.0.1:8000/docs
  2. Expand POST /users
  3. Click Try it out, paste this body, Execute:
{
  "name": "Alice",
  "age": 25,
  "email": "alice@example.com"
}

Response: {"created": {...}, "message": "Welcome, Alice!"}

How it executes

When the client sends POST /users with a JSON body:

  1. uvicorn receives the bytes.
  2. FastAPI sees the parameter user: User.
  3. It parses the body as JSON → a Python dict.
  4. It validates the dict against the User model (right types, all required fields present).
  5. If valid → builds a User instance and passes it to your function.
  6. If invalid → returns HTTP 422 with a clear list of errors.

You only handle valid data — invalid requests are rejected before your code runs.

Pydantic validation in action

Send bad data:

{
  "name": "Alice",
  "age": "twenty-five",
  "email": "alice@example.com"
}

Response:

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["body", "age"],
      "msg": "Input should be a valid integer..."
    }
  ]
}

Send a missing field:

{ "name": "Alice", "age": 25 }

Response:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "email"],
      "msg": "Field required"
    }
  ]
}

No code you write to handle that — FastAPI + Pydantic do it for free.

Optional fields with defaults

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    email: str
    role: str = "user"            # default — optional in the request
    bio: str | None = None        # default None — also optional

Field constraints

For more rules — length, range, regex — use Field:

from pydantic import BaseModel, Field

class User(BaseModel):
    name: str   = Field(..., min_length=2, max_length=50)
    age: int    = Field(..., ge=0, le=150)
    email: str  = Field(..., pattern=r"^\S+@\S+\.\S+$")
    bio: str    = Field("", max_length=500)

... is "no default — required".

Nested models

Models can contain other models:

class Address(BaseModel):
    street: str
    city: str
    pin: str

class User(BaseModel):
    name: str
    age: int
    address: Address           # nested
    hobbies: list[str] = []    # list of strings

@app.post("/users")
def create_user(user: User):
    return {"city": user.address.city, "hobby_count": len(user.hobbies)}

Client sends:

{
  "name": "Alice",
  "age": 25,
  "address": {
    "street": "MG Road",
    "city": "Bangalore",
    "pin": "560001"
  },
  "hobbies": ["coding", "chess", "music"]
}

Lists of items

class Order(BaseModel):
    user_id: int
    items: list[str]
    quantity: list[int]

@app.post("/orders")
def create_order(order: Order):
    if len(order.items) != len(order.quantity):
        return {"error": "items and quantity length must match"}
    return {"order": order}

Body + Path + Query together

You can mix all three:

@app.put("/users/{user_id}")
def update_user(
    user_id: int,                 # path
    user: User,                   # body — Pydantic model
    notify: bool = False,         # query
):
    return {
        "user_id": user_id,
        "updated_data": user,
        "notify": notify,
    }

PUT /users/42?notify=true with a JSON body → all three are populated.

Returning the model

Pydantic models can be returned too — FastAPI converts them to JSON:

@app.post("/users")
def create_user(user: User) -> User:
    # ... save to database ...
    return user

How to test without the Swagger UI

Using curl from the terminal:

curl -X POST http://127.0.0.1:8000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","age":25,"email":"alice@example.com"}'

Using Python:

import requests

response = requests.post(
    "http://127.0.0.1:8000/users",
    json={"name": "Alice", "age": 25, "email": "alice@example.com"},
)
print(response.status_code)
print(response.json())

Using httpie (nicer CLI):

pip install httpie
http POST http://127.0.0.1:8000/users name=Alice age:=25 email=alice@example.com

Common pitfalls

  • Forgetting BaseModel — your class won't be a Pydantic model. Always class X(BaseModel):.
  • Wrong Content-Type — clients must send Content-Type: application/json. Most HTTP libraries do this automatically.
  • Defining body params as separate kwargsdef x(a: int, b: int): treats them as query params. To make them body params, group into a Pydantic model.
  • int field receives "123" — Pydantic v2 is strict: "123" (string) does NOT auto-coerce to int. The client must send a real number.

What's next

Practice

What does this print?

Expected: Alice

from pydantic import BaseModel
class User(BaseModel):
    name: str
    age: int
u = User(name="Alice", age=30)
print(u.name)

Add a Pydantic validation to require age >= 0

Expected: True

from pydantic import BaseModel
class User(BaseModel):
    name: str
    age: int                # bug: no constraint — negative ages accepted
try:
    User(name="Bob", age=-5)
    ok = True
except Exception:
    ok = False
print(ok)                   # we expect it to accept, but should reject

Quiz — Quick check

What you remember

Q1. What's the easiest way to validate a JSON request body in FastAPI?

  • Define a Pydantic BaseModel and use it as a function arg type
  • Manually parse JSON in the handler
  • Use a JSON schema library
  • Disable validation

Why: FastAPI sees the Pydantic model in the signature and: (1) parses the JSON body, (2) validates against the model, (3) returns 422 with details on failure, (4) gives you a typed object in the handler.

Q2. What happens if the request body has an extra field not in the model?

  • By default, the extra field is ignored (silently accepted)
  • Returns 422
  • Crashes the server
  • Adds it to the model

Why: Pydantic's default is to ignore unknown fields. To reject them, set model_config = ConfigDict(extra="forbid") in your model. Useful for strict APIs.

Q3. How do you add a constraint like "age must be 0–120"?

  • age: int = Field(ge=0, le=120)
  • age: int(0, 120)
  • In a separate validator
  • Custom middleware

Why: Field(ge=N, le=N) (or gt/lt for strict) declares constraints inline. Pydantic enforces them and includes them in OpenAPI docs — users see the valid range in Swagger UI.

Common doubts

Should I use BaseModel for query params too?

Use Annotated[QueryModel, Query()] (FastAPI 0.95+). Lets you group related query params into one Pydantic model — cleaner for endpoints with many filters.

What's the difference between Pydantic v1 and v2?

v2 (current) is a major rewrite — faster, stricter type coercion, slightly different API (@validator@field_validator, ConfigConfigDict). FastAPI ≥0.100 uses v2 by default. Migration is mostly mechanical; check the Pydantic migration guide.

How do I add custom validation logic?

@field_validator("field_name") decorator: define a classmethod that takes the value, validates it, and either returns the value or raises ValueError. For cross-field validation, use @model_validator(mode="after").

Response Models & Status Codes