PyFu

FastAPI Pydantic Data Models

Python Web Development Frameworks

FastAPI uses Pydantic as its core data validation and serialization engine. Pydantic provides a powerful way to define data models using standard Python type hints, allowing FastAPI to automatically validate request data, parse query parameters, and generate documentation.

Introduction to Pydantic

A Pydantic model is defined by creating a class that inherits from pydantic.BaseModel. Each attribute in the class represents a field in the expected data, with its type hint defining what data type is valid for that field.

Pydantic automatically converts incoming data to the correct types if possible, and rejects invalid data.

Example

from pydantic import BaseModel

class User(BaseModel):
    id: int
    username: str
    email: str
    is_active: bool = True

user_data = User(id=1, username="Askar", email="askar@pyfu.io")
print(user_data.id)
print(user_data.username)
# Output:
# 1
# Askar

In this example, any incoming data passed to User will be validated and converted according to the specified types. Default values, like is_active = True, are supported.

Pydantic in FastAPI

As we said, FastAPI uses Pydantic models extensively to simplify request and response handling. Models can be used to define request bodies, responses, query parameters, headers, cookies, and internal data structures.

FastAPI automatically parses incoming data into the appropriate model and serializes outgoing responses based on the model definitions.

Lets create a FastAPI application that uses the User model to handle POST requests.

from fastapi import FastAPI
from UserModel import User

app = FastAPI()

@app.post("/items/")
async def create_item(user: User):
    return {"received_user": user}

When a client sends a POST request to /items/, FastAPI reads the request body, parses the incoming JSON payload, and validates its content against the User model. If the data matches the expected schema, FastAPI automatically converts it into a User object and passes it as an argument to the route function for further processing.

If the data is valid, the API responds by returning the received user object as JSON. If the data is invalid, FastAPI automatically returns a 422 Unprocessable Entity error with detailed validation information.

To test this, The API expects a JSON body matching the User model, and here’s how to test it using curl command that send a json body as the expected body.

» curl -X POST http://localhost:8000/items/ \
  -H "Content-Type: application/json" \
  -d '{"id": 1337, "username": "askar", "email": "askar@pyfu.io"}' | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   160  100    95  100    65  30944  21172 --:--:-- --:--:-- --:--:-- 53333
{
  "received_item": {
    "id": 1337,
    "username": "askar",
    "email": "askar@pyfu.io",
    "is_active": true
  }
}  

If you send an invalid type, FastAPI will automatically reject it, for example. I will send a string as value of id which expects int :

~ » curl -X POST http://localhost:8000/items/ \
  -H "Content-Type: application/json" \
  -d '{"id": "askar", "username": "askar", "email": "askar@pyfu.io"}' | jq
---
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": [
        "body",
        "id"
      ],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "askar"
    }
  ]
}

Why Pydantic models matter from an offensive security perspective

In FastAPI, the Pydantic model is the trust boundary. The type hint on each field is not documentation, it is the validation and coercion rule the framework enforces on every request (the deeper mechanics of annotations as a security control are in Python Type Hinting and Annotations). That makes the model definition the most precise description of what the application will and will not accept, and the gaps between “what the type allows” and “what the handler assumes” are where the bugs live.

A few patterns to look for when you read a model:

  • Mass assignment. If the same model (or a superset) is used both for request input and for internal state, a client can set fields the developer never meant to expose. A User input model that includes is_admin, role, or balance lets the request set them directly. The fix is separate input and output models, but the exposure is in the annotation. See Business Logic Vulnerabilities in FastAPI Applications.
  • Over-broad types. A field typed Any, dict, or a permissive union accepts far more than the author pictured. Any disables validation for that field entirely, turning the “strict type control” this page advertises back into untrusted input.
  • Coercion surprises. The example shows Pydantic rejecting a string where an int is expected, which is the safe case. But coercion can also silently change a value’s meaning (a string accepted where a bool check was assumed, numeric strings becoming numbers), and coercion that changes meaning is a classic logic-flaw source.
  • Output leakage. Models also serialize responses. Returning a full internal model instead of a narrow response_model leaks every field on it, including ones added later. Pin a response model so the output shape is intentional, not whatever the object happens to carry.

When you assess a FastAPI app, read the models first. They tell you exactly which fields exist and what coercion happens before your input reaches the logic, which is precisely the information you need to find the field the handler trusts but should not.

Mitigation

Treat the model as the trust boundary it is and make it as narrow as the use it serves. Keep separate input and output models so a client can never set a field the handler treats as internal, pin a response_model on every route so the serialized shape is intentional rather than whatever the object carries, and forbid unexpected fields with model_config = ConfigDict(extra="forbid") so a request cannot smuggle extra keys. Use precise types instead of Any/dict, and add constraints (StrictInt, EmailStr, length and range validators) so coercion cannot quietly change a value’s meaning.

from pydantic import BaseModel, ConfigDict, EmailStr, StrictInt

class UserIn(BaseModel):                       # what the client may send
    model_config = ConfigDict(extra="forbid")
    username: str
    email: EmailStr

class UserOut(BaseModel):                       # what the client may see
    id: StrictInt
    username: str
    # no is_admin, no balance: privileged fields never serialize

@app.post("/users", response_model=UserOut)
async def create_user(user: UserIn):
    ...