Free lesson · GenAI Agent Engineering
Create Pydantic models
You will create Pydantic models for data validation. Define BaseModel classes, add typed fields with validation, and use field validators for custom rules.
Course: LLM Foundations for Agent Builders · Chapter 3 · Type Hints & Pydantic
Free to read — no subscription required.
Pydantic is a data validation library that uses Python type annotations to define data structures with automatic validation. It's become essential in AI development for handling API responses, configuration management, and ensuring data integrity throughout your application.
Introduction
When you wire an LLM into a real application, the data flowing in and out is messy: an API returns a string where you expected a float, a config file omits a required field, a user payload smuggles in a temperature of 3.0 that the provider will reject deep inside the call. If you've shipped without a validation layer, you've watched these mistakes surface as opaque tracebacks far from the source. Skipping validation has a concrete consequence — invalid requests reach the model, burn tokens, and fail silently or crash mid-stream — and the fix has to live at the boundary, not in every call site. By the end of this lesson you will be able to define Pydantic models that validate LLM messages, configurations, and conversations on instantiation, use Field to enforce numeric and length constraints, and compose models through inheritance for polymorphic message collections.
Key Terminology
- BaseModel — the Pydantic base
classyou subclass to declare a validated data structure; instantiating a subclass triggers type coercion and validation, which is what makes it safe to feed external data straight into model fields. - Field — a function used in a
classattribute's default to attach validation constraints (ge,le,gt,max_length), descriptions, examples, and factory defaults; it's how you encode business rules like "temperature must be between 0 and 2" alongside the type. - ValidationError — the exception Pydantic raises when input data fails type or constraint checks; catching it at the boundary is what lets you reject bad payloads before they reach the LLM API.
- Type coercion — Pydantic's automatic conversion of compatible inputs to the declared type (e.g. the string
"0.5"becomes the float0.5); critical when consuming JSON or form data where everything arrives as strings. - Model composition — the pattern of one model containing fields typed as other models (e.g.
Conversationcontaininglist[BaseMessage]); validation cascades into nested models so the whole tree is checked in one call.
Concepts
BaseModel and validation-on-instantiation
A Pydantic model is a Python class that subclasses BaseModel and declares its fields with type annotations. The moment you instantiate it — LLMMessage(role="user", content="Hello!") — Pydantic runs every declared type through its validation pipeline: required fields must be present, types must match (or be coercible), and any constraints declared via Field must hold. If any check fails, you get a single ValidationError describing every problem at once — not a TypeError deep inside a downstream call. There is no separate .validate() step; construction is validation, which is what lets you trust every instance once it exists (see Code Walkthrough).
Field for constraints and metadata
The bare type annotation says what shape a field has; Field(...) says what values it accepts. Numeric ranges (ge, le, gt, lt), collection bounds (max_length, min_length), defaults, factories (default_factory=list), and human-readable description / examples all live in the Field call. This is how temperature: float = Field(default=0.7, ge=0.0, le=2.0) rejects temperature=3.0 before it ever reaches the provider — the constraint travels with the type, so it cannot drift from the field it protects (see Code Walkthrough).
Inheritance and composition for polymorphic data
LLM conversations are heterogeneous — system, user, and assistant turns share fields like content and timestamp but diverge on role, user_id, or tokens_used. Pydantic's model inheritance lets you share the common base and extend per role, while composition lets a Conversation model hold a list[BaseMessage] whose nested validation cascades top-down in a single call. You never loop and re-validate — the whole tree is checked at construction, and any nested failure surfaces in the same ValidationError (see Code Walkthrough).
Code Walkthrough
Now that you've seen how BaseModel, Field constraints, and model composition work conceptually, the two examples below put all three patterns into runnable Python.
The first block shows a validated LLMConfig. Field encodes the business rule — temperature must sit between 0.0 and 2.0 — directly on the field, so Pydantic rejects out-of-range values on instantiation before any network call. Notice that passing temperature="0.5" (a string) succeeds: Pydantic coerces it to float automatically, which is exactly what happens when config values arrive from environment variables or JSON.
Code snippetpython
1from pydantic import BaseModel, Field, ValidationError 2from typing import Optional 3 4class LLMConfig(BaseModel): 5 model: str = Field(description="The model identifier to use") 6 temperature: float = Field(default=0.7, ge=0.0, le=2.0) 7 max_tokens: int = Field(default=1000, gt=0, le=4096) 8 top_p: Optional[float] = Field(default=None, ge=0.0, le=1.0) 9 stop_sequences: list[str] = Field(default_factory=list, max_length=4) 10 11# String "0.5" is coerced to float 0.5 — type coercion at the boundary. 12config = LLMConfig(model="gemini-2.0-flash", temperature="0.5", max_tokens="2000") 13print(config.temperature) # 0.5 14 15# Out-of-range value raises ValidationError before any API call. 16try: 17 LLMConfig(model="gemini", temperature=3.0) 18except ValidationError as e: 19 print(f"Caught at the boundary: {e}")
The second block shows inheritance and composition. BaseMessage declares the shared fields; UserMessage, AssistantMessage, and SystemMessage each inherit and add their role-specific fields. Conversation composes a list[BaseMessage], and validation cascades into every nested message in the single constructor call — there is no separate .validate() step per message.
Code snippetpython
1from pydantic import BaseModel, Field 2from typing import Optional 3from datetime import datetime 4 5class BaseMessage(BaseModel): 6 content: str 7 timestamp: datetime = Field(default_factory=datetime.now) 8 9class UserMessage(BaseMessage): 10 role: str = "user" 11 user_id: Optional[str] = None 12 13class AssistantMessage(BaseMessage): 14 role: str = "assistant" 15 model: Optional[str] = None 16 tokens_used: Optional[int] = None 17 18class SystemMessage(BaseMessage): 19 role: str = "system" 20 21class Conversation(BaseModel): 22 id: str 23 messages: list[BaseMessage] 24 metadata: dict[str, str] = Field(default_factory=dict) 25 26convo = Conversation( 27 id="c-1", 28 messages=[ 29 SystemMessage(content="You are a helpful assistant."), 30 UserMessage(content="Hello!", user_id="user123"), 31 AssistantMessage(content="Hi! How can I help?", model="gemini"), 32 ], 33) 34for msg in convo.messages: 35 print(f"{msg.role}: {msg.content}")
You'll know it works when LLMConfig(model="gemini", temperature=3.0) raises ValidationError before any network call, the string "0.5" is silently coerced to float inside a valid LLMConfig, and Conversation(...) validates every nested BaseMessage in a single constructor call with no per-message .validate() step required.
Do's and Don'ts
Do's
- ✓Do instantiate at the boundary — wrap inbound API requests, parsed tool-calls, and loaded configs in a Pydantic model the moment they enter your system, so downstream code can trust the field types without re-checking.
- ✓Do put
Fieldconstraints on every numeric LLM parameter —temperature,top_p,max_tokens,stop_sequenceslength — so out-of-range inputs fail at validation, not at the provider's 400. - ✓Do use
default_factory=list/default_factory=dictfor mutable defaults — reusing a shared mutable default across instances is the exact bug Pydantic exists to prevent.
Don'ts
- ✗Don't bypass validation with
model.__dict__[...] = valueormodel_construct()on untrusted data — both skip the pipeline and reintroduce the failure modes the model exists to prevent. - ✗Don't swallow
ValidationErrorwith a bareexcept Exception:— catchValidationErrorspecifically and surface.errors()to the caller so they learn which field failed. - ✗Don't put business logic in
__init__— use@field_validator/@model_validatorso rule failures surface asValidationErroralongside the type errors, not as anAttributeErrorafter the model is "constructed."
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
More free lessons in LLM Foundations for Agent Builders
- Ch 1Build data pipelines
- Ch 3Create Pydantic modelsYou are here
- Ch 3Configure Pydantic
- Ch 6Install Gemini SDK