Pydantic-Driven Schema Design for LLMs

Protocols & Interoperability intermediate Building with AI 12 min read

Overview

Pydantic is the de facto standard for defining, validating, and serializing data schemas in Python. In the LLM ecosystem, it has become the universal language for specifying what structured output you want from a language model. Every major LLM framework — OpenAI’s SDK, Anthropic’s SDK, LangChain, Instructor, Outlines, PydanticAI — uses Pydantic models as the primary interface for defining output schemas.

The power of Pydantic for LLMs goes beyond simple type checking. Pydantic models serve triple duty: they define the JSON Schema sent to the model (guiding generation), they validate the model’s output at runtime (catching any violations), and they provide typed Python objects for downstream code (enabling IDE autocompletion and static analysis). This “define once, use everywhere” approach eliminates the mismatches that plague hand-written schemas and manual parsing.

However, designing Pydantic schemas for LLMs requires different thinking than designing schemas for traditional APIs. LLMs are sensitive to field names, descriptions, and nesting depth. A well-designed schema acts as implicit instructions to the model, while a poorly designed one leads to confused outputs even when constrained decoding guarantees structural validity. Understanding these design patterns is essential for building reliable LLM applications.

How It Works

From Pydantic Model to JSON Schema to Constrained Generation

When you pass a Pydantic model to an LLM framework, the following pipeline executes:

  1. Pydantic’s .model_json_schema() method generates a JSON Schema from the model definition
  2. The framework sends this JSON Schema to the LLM API as part of the request
  3. The API’s constrained decoding engine compiles the schema into a grammar
  4. The model generates tokens constrained to this grammar
  5. The raw JSON response is parsed back into a Pydantic model instance via .model_validate_json()
  6. Any custom validators run on the parsed data
from pydantic import BaseModel, Field
from typing import Literal

class SentimentAnalysis(BaseModel):
    """Analyze the sentiment of the given text."""

    sentiment: Literal["positive", "negative", "neutral"] = Field(
        description="The overall sentiment of the text"
    )
    confidence: float = Field(
        ge=0.0, le=1.0,
        description="Confidence score between 0 and 1"
    )
    reasoning: str = Field(
        description="Brief explanation of why this sentiment was chosen"
    )

# This generates the JSON Schema automatically:
print(SentimentAnalysis.model_json_schema())
# {
#   "properties": {
#     "sentiment": {"enum": ["positive", "negative", "neutral"], ...},
#     "confidence": {"minimum": 0.0, "maximum": 1.0, "type": "number"},
#     "reasoning": {"type": "string"}
#   },
#   "required": ["sentiment", "confidence", "reasoning"],
#   ...
# }
Key Concept: Docstrings as Prompts

When using libraries like Instructor or PydanticAI, the class docstring and field descriptions are included in the prompt sent to the model. This means your Pydantic model definition is not just a schema — it’s part of the instruction set. Write docstrings and descriptions as if you’re explaining the expected output to the LLM.

Field Design Patterns

The way you define fields significantly affects output quality. Here are the key patterns:

from pydantic import BaseModel, Field, field_validator
from typing import Optional
from enum import Enum

# Pattern 1: Use Literal types or Enums to constrain values
class Priority(str, Enum):
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"

# Pattern 2: Use descriptive field names and descriptions
class ExtractedEntity(BaseModel):
    entity_name: str = Field(
        description="The exact name as it appears in the text"
    )
    entity_type: Literal["person", "organization", "location"] = Field(
        description="The category of the named entity"
    )
    # Pattern 3: Use Optional for fields the LLM might not find
    confidence_note: Optional[str] = Field(
        default=None,
        description="Only set if the extraction is uncertain"
    )

# Pattern 4: Nested models for complex structures
class DocumentAnalysis(BaseModel):
    """Analyze a document and extract structured information."""

    title: str = Field(description="The document title or subject")
    summary: str = Field(
        description="2-3 sentence summary of the document",
        min_length=20,
        max_length=500
    )
    entities: list[ExtractedEntity] = Field(
        description="All named entities found in the document",
        min_length=1
    )
    priority: Priority = Field(
        description="How urgent or important this document is"
    )
    tags: list[str] = Field(
        description="3-5 relevant topic tags",
        min_length=3,
        max_length=5
    )

Custom Validators for LLM Output

Pydantic validators let you enforce constraints that go beyond what JSON Schema can express. This is critical for LLM output because constrained decoding guarantees structural validity but not semantic correctness.

from pydantic import BaseModel, Field, field_validator, model_validator

class MeetingNotes(BaseModel):
    """Extract structured meeting notes from a transcript."""

    attendees: list[str] = Field(
        description="Names of people who attended the meeting"
    )
    action_items: list[dict] = Field(
        description="Tasks assigned during the meeting"
    )
    next_meeting_date: Optional[str] = Field(
        default=None,
        description="Next meeting date in YYYY-MM-DD format if mentioned"
    )

    @field_validator("next_meeting_date")
    @classmethod
    def validate_date_format(cls, v):
        if v is None:
            return v
        from datetime import datetime
        try:
            datetime.strptime(v, "%Y-%m-%d")
        except ValueError:
            raise ValueError("Date must be in YYYY-MM-DD format")
        return v

    @model_validator(mode="after")
    def check_action_items_have_owners(self):
        for item in self.action_items:
            if "owner" in item and item["owner"] not in self.attendees:
                raise ValueError(
                    f"Action item owner '{item['owner']}' "
                    f"not in attendees list"
                )
        return self

The Instructor Library

Instructor is the most popular library for Pydantic-driven LLM output, with over 3 million monthly downloads. It wraps LLM API clients to add automatic schema injection, response parsing, validation, and retry logic.

import instructor
from openai import OpenAI
from pydantic import BaseModel, Field

client = instructor.from_openai(OpenAI())

class UserProfile(BaseModel):
    """Extract user profile information from the text."""
    name: str = Field(description="Full name of the person")
    age: int = Field(ge=0, le=150, description="Age in years")
    occupation: str = Field(description="Current job title or role")
    skills: list[str] = Field(
        description="Technical or professional skills mentioned"
    )

# Instructor handles schema injection, parsing, and validation
profile = client.chat.completions.create(
    model="gpt-4o",
    response_model=UserProfile,  # Pydantic model as the interface
    messages=[{
        "role": "user",
        "content": "John Smith is a 35-year-old ML engineer "
                   "skilled in Python, PyTorch, and distributed systems."
    }],
    max_retries=3  # Auto-retry on validation failure
)

# profile is a typed UserProfile instance
print(profile.name)       # "John Smith"
print(profile.skills)     # ["Python", "PyTorch", "distributed systems"]
Key Concept: Automatic Retries with Validation Context

When a Pydantic validator fails, Instructor automatically retries the LLM call with the validation error message included in the prompt. This creates a feedback loop where the model can correct its output based on the specific validation failure. For example, if a date field fails format validation, the retry prompt includes “Date must be in YYYY-MM-DD format,” guiding the model to correct the format.

Schema Versioning and Evolution

In production systems, output schemas evolve over time — new fields are added, types change, or field semantics shift. Pydantic provides tools for managing this evolution gracefully:

Practical Applications

Data Extraction at Scale

The most common use case is structured data extraction from unstructured text. Define a Pydantic model for the target schema (invoice fields, resume sections, product attributes), pass it as the response_model, and process documents in bulk. With constrained decoding + Pydantic validation, you get consistent, typed output on every call — no custom parsing, no regex, no post-processing.

Tool Definitions for AI Agents

In agentic systems, each tool’s input and output can be defined as a Pydantic model. The model’s JSON Schema becomes the tool definition sent to the LLM, and the model’s validators ensure the LLM produces valid tool arguments. This is how frameworks like PydanticAI, LangChain, and CrewAI define tool interfaces.

Multi-Model Pipelines

In pipelines where multiple LLM calls are chained together, Pydantic models serve as the typed contract between steps. Step 1 outputs a PlanResult, Step 2 accepts a PlanResult and outputs an ExecutionResult, and so on. Type checkers and IDE autocompletion catch integration errors at development time rather than runtime.

Content Moderation and Validation

Instructor supports LLM-powered validators — validators that themselves call an LLM to check the output. For example, a validator could use a smaller, faster model to verify that a summary accurately represents the source document, or that generated content doesn’t contain harmful material. This creates a two-tier validation system: fast structural checks via Pydantic, then deeper semantic checks via LLM validators.

Schema Design Best Practices

Key Takeaways

Further Reading