Request Bodies with Pydantic

Published by Programming Line in FastAPI. Last updated .

Request bodies are how clients send structured data to an API: a new product, an account profile, a search filter, a checkout request, or any other object that does not fit naturally into a path or query parameter. In FastAPI, Pydantic models are the main tool for describing those bodies. The outcome is practical: your endpoint receives a normal Python object whose fields have already been parsed, converted, and checked, while clients receive consistent validation errors when their JSON does not match the shape you declared.

This lesson belongs in Routing and Schemas because a route is more than a URL and function. A route also declares what data it accepts, how that data is validated, and what OpenAPI schema clients can use to call it correctly. Request body models are where FastAPI, Python type hints, Pydantic validation, and generated API documentation meet.

How FastAPI Reads a Body

When a request reaches a FastAPI route, FastAPI inspects the endpoint function signature. Simple scalar parameters such as limit: int are usually treated as query parameters unless told otherwise. Parameters whose type is a Pydantic model are treated as request bodies. FastAPI asks Starlette to read the HTTP body bytes, decodes JSON according to the request content type, and passes the decoded object into Pydantic for model construction.

Pydantic does not merely check that a dictionary exists. It walks the declared model fields, applies type parsing, runs field constraints, builds nested models, handles defaults, and records all validation problems with locations. If validation succeeds, your endpoint receives an instance of your model. If validation fails, FastAPI returns a 422 response containing details such as the failing field path, error type, and message. This happens before your route function body runs, which keeps invalid input away from application logic.

The key terms are precise. A BaseModel is a Pydantic class describing fields. A field annotation such as price: float declares the expected Python type after parsing. Field adds metadata and constraints such as length, numeric bounds, examples, descriptions, or aliases. Nested models describe structured JSON objects inside other objects. Lists describe repeated values. Optional fields, default values, and required fields determine whether a client must send a key.

Model Anatomy

A request body model normally contains annotated attributes. A field with no default is required. A field with a default is optional from the client’s perspective because Pydantic can fill in the missing value. Constraints should express rules that are purely about data shape: string length, minimum numbers, allowed literal values, and similar checks. Rules that require a database lookup or business decision usually belong in application code after validation.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=80)
    price: float = Field(gt=0)
    in_stock: bool = True

@app.post("/items")
async def create_item(item: ItemCreate):
    return {
        "name": item.name,
        "price": item.price,
        "in_stock": item.in_stock,
    }

This first example declares a compact body. A valid request such as {"name":"Notebook","price":4.5} produces {"name":"Notebook","price":4.5,"in_stock":true} because in_stock defaults to True. A request with {"name":"","price":4.5} fails because the name violates min_length. A request with {"name":"Notebook","price":0} fails because gt=0 means greater than zero, not greater than or equal to zero.

Nested Bodies

Real request bodies often contain nested structures. Pydantic handles this by allowing one model to reference another. FastAPI uses the nested model graph to validate incoming JSON and generate nested OpenAPI schemas. The endpoint still receives one Python object at the top, but attributes below it are also typed model instances.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class Image(BaseModel):
    url: str = Field(min_length=8)
    alt_text: str = Field(max_length=140)

class ProductCreate(BaseModel):
    sku: str = Field(pattern=r"^[A-Z0-9-]{4,20}$")
    title: str = Field(min_length=3, max_length=120)
    price: float = Field(gt=0)
    images: list[Image] = Field(default_factory=list, max_length=5)

@app.post("/products", status_code=201)
async def create_product(product: ProductCreate):
    return {
        "sku": product.sku,
        "title": product.title,
        "image_count": len(product.images),
    }

For a request with one valid image, the response deterministically includes the SKU, title, and image_count of 1. If the client sends an image object missing alt_text, the validation error location points into the nested list, commonly along a path like body, images, item index, and field name. That location is the main reason nested Pydantic models are more useful than manually indexing dictionaries: they tell the client exactly where the payload stopped matching the schema.

The default_factory=list detail matters. Mutable defaults should not be shared between model instances. A factory creates a fresh list for each parsed product. The pattern constraint keeps SKU syntax close to the data definition, while inventory rules, duplicate SKU checks, or permissions would be handled outside the Pydantic model because they require context beyond the request body itself.

Combining Body Data With Route Data

A route can combine a path parameter, query parameters, and a body model. FastAPI decides the source of each parameter from its annotation and default helpers. This lets the URL identify the resource while the body describes the intended replacement or update.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

class ProductUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=3, max_length=120)
    price: float | None = Field(default=None, gt=0)
    discontinued: bool = False

@app.patch("/products/{sku}")
async def update_product(sku: str, update: ProductUpdate):
    changes = update.model_dump(exclude_unset=True)
    if not changes:
        raise HTTPException(status_code=400, detail="No changes supplied")
    return {"sku": sku, "changes": changes}

This third example is progressive because it distinguishes omitted fields from fields set to null. model_dump(exclude_unset=True) returns only keys the client actually sent. A request to /products/ABC-123 with {"price":12.99} returns {"sku":"ABC-123","changes":{"price":12.99}}. A request with {} parses successfully as a model, but application logic rejects it because an empty patch has no effect. A request with {"price":-1} never reaches that logic because Pydantic rejects the numeric constraint first.

Design Choices

Use one model when the create, update, and response shapes are truly the same. Split them when required fields, writable fields, or output-only fields differ. For example, clients may provide title and price when creating a product, while the API response includes id, created_at, or computed availability. Separate models prevent clients from sending fields they should not control and make generated documentation clearer.

Prefer precise field types over a loose dictionary. dict is appropriate for intentionally arbitrary metadata, but it gives up field-level documentation and validation. Use nested models for known objects, lists for repeated known items, and constrained fields for local rules. Keep Pydantic models close to API data, not database tables. They may look similar, but an HTTP schema and a persistence schema change for different reasons.

Coercion is another trade-off. Pydantic can often parse strings into numbers or booleans, which is convenient for clients but can hide messy input. For public APIs, document accepted forms and add stricter validation where ambiguous data would be dangerous. For internal APIs, convenience may be acceptable if producers and consumers are controlled together.

Failure Modes and Troubleshooting

A common symptom is a 422 response before the endpoint logs anything from inside the function. The cause is usually body validation failure. Reproduce the request, inspect the response detail, and follow the loc path to the bad field. Correct the client payload or adjust the model if the schema was too strict.

Another symptom is a 422 saying the body is missing even though the client sent data. Check that the request uses JSON, sets Content-Type: application/json, and sends an object matching the model shape. Form data and JSON bodies are different inputs. If you intended to accept form fields, model-based JSON parsing is the wrong mechanism.

A subtle failure appears when update endpoints cannot tell whether a client omitted a field or explicitly sent null. The cause is treating every defaulted field as equivalent. Use model_dump(exclude_unset=True) when patch semantics depend on supplied keys, and decide separately whether null should clear a value or be rejected.

Nested validation can also surprise teams. A top-level list length may pass while an item inside the list fails. The diagnostic path is to read the full error location instead of stopping at the first field name. Correct the exact nested object, or move the constraint if the rule really belongs to the collection rather than each item.

Security, Performance, and Reliability

Pydantic validation is a boundary check, not a complete security layer. It prevents malformed shapes from entering endpoint logic, but it does not authenticate the caller, authorize the action, sanitize output for every possible sink, or verify database state. Treat request models as the first filter: they reduce ambiguity before permission checks and business rules run.

Validation has a cost proportional to body size and model complexity. Keep request bodies bounded with web server limits and application-level expectations. Avoid accepting deeply nested arbitrary JSON unless the feature truly requires it. For reliability, return validation errors as client errors, reserve server errors for failures inside your system, and test representative invalid payloads so changes to models do not accidentally break clients.

Hands-On Lab

Prerequisites: a working Python environment with FastAPI installed, plus the ability to run tests with FastAPI’s test client. Create a small app module containing the product create and patch endpoints from this lesson. Start with the create endpoint, then add the patch endpoint after the first verification passes.

  1. Define Image, ProductCreate, and ProductUpdate models.
  2. Add POST /products and return the SKU, title, and image count.
  3. Add PATCH /products/{sku} and return only fields supplied by the client.
  4. Send a valid create request with two images and verify the response contains image_count equal to 2.
  5. Send an invalid create request with a lowercase SKU and verify the response status is 422.
  6. Send an empty patch request and verify the response status is 400 with No changes supplied.

Verification is complete when valid data reaches endpoint logic as typed attributes, invalid field data returns 422, and a semantically empty patch returns the deliberate 400. Cleanup is simple for this lab: stop the development server or test process. If you added temporary routes to a larger project, remove them after confirming the behavior.

Assessment Exercises

  • Design separate create and response models for a blog post where clients provide title and body, but the API returns id and published. Explain why one shared model would be weaker.
  • Given a 422 error whose location points to body.images.0.alt_text, identify which part of the JSON failed and what you would ask the client developer to inspect.
  • Modify the patch example so an explicit null title is rejected while an omitted title is allowed. Explain the difference between missing and null.
  • Choose one rule that belongs in Field and one rule that belongs in endpoint logic for a checkout request. Defend the boundary.

Summary

FastAPI request bodies with Pydantic let you declare JSON structure once and use it for parsing, validation, endpoint parameters, and API documentation. The mechanism starts with the endpoint signature, reads and decodes the HTTP body, builds nested Pydantic models, and either passes a typed object to your route or returns a structured 422. Use constraints for local data rules, nested models for known structures, separate schemas when create, update, and response shapes differ, and explicit endpoint logic for rules that require context.