Skip to content
Blog

Structured Outputs for Agents: JSON Schema Validation with Pydantic

Ensure reliable agent outputs with JSON Schema validation using Pydantic. Learn to define typed tool outputs, validate LLM responses, and build production-grade structured output pipelines.

Published on September 6, 2026

AI Assistant

LLMs generate text. Your applications need structured data. Structured outputs bridge this gap by constraining LLM responses to conform to a defined JSON Schema, ensuring every output is valid, parseable, and ready for downstream processing.

Pydantic is the de facto standard for this in Python, providing type-safe data validation that integrates directly with LLM tool definitions and response schemas.

In this tutorial, you will learn how to define structured outputs for agents, validate tool calls with Pydantic, and build reliable agent pipelines that produce deterministic, schema-compliant results.

Why Structured Outputs

Without structured outputs, you face:

  • Parsing failures: LLMs sometimes return malformed JSON
  • Type errors: Numbers as strings, missing fields, wrong types
  • Hallucinated fields: Output containing fields you didn’t request
  • Retry loops: Wasting tokens on invalid responses

Structured outputs solve these problems by:

  1. Schema enforcement: The model is constrained to produce valid JSON
  2. Type validation: Pydantic validates every field against your schema
  3. Deterministic parsing: No regex or string manipulation needed
  4. Error handling: Clear validation errors for debugging

Defining Output Schemas with Pydantic

Pydantic models define the expected structure of LLM outputs:

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

class Sentiment(str, Enum):
    POSITIVE = "positive"
    NEGATIVE = "negative"
    NEUTRAL = "neutral"

class AnalysisResult(BaseModel):
    sentiment: Sentiment
    confidence: float = Field(ge=0.0, le=1.0)
    key_topics: list[str]
    summary: str
    action_items: Optional[list[str]] = None

# Generate JSON Schema
schema = AnalysisResult.model_json_schema()
print(schema)

This produces a JSON Schema that can be passed directly to LLM providers:

{
  "type": "object",
  "properties": {
    "sentiment": {"enum": ["positive", "negative", "neutral"]},
    "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
    "key_topics": {"type": "array", "items": {"type": "string"}},
    "summary": {"type": "string"},
    "action_items": {"type": "array", "items": {"type": "string"}, "nullable": true}
  },
  "required": ["sentiment", "confidence", "key_topics", "summary"]
}

Using Structured Outputs in Agents

Tool Output Validation

Define tool outputs with Pydantic schemas:

from agents import Agent, function_tool
from pydantic import BaseModel

class WeatherData(BaseModel):
    temperature: float
    condition: str
    humidity: int
    wind_speed: float

@function_tool
def get_weather(city: str) -> WeatherData:
    """Get current weather for a city."""
    return WeatherData(
        temperature=72.0,
        condition="sunny",
        humidity=45,
        wind_speed=8.5,
    )

agent = Agent(
    name="Weather assistant",
    instructions="Use the get_weather tool to answer weather questions.",
    tools=[get_weather],
)

The tool return value is automatically validated against the Pydantic schema. If the data doesn’t match, a validation error is raised before it reaches the model.

Response Schema Enforcement

Constrain the entire agent output to a schema:

from agents import Agent
from pydantic import BaseModel

class TaskPlan(BaseModel):
    task: str
    steps: list[str]
    estimated_time_minutes: int
    required_tools: list[str]
    risk_level: str

agent = Agent(
    name="Planner",
    instructions="Create detailed task plans for user requests.",
    output_type=TaskPlan,
)

The agent will always return a TaskPlan instance, with all fields validated.

Advanced Validation Patterns

Field Constraints

Use Pydantic’s Field for fine-grained validation:

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0, le=10000)
    description: str = Field(max_length=500)
    tags: list[str] = Field(min_length=1, max_length=10)
    sku: str = Field(pattern=r'^[A-Z]{2}-\d{4}$')

Custom Validators

Add custom validation logic:

from pydantic import BaseModel, field_validator

class EmailContact(BaseModel):
    email: str
    name: str

    @field_validator('email')
    @classmethod
    def validate_email(cls, v):
        if '@' not in v:
            raise ValueError('Invalid email address')
        return v.lower()

Nested Models

Compose complex schemas from simpler models:

class Address(BaseModel):
    street: str
    city: str
    country: str
    postal_code: str

class Customer(BaseModel):
    name: str
    email: str
    address: Address
    orders: list['Order']

class Order(BaseModel):
    order_id: str
    items: list['OrderItem']
    total: float

class OrderItem(BaseModel):
    product_id: str
    quantity: int
    price: float

Handling Validation Errors

When an LLM response doesn’t match the schema, handle it gracefully:

from pydantic import ValidationError

async def run_with_validation(agent, prompt):
    try:
        result = await agent.run(prompt)
        return result
    except ValidationError as e:
        # Log the validation error
        print(f"Validation failed: {e}")

        # Optionally retry with error context
        retry_prompt = f"{prompt}\n\nNote: Your previous response had errors: {e}"
        return await agent.run(retry_prompt)

Streaming Structured Outputs

When streaming agent responses, structured outputs are still validated:

async for event in agent.run_streamed(prompt):
    if event.type == "output_text":
        # Text is validated as it streams
        partial_result = parse_partial(event.text)

Tool Definition Schemas

Pydantic models automatically generate tool definitions for LLMs:

from agents import function_tool
from pydantic import BaseModel

class SearchQuery(BaseModel):
    query: str
    max_results: int = 10
    filters: dict[str, str] | None = None

@function_tool
def search_documents(query: SearchQuery) -> list[dict]:
    """Search the document database."""
    # Implementation here
    pass

# The tool schema is automatically generated from SearchQuery

Comparison with JSON Schema

Pydantic generates JSON Schema automatically:

FeaturePydanticManual JSON Schema
Type safetyCompile-time checksRuntime only
ValidationAutomaticManual
Serializationmodel_dump()json.dumps()
Documentationmodel_json_schema()Manual
RefactoringIDE supportNone

Best Practices

  1. Start simple: Define minimal schemas first, add constraints as needed
  2. Use enums: Constrain string fields to known values
  3. Make optional fields explicit: Use Optional or None defaults
  4. Validate early: Check inputs before they reach the LLM
  5. Handle failures gracefully: Plan for validation errors in production
  6. Document schemas: Use Field descriptions for LLM context

Next Steps

Structured outputs turn unpredictable LLM text into reliable, validated data. With Pydantic, you get type safety, clear schemas, and automatic validation—all critical for production agent systems.