Free lesson · GenAI Agent Engineering
Define Pydantic request and response models
You will build a type-safe API layer using Pydantic models. Create PromptCreate with field validators for name length (3-100 chars), template format validation using regex, and a custom validator ensuring template variables match declared parameters. Build PromptResponse with computed fields for token_count and created_at defaults. Implement PromptList with pagination metadata. Use Field() for descriptions, examples, and JSON schema customization.
Course: Web APIs & Services for GenAI Engineers · Chapter 1 · FastAPI Fundamentals
Free to read — no subscription required.
Introduction
When you ship a FastAPI endpoint that accepts model-generation parameters from clients, every untyped field is an incident waiting to happen — a temperature of "hot", a max_tokens of -5, a prompt template missing its {prompt} placeholder. By the time those values reach a provider SDK, the stack trace points at the SDK, not at the request that caused the failure. Pydantic models push the rejection to the boundary: malformed input is converted into a structured 422 response before your handler runs. By the end of this lesson you will be able to constrain fields with Field() (length, numeric range, regex pattern, enum), enforce per-field rules with @field_validator, enforce cross-field rules with @model_validator, and separate request, response, and update models so each carries the right contract — without writing a single line of hand-rolled error-handling code.
Key Terminology
- Pydantic model — a
BaseModelsubclass whose typed attributes describe the shape of an incoming or outgoing payload; FastAPI parses, coerces, and validates JSON against it before invoking your handler. - Field constraint — a parameter passed to
Field()such asmin_length,max_length,ge,le, orpatternthat bounds a single attribute structurally without procedural code. @field_validator— a Pydantic v2 decorator that attaches custom per-field logic which runs after type coercion and field constraints, either transforming the value or raisingValueErrorto reject it.@model_validator— a Pydantic v2 decorator (typicallymode="after") that runs once all fields have been validated, used to enforce relationships between fields — such as amax_tokensceiling that depends on the selectedtier.- 422 Unprocessable Entity — the HTTP status FastAPI returns when validation fails; the response body lists every offending field with
loc,msg, andtype, so clients can fix all issues in one round trip.
Concepts
Three ideas anchor this lesson. First, validation is declarative and boundary-enforced: the model definition is the contract, and every request body is checked against it before your handler runs, so business logic never has to defend against malformed input. Second, validation has distinct layers that compose — structural constraints via Field(), per-field logic via @field_validator, cross-field logic via @model_validator — and each layer addresses a different class of invalid input; choosing the right layer keeps the model readable. Third, an API needs separate models for separate roles: PromptCreate for incoming POST bodies (strict, no server-managed fields), PromptResponse for outgoing payloads (includes prompt_id, created_at), and PromptUpdate for PATCH bodies (every field optional). Keeping these distinct prevents clients from setting server-managed fields and prevents the response schema from leaking internal state.
The diagram below shows the order in which the layers fire on every incoming request. Anything that fails short-circuits to a 422 response; success delivers a fully-typed instance to your handler (see Code Walkthrough).
Each stage rejects independently and Pydantic collects every error before responding, so a client that violates three constraints in one payload receives all three errors in a single 422 — they fix everything in one round trip instead of playing whack-a-mole across sequential calls. The 422 body holds a detail array; each entry includes loc (the field path, e.g. ["body", "template"]), msg (the human-readable message from your ValueError or Pydantic's built-in text), and type (a machine-readable code like value_error or string_too_short) so frontends can map errors directly to form fields.
Code Walkthrough
Now that you have a mental model of the three validation layers and the three-role split, the next step is to see them rendered in code. The first snippet shows the three-model split — PromptCreate, PromptResponse, PromptUpdate — with Field() constraints and an Enum for closed-set values. The second extends PromptCreate with @field_validator and @model_validator to enforce rules that constraints alone cannot express.
Code snippetpython
1from datetime import datetime 2from enum import Enum 3from pydantic import BaseModel, Field 4 5class ModelTier(str, Enum): 6 FAST = "fast" 7 BALANCED = "balanced" 8 POWERFUL = "powerful" 9 10class PromptCreate(BaseModel): 11 name: str = Field(min_length=3, max_length=100) 12 template: str = Field( 13 min_length=10, 14 max_length=50_000, 15 pattern=r".*\{prompt\}.*", 16 description="Prompt template containing a {prompt} placeholder", 17 ) 18 model_name: str = Field( 19 default="claude-3-sonnet", 20 pattern=r"^(claude|gpt|gemini)-[\w.-]+$", 21 ) 22 temperature: float = Field(default=0.7, ge=0.0, le=2.0) 23 max_tokens: int = Field(default=1024, ge=1, le=128_000) 24 tier: ModelTier = Field(default=ModelTier.BALANCED) 25 26class PromptResponse(BaseModel): 27 prompt_id: str 28 name: str 29 template: str 30 model_name: str 31 temperature: float 32 max_tokens: int 33 tier: ModelTier 34 created_at: datetime 35 updated_at: datetime | None = None 36 37class PromptUpdate(BaseModel): 38 name: str | None = Field(default=None, min_length=3, max_length=100) 39 template: str | None = Field(default=None, min_length=10, pattern=r".*\{prompt\}.*") 40 temperature: float | None = Field(default=None, ge=0.0, le=2.0) 41 max_tokens: int | None = Field(default=None, ge=1, le=128_000)
ModelTier inherits from both str and Enum so values serialize as plain strings while still being type-checked. The template field's pattern=r".*\{prompt\}.*" catches the common bug of clients posting a static string instead of a parameterized template. temperature's ge=0.0, le=2.0 defines an inclusive range; values outside it are rejected before your handler runs. PromptResponse adds server-generated fields (prompt_id, created_at) absent from the request shape — declared as the route's response_model, it strips anything else. PromptUpdate types every field as optional so PATCH callers send only what they want to change, while the constraints still apply to fields they do supply.
Code snippetpython
1import re 2from pydantic import BaseModel, Field, field_validator, model_validator 3 4class PromptCreate(BaseModel): 5 name: str = Field(min_length=3, max_length=100) 6 template: str = Field(min_length=10, max_length=50_000) 7 temperature: float = Field(default=0.7, ge=0.0, le=2.0) 8 max_tokens: int = Field(default=1024, ge=1, le=128_000) 9 tier: ModelTier = Field(default=ModelTier.BALANCED) 10 stop_sequences: list[str] = Field(default_factory=list, max_length=4) 11 stream: bool = Field(default=False) 12 13 @field_validator("template") 14 @classmethod 15 def template_has_prompt_placeholder(cls, v: str) -> str: 16 if "prompt" not in re.findall(r"\{(\w+)\}", v): 17 raise ValueError("Template must contain a {prompt} placeholder") 18 if v.count("{") != v.count("}"): 19 raise ValueError("Template has unbalanced braces") 20 return v.strip() 21 22 @model_validator(mode="after") 23 def max_tokens_within_tier(self) -> "PromptCreate": 24 tier_limits = { 25 ModelTier.FAST: 4_096, 26 ModelTier.BALANCED: 32_000, 27 ModelTier.POWERFUL: 128_000, 28 } 29 limit = tier_limits[self.tier] 30 if self.max_tokens > limit: 31 raise ValueError( 32 f"max_tokens={self.max_tokens} exceeds the " 33 f"{self.tier.value} tier limit of {limit}" 34 ) 35 return self 36 37 @model_validator(mode="after") 38 def stop_sequences_disallowed_when_streaming(self) -> "PromptCreate": 39 if self.stream and self.stop_sequences: 40 raise ValueError("stop_sequences cannot be used when stream=True") 41 return self
@field_validator("template") runs after type coercion, so v is guaranteed to be a str; the @classmethod decorator is required in Pydantic v2. Returning v.strip() normalizes whitespace — field validators may transform, not only reject. The two @model_validator(mode="after") methods see the assembled instance via self and enforce rules that span fields: a tier-dependent ceiling on max_tokens, and the mutual exclusivity of stop_sequences and stream. Both methods must return self — forgetting the return silently produces an invalid instance.
You'll know it works when a POST with {"name": "x", "template": "short", "max_tokens": -5} returns a single 422 whose detail array carries three entries (one per constraint), while a valid payload yields a PromptResponse whose prompt_id and created_at are present and no other internal fields appear.
Do's and Don'ts
Having just wired up the models, validators, and discipline-specific overlay, the rules below distill the choices that consistently keep these boundaries intact in real services.
Do's
- ✓Do separate request and response models —
PromptCreatecarries the constraints;PromptResponseis the contract for what leaves the server and never inherits the request's validation rules. - ✓Always
returnself from every@model_validator(mode="after")method — a missingreturnstatement silently produces a model whose cross-field rules did not run. - ✓Do use
ge/lefor inclusive bounds —temperature: 0.0 ≤ x ≤ 2.0requiresge=0.0, le=2.0;gt/ltis an off-by-one bug against the documented API.
Don'ts
- ✗Don't catch
ValidationErrorin your handler — FastAPI's built-in handler emits the structured 422 withloc/msg/typeper error; wrapping it strips that detail from clients. - ✗Don't put cross-field business rules in the route function — if
max_tokensdepends ontier, that is a@model_validator, not anifstatement after the request lands. - ✗Don't accept unvalidated free-text into prompt templates —
Field(pattern=...)plus a@field_validatorchecking placeholders is your first line of defense against prompt-injection-shaped input.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
Listen to this lesson
Audio overviews of this lesson's labs and its chapter, from GenBodha Bytes.
- FastAPI FundamentalsChapter overview20 min
More free lessons in Web APIs & Services for GenAI Engineers
- Ch 1Create a FastAPI application with path operations
- Ch 1Define Pydantic request and response modelsYou are here
- Ch 1Configure OpenAPI documentation with examples
- Ch 5Build real-time notification system with Redis pub/sub
- Ch 6Implement rate limiting with Redis sliding window
- Ch 8Generate rich OpenAPI documentation with examples
- Ch 10Build production Docker images with multi-stage builds