Learnixo

FastAPI for AI Engineers · Lesson 3 of 12

Pydantic v2: Request and Response Models

Why Pydantic?

Every AI service receives untrusted input. A user sends JSON — but is the max_tokens field actually an integer? Is the messages list non-empty? Is the model field one of the allowed values?

Without validation you write if-statements everywhere:

Python
# Without Pydantic  tedious and error-prone
@app.post("/chat")
async def chat(request: Request):
    body = await request.json()
    if "messages" not in body:
        return JSONResponse({"error": "messages required"}, status_code=422)
    if not isinstance(body["messages"], list):
        return JSONResponse({"error": "messages must be a list"}, status_code=422)
    if len(body["messages"]) == 0:
        return JSONResponse({"error": "messages cannot be empty"}, status_code=422)
    # ... and so on for every field

With Pydantic you declare what the data should look like and Pydantic enforces it automatically:

Python
# With Pydantic  declarative, automatic, documented
from pydantic import BaseModel, Field

class ChatRequest(BaseModel):
    messages: list[dict]   # non-empty list is enforced by min_length below
    model: str = "gpt-4o"

@app.post("/chat")
async def chat(req: ChatRequest):
    ...   # req is already validated  safe to use

Pydantic v2 (released mid-2023, the default in 2026) rewrote the validation engine in Rust via pydantic-core, making validation roughly 5–50x faster than v1.

BaseModel Basics

Python
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime

class ArticleModel(BaseModel):
    id: int
    title: str
    body: str
    published: bool = False
    tags: list[str] = []
    created_at: datetime = Field(default_factory=datetime.utcnow)
    author_id: Optional[int] = None

Parsing from dict

Python
article = ArticleModel(
    id=1,
    title="FastAPI Guide",
    body="...",
    tags=["python", "api"],
)
print(article.model_dump())
# {'id': 1, 'title': 'FastAPI Guide', 'body': '...', 'published': False,
#  'tags': ['python', 'api'], 'created_at': datetime(...), 'author_id': None}

Coercion

Pydantic v2 coerces compatible types by default. Sending "123" for an int field raises a validation error in strict mode but converts silently in lax mode (the default).

Python
# Lax mode (default): coerces string "42" to int 42
article = ArticleModel.model_validate({"id": "42", "title": "t", "body": "b"})
print(article.id)   # 42  coerced from string

# Strict mode: raises ValidationError for wrong types
class StrictArticle(BaseModel):
    model_config = {"strict": True}
    id: int

Field: Constraints and Metadata

Field() adds constraints, defaults, and documentation metadata:

Python
from pydantic import BaseModel, Field

class ChatRequest(BaseModel):
    messages: list[dict] = Field(
        ...,                    # required (no default)
        min_length=1,           # list must have at least 1 item
        description="The conversation history",
    )
    model: str = Field(
        default="gpt-4o",
        pattern="^gpt-[a-z0-9-]+$",   # regex validation
        description="The OpenAI model name",
    )
    temperature: float = Field(
        default=0.7,
        ge=0.0,    # greater than or equal to 0
        le=2.0,    # less than or equal to 2
    )
    max_tokens: int = Field(
        default=1024,
        ge=1,
        le=4096,
    )
    stream: bool = Field(default=False)

Common Field constraints:

| Constraint | Applies To | Meaning | |-----------|-----------|---------| | ge=n | int, float | greater than or equal to n | | gt=n | int, float | strictly greater than n | | le=n | int, float | less than or equal to n | | lt=n | int, float | strictly less than n | | min_length=n | str, list | minimum character / item count | | max_length=n | str, list | maximum character / item count | | pattern=r"..." | str | must match regex |

Pydantic v2 Changes from v1

If you are upgrading a service from Pydantic v1, here are the critical changes:

ConfigDict replaces inner Config class

Python
# v1 (deprecated)
class MyModel(BaseModel):
    class Config:
        orm_mode = True
        extra = "forbid"

# v2 (current)
from pydantic import ConfigDict

class MyModel(BaseModel):
    model_config = ConfigDict(from_attributes=True, extra="forbid")

field_validator replaces @validator

Python
# v1 (deprecated)
from pydantic import validator

class OldModel(BaseModel):
    name: str

    @validator("name")
    def name_must_be_capitalised(cls, v):
        return v.capitalize()

# v2 (current)
from pydantic import field_validator

class NewModel(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def name_must_be_capitalised(cls, v: str) -> str:
        return v.capitalize()

model_validator replaces root_validator

Python
# v2 model_validator
from pydantic import model_validator

class SearchRequest(BaseModel):
    q: str
    limit: int = 10
    offset: int = 0

    @model_validator(mode="after")
    def validate_pagination(self) -> "SearchRequest":
        if self.offset < 0:
            raise ValueError("offset must be non-negative")
        if self.limit + self.offset > 10000:
            raise ValueError("limit + offset may not exceed 10000")
        return self

mode="before" receives the raw dict before field parsing; mode="after" receives the fully-constructed model instance.

.dict() and .json() renamed

Python
# v1 (still works but deprecated)
model.dict()
model.json()

# v2
model.model_dump()
model.model_dump_json()
model.model_dump(mode="json")    # converts datetime, UUID etc. to JSON-safe types

Nested Models

Nested models let you represent hierarchical JSON without any manual parsing:

Python
from pydantic import BaseModel, Field
from typing import Literal

class Message(BaseModel):
    role: Literal["system", "user", "assistant"]
    content: str = Field(..., min_length=1, max_length=32000)

class Usage(BaseModel):
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int

class Choice(BaseModel):
    index: int
    message: Message
    finish_reason: Literal["stop", "length", "content_filter", "tool_calls"]

class ChatCompletionResponse(BaseModel):
    id: str
    object: str = "chat.completion"
    created: int
    model: str
    choices: list[Choice]
    usage: Usage

FastAPI serialises this to JSON automatically and includes the full nested schema in the OpenAPI docs.

Optional Fields and Defaults

Python
from typing import Optional, Union
from pydantic import BaseModel

class DrugInfoRequest(BaseModel):
    drug_name: str
    include_interactions: bool = True
    max_results: int = 10
    language: Optional[str] = None      # None means "not provided"
    filters: Optional[dict] = None

# All of these are valid:
DrugInfoRequest(drug_name="ibuprofen")
DrugInfoRequest(drug_name="aspirin", language="fr")
DrugInfoRequest(drug_name="metformin", filters={"category": "diabetes"})

Request and Response Models for a Chat Endpoint

Here is a complete pair of request/response models for a production AI chat endpoint:

Python
# models/chat.py
from __future__ import annotations

from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict


class Message(BaseModel):
    role: Literal["system", "user", "assistant"] = Field(
        ...,
        description="The speaker role for this message",
    )
    content: str = Field(
        ...,
        min_length=1,
        max_length=32000,
        description="Message content — text only in this version",
    )

    @field_validator("content")
    @classmethod
    def strip_whitespace(cls, v: str) -> str:
        return v.strip()


class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")   # reject unknown fields

    messages: list[Message] = Field(
        ...,
        min_length=1,
        description="Conversation history. Must contain at least one message.",
    )
    model: str = Field(
        default="gpt-4o",
        description="Deployment name or model ID",
    )
    temperature: float = Field(default=0.7, ge=0.0, le=2.0)
    max_tokens: int = Field(default=1024, ge=1, le=4096)
    stream: bool = Field(
        default=False,
        description="If true, use the /chat/stream endpoint instead",
    )
    user_id: Optional[str] = Field(
        default=None,
        description="Caller's user ID for audit logging",
    )

    @model_validator(mode="after")
    def system_message_first(self) -> "ChatRequest":
        """If there is a system message it must be the first message."""
        system_indices = [i for i, m in enumerate(self.messages) if m.role == "system"]
        if len(system_indices) > 1:
            raise ValueError("At most one system message is allowed")
        if system_indices and system_indices[0] != 0:
            raise ValueError("The system message must be the first message")
        return self


class TokenUsage(BaseModel):
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int


class ChatResponse(BaseModel):
    content: str
    finish_reason: Literal["stop", "length", "content_filter", "tool_calls"]
    model: str
    usage: TokenUsage

Custom Validators: Drug Name and Message Length

Building a drug information service? Here are domain-specific validators:

Python
import re
from pydantic import BaseModel, Field, field_validator, model_validator

ALLOWED_DRUG_NAME_RE = re.compile(r"^[a-zA-Z0-9 \-\(\)\/]+$")
MAX_TOTAL_MESSAGE_CHARS = 64000

class DrugQueryRequest(BaseModel):
    drug_name: str = Field(..., min_length=2, max_length=200)
    context: Optional[str] = Field(default=None, max_length=2000)
    messages: list[Message] = Field(default_factory=list)

    @field_validator("drug_name")
    @classmethod
    def validate_drug_name(cls, v: str) -> str:
        v = v.strip()
        if not ALLOWED_DRUG_NAME_RE.match(v):
            raise ValueError(
                "Drug name must contain only letters, numbers, spaces, "
                "hyphens, parentheses, or forward slashes."
            )
        return v

    @field_validator("messages")
    @classmethod
    def validate_message_count(cls, v: list[Message]) -> list[Message]:
        if len(v) > 50:
            raise ValueError("A single request may not contain more than 50 messages.")
        return v

    @model_validator(mode="after")
    def validate_total_length(self) -> "DrugQueryRequest":
        total = sum(len(m.content) for m in self.messages)
        if total > MAX_TOTAL_MESSAGE_CHARS:
            raise ValueError(
                f"Total message content exceeds limit of {MAX_TOTAL_MESSAGE_CHARS} characters. "
                f"Current total: {total}."
            )
        return self

ConfigDict Options Useful for AI Services

Python
from pydantic import BaseModel, ConfigDict

class AIResponse(BaseModel):
    model_config = ConfigDict(
        # Allow instantiation from ORM objects (e.g. SQLAlchemy rows)
        from_attributes=True,
        # Reject fields not declared in the model  catches typos
        extra="forbid",
        # Use enum values in JSON output rather than enum names
        use_enum_values=True,
        # Include field descriptions in JSON Schema
        json_schema_extra={
            "examples": [
                {
                    "content": "Ibuprofen is a nonsteroidal anti-inflammatory drug...",
                    "finish_reason": "stop",
                    "model": "gpt-4o",
                    "usage": {
                        "prompt_tokens": 120,
                        "completion_tokens": 80,
                        "total_tokens": 200,
                    },
                }
            ]
        },
    )
    content: str
    finish_reason: str
    model: str
    usage: TokenUsage

Validation Errors and HTTP 422

When FastAPI receives a request body that fails Pydantic validation, it automatically returns HTTP 422 Unprocessable Entity with a structured error body:

JSON
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "messages"],
      "msg": "List should have at least 1 item after validation, not 0",
      "input": [],
      "url": "https://errors.pydantic.dev/2.6/v/too_short"
    }
  ]
}

This happens with zero code from you. Clients get clear, machine-readable error messages.

Key Takeaways

  • Pydantic v2 validates incoming JSON, serializes outgoing JSON, and generates OpenAPI schemas from one set of type annotations
  • Field() adds numeric constraints (ge, le), string constraints (min_length, pattern), and documentation metadata
  • v2's field_validator replaces @validator; model_validator replaces @root_validator; ConfigDict replaces the inner Config class
  • Use model_dump() and model_dump_json() — the v1 .dict() and .json() methods are deprecated
  • extra="forbid" in ConfigDict rejects unknown fields, which is recommended for AI API endpoints
  • Domain-specific validators (drug name format, message length limits) belong in field_validator and model_validator — keep them in your model definitions, not in route handlers

Next lesson: path, query, and body parameters — how FastAPI routes different parts of the HTTP request to your handler.