Hello. In the previous lesson, you turned an AI feature into testable requirements: the client must be able to submit a question, the service must return either an answer or a safe fallback, answers need citations, and failures must not leak internal details.
This lesson turns those requirements into a concrete API contract. You will define what an untrusted client is allowed to send, what the service promises to return, and which malformed payloads are rejected before they enter your application or reach a model provider. We will use modern Python type hints and Pydantic v2, the validation layer used by FastAPI.
Schemas make an API boundary explicit
An API schema is an executable description of data at a system boundary. It specifies:
- field names and data types;
- which fields are required;
- which fields may be omitted or be
null; - valid values and size limits;
- nested structures;
- response shapes that clients can safely rely on.
Python type hints alone primarily help readers, editors, and static type checkers. Pydantic uses those same hints at runtime to parse and validate incoming data. If validation succeeds, your service receives a typed Python object. If it fails, Pydantic produces structured details about every detected problem.
For an AI service, validation is not just neatness. It is a control point:
| Boundary risk | Schema decision |
|---|---|
| A client submits an empty or enormous question. | Require meaningful text and impose a length limit. |
| A client sends an unsupported option because of a typo. | Reject unknown fields instead of silently ignoring them. |
| A frontend cannot distinguish an answer from an abstention. | Return explicit, typed outcome variants. |
| A response accidentally includes model-provider internals. | Define a narrow response schema that exposes only approved fields. |
| A client needs to render citations reliably. | Define citations as nested, validated objects. |
A useful mental model is that a request schema is an allowlist. Do not put server-controlled values such as user_id, tenant_id, access permissions, raw model settings, or system prompts into a client request model. The backend obtains identity and authorization from trusted authentication middleware, not from JSON supplied by the caller.
The core Pydantic model
A Pydantic model is a class that inherits from BaseModel. Its annotated attributes become fields in the schema.
Watch the foundations in Corey Schafer’s Python Pydantic Tutorial: Complete Data Validation Course. It demonstrates required and defaulted fields, runtime validation, serialization, and Pydantic’s default coercion behavior.
Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI)
Watch these sections to see Pydantic v2 models built and exercised from scratch. Focus on the difference between declaring a shape with type hints and validating actual runtime input.
Watch model fields to distinguish required fields, fields with defaults, and nullable fields. Then watch serialization for model_dump() and JSON output. Finish with errors and coercion, paying attention to why a value such as "123" may be accepted for an integer field by default.
A first request model for the internal IT assistant might look like this:
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
QuestionText = Annotated[
str,
Field(min_length=1, max_length=2_000),
]
class AskAssistantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
question: QuestionText
conversation_id: UUID | None = None
locale: Literal["en-US", "en-GB"] = "en-US"
include_citations: bool = True
@field_validator("question")
@classmethod
def normalize_question(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("question must contain non-whitespace characters")
return normalized
Read this as a contract, not merely as a Python class:
questionis required because it has no default value.- It must be a string between 1 and 2,000 characters. The maximum is a deliberate service boundary: it limits accidental oversized requests and helps constrain retrieval and model cost.
conversation_idcan be absent, or it can explicitly benull, or it can be a UUID. This is appropriate when a user may start a new conversation or continue an existing one.localeis optional because it has a default.Literallimits it to the values the current service actually supports.include_citationsdefaults toTrue, matching the product requirement that responses should ordinarily provide evidence.extra="forbid"rejects unexpected fields. This catches client typos and prevents clients from quietly assuming an option had an effect when your service never recognized it.- The validator trims surrounding whitespace and rejects a question made entirely of spaces. A length constraint alone would consider
" "non-empty.
Required, optional, and nullable are different
These terms are easy to blur together:
| Declaration | May the field be omitted? | May its value be null? |
|---|---|---|
question: str | No | No |
locale: str = "en-US" | Yes | No |
| `conversation_id: UUID | None = None` | Yes |
| `conversation_id: UUID | None` | No |
The final row is especially important in Pydantic v2. Adding | None means that None is an accepted value; it does not supply a default. A field becomes omittable when you give it a default value.
Pydantic’s field documentation is a useful reference for these declaration choices and for attaching constraints cleanly to standard Python types.
Read the Pydantic documentation on field metadata and defaults. It provides the reasoning behind the Annotated[...] and Field(...) syntax used in this lesson.
In the subsection “The annotated pattern,” read the annotated pattern to see how validation metadata can be attached without changing the underlying Python type. Next, in “Default values,” read defaults and factories; focus on why a default factory creates a fresh value for each model instance. Finally, read the complete “Field constraints” subsection, beginning at field constraints, and compare min_length, max_length, and numeric constraints with the product rules they enforce.
Validate request data before application logic
At an API boundary, JSON is untrusted input. Pydantic can validate a Python dictionary using model_validate() or JSON text using model_validate_json().
from pydantic import ValidationError
payload = {
"question": " How do I reconnect to the corporate VPN? ",
"conversation_id": "e1ee6323-75b7-4f6a-8f80-73e54a6d7701",
"locale": "en-US",
}
request = AskAssistantRequest.model_validate(payload)
print(request.question)
# How do I reconnect to the corporate VPN?
print(request.model_dump(mode="json"))
The UUID arrives from JSON as a string but is represented in Python as a UUID. When you use mode="json" during serialization, Pydantic converts it back to a JSON-compatible string.
Now consider an invalid payload:
bad_payload = {
"question": " ",
"locale": "fr-FR",
"include_citations": True,
"model_name": "some-unapproved-model",
}
try:
AskAssistantRequest.model_validate(bad_payload)
except ValidationError as error:
print(error.errors())
This payload has three separate problems:
- The question contains no meaningful text after normalization.
fr-FRis outside the explicitly supported locale set.model_nameis an unknown field and is rejected because ofextra="forbid".
Pydantic collects validation failures rather than stopping at the first one. In a web framework, the framework can convert these errors into a structured client error response. In the next FastAPI lesson, you will let the framework invoke this validation automatically for an HTTP request body.
Coercion versus strictness
Pydantic often performs reasonable conversions. For example, it can usually parse a UUID string into a UUID, which is useful because JSON has no native UUID type.
But coercion should be a conscious choice. If accepting a string where an integer or boolean is expected would hide an upstream integration defect, make that specific field strict:
from typing import Annotated
from pydantic import Field
StrictBoolean = Annotated[bool, Field(strict=True)]
class FeedbackRequest(BaseModel):
was_helpful: StrictBoolean
With this model, JSON true is valid, while the string "true" is not. Use strictness where type precision has business or security significance; do not make every field strict without considering normal JSON representations and legitimate client behavior.
Model the response separately from the request
A common and costly mistake is to reuse one model for input, database storage, and output. Those are distinct contracts with distinct trust levels.
For example, a user-registration request may contain a password, but its response must never contain that password. For an AI assistant, an internal service result may contain raw retrieved passages, provider request IDs, token counts, prompt versions, or debugging metadata. A browser client usually should not receive most of those fields.
The FastAPI Swagger UI image below illustrates the same principle: UserIn contains a password, while UserOut intentionally does not.

For the internal IT assistant, the earlier requirements identify two legitimate outcomes:
- an evidence-backed answer; or
- a safe inability-to-answer outcome, such as insufficient evidence or a model-provider failure.
Those are different shapes. An answered response must contain an answer and citations. A safe fallback must contain a safe user-facing message, but should not pretend that it has an answer.
from typing import Annotated, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
ShortText = Annotated[str, Field(min_length=1, max_length=500)]
AnswerText = Annotated[str, Field(min_length=1, max_length=4_000)]
class Citation(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: ShortText
title: ShortText
location: ShortText
excerpt: ShortText
class AnsweredResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["answered"]
request_id: UUID
answer: AnswerText
citations: Annotated[list[Citation], Field(min_length=1)]
class UnableToAnswerResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["insufficient_evidence", "provider_unavailable"]
request_id: UUID
message: AnswerText
AssistantResponse = Annotated[
AnsweredResponse | UnableToAnswerResponse,
Field(discriminator="status"),
]
This is a discriminated union. The status field tells a consumer which schema applies:
status value | Guaranteed fields | Client behavior |
|---|---|---|
"answered" | answer and at least one citation | Render the answer and source links. |
"insufficient_evidence" | message | Explain that approved evidence was not sufficient and show the escalation path. |
"provider_unavailable" | message | Display a temporary safe failure state and offer retry or escalation. |
The design captures an important product rule in the type system: an AnsweredResponse cannot validate without at least one citation. This is stronger than a loose response such as answer: str | None plus citations: list[Citation], which permits confusing combinations such as a status of "answered" with no answer or no evidence.
Nested models make the citation contract recursive. Pydantic validates every object in the citations list, not just the outer response. A malformed location or missing source_id invalidates the response before it is returned to the client.
The Pydantic models documentation covers this same nesting behavior and the validation methods you will use in tests and service code.
Use this reference to consolidate the model behavior behind the request and response schemas. Read the examples as patterns for validating untrusted dictionaries and exporting validated models.
Begin with “Basic model usage” and read the model guarantee. Then read all of “Nested models,” beginning with the nested example, relating Foo and Bar to the Citation list inside AnsweredResponse. In “Validating data,” focus on validation modes, especially model_validate() and model_validate_json(). Finally, read “Error handling,” starting with validation errors, noting that one exception can report multiple failing fields.
Response validation is a safety check, not only documentation
A response schema serves three jobs simultaneously:
-
It gives clients a stable contract. A frontend can render each
statusvariant deliberately rather than guess from a free-form message. -
It produces machine-readable API documentation. FastAPI can use Pydantic schemas to generate OpenAPI documentation and interactive Swagger UI.
-
It catches accidental response leaks. If service code tries to return an unexpected field and the response model forbids extras, validation exposes the mismatch during development or testing.
Suppose the application layer produces this approved result:
service_result = {
"status": "answered",
"request_id": "b23a41b0-55e3-49bd-bb3c-61dd6e2e85c2",
"answer": "Reconnect to the VPN, then sign in through the approved MFA prompt.",
"citations": [
{
"source_id": "vpn-guide-2025",
"title": "Corporate VPN Troubleshooting Guide",
"location": "Section 3.2",
"excerpt": "Reconnect to the VPN before starting a new MFA challenge.",
}
],
}
response = AnsweredResponse.model_validate(service_result)
json_ready = response.model_dump(mode="json")
The schema ensures this result has the expected fields and types. It does not establish that the answer is factually correct or that the citation genuinely supports it. Those are separate application-level and evaluation concerns. Your retrieval layer must select authorized sources, and later course modules will measure groundedness and citation quality. Schema validation guarantees the shape of an answer, not its truth.
Similarly, do not rely on SecretStr or masked printing as the primary protection against response leakage. The stronger design is to ensure secrets, raw tokens, and internal provider payloads are absent from public response models entirely.
For a brief preview of how a framework exposes this contract, watch the response-model portion of pixegami’s FastAPI tutorial.
Python FastAPI Tutorial: Build a REST API in 15 Minutes
Watch this short segment to connect the Pydantic response classes you just wrote with the API documentation and client guarantees FastAPI will create from them.
Watch response models. Focus on the idea that declaring a response model communicates and enforces the outgoing shape; you will implement the route decorator mechanics in the upcoming FastAPI lesson.
A practical schema-design checklist
Before committing a request or response model, review it against the feature requirements.
For every request model
- Is each field genuinely controlled by the client?
- Is each required field required for a reason?
- Does each optional field have an intentional default?
- Are nullable fields meaningfully different from omitted fields?
- Are finite option sets represented with
Literalor an enum rather than arbitrary strings? - Do size constraints protect the service from unreasonable text, lists, or payloads?
- Are unknown fields rejected when they represent unsupported behavior or likely typos?
- Are cross-field rules handled with a validator when field constraints are insufficient?
For every response model
- Does it include only information the client is authorized to see?
- Are IDs, timestamps, and nested objects typed rather than left as unstructured dictionaries?
- Does the response distinguish success, abstention, and safe operational failure?
- Can the client render every declared outcome without parsing natural-language text?
- Are required product guarantees encoded where possible, such as non-empty citations for an evidence-backed answer?
- Would an unexpected internal field be caught before it becomes a public API behavior?
Schemas are versioned contracts. Renaming a response field, changing str to a nested object, or making an optional field required can break a frontend or external integration. Prefer additive changes, such as introducing an optional field or a new outcome variant, and coordinate breaking changes explicitly.
Key takeaways
Pydantic turns Python type hints into runtime-validated API contracts.
- A
BaseModeldefines a data shape using ordinary Python annotations. - A field is required when it has no default;
| Nonepermitsnullbut does not by itself make a field omittable. Field(...)andAnnotated[...]express constraints such as length limits and numeric bounds.- Validators cover rules that simple type constraints cannot express, such as rejecting whitespace-only text or checking relationships among fields.
ConfigDict(extra="forbid")makes a model an explicit allowlist of accepted fields.- Request and response schemas should be separate. A public response must expose only the data a client needs and is allowed to receive.
- Nested models provide recursive validation for structures such as citations.
- A discriminated response union gives clients reliable, typed handling for answers and safe fallback outcomes.
- Schema validation guarantees data structure, not LLM factual correctness or authorization correctness.
Next, you will use asynchronous Python to run independent data or model operations concurrently. That matters once a validated request reaches a service that may need retrieval, policy checks, and model calls without making the user wait for each independent operation in sequence.
Can't find a good explanation? Sign up and we'll make it for you
Sign up