LLM Structured Outputs with Pydantic and Claude API in Production
Stop parsing JSON manually from LLM responses. Use Pydantic models with Claude API to get validated, typed structured outputs — with retry logic, partial failure handling, and real production patterns.
The #1 source of LLM application bugs in production is malformed JSON from model responses. Models hallucinate fields, swap types, return markdown instead of JSON, or truncate responses mid-object. Pydantic + Claude API solves this properly.
The Problem
# Fragile code that breaks constantly
response = client.messages.create(...)
data = json.loads(response.content[0].text) # Fails if model adds ```json wrapper
result = data["recommendations"] # KeyError if model used different field nameThe Solution: Claude API + Pydantic
Claude supports structured output natively via tool use — it forces the model to return valid JSON matching your schema.
pip install anthropic pydanticPattern 1: Tool Use for Strict Schemas
import anthropic
import json
from pydantic import BaseModel, Field, validator
from typing import Optional
class ResourceRecommendation(BaseModel):
workload: str = Field(description="namespace/deployment/container")
current_cpu: str = Field(description="Current CPU request e.g. '1000m'")
recommended_cpu: str = Field(description="Recommended CPU request e.g. '250m'")
current_memory: str = Field(description="Current memory request e.g. '2Gi'")
recommended_memory: str = Field(description="Recommended memory request")
monthly_savings_usd: float = Field(ge=0, description="Estimated monthly savings")
confidence: str = Field(description="high|medium|low")
reason: str = Field(description="One sentence explanation")
@validator("confidence")
def validate_confidence(cls, v):
if v not in ("high", "medium", "low"):
raise ValueError(f"confidence must be high/medium/low, got: {v}")
return v
class OptimizationReport(BaseModel):
recommendations: list[ResourceRecommendation]
total_monthly_savings: float = Field(ge=0)
critical_issues: list[str] = Field(default_factory=list)
summary: str
def get_structured_analysis(workload_data: str) -> OptimizationReport:
client = anthropic.Anthropic()
# Define the schema as a Claude tool
tool_schema = {
"name": "submit_optimization_report",
"description": "Submit the structured optimization analysis",
"input_schema": OptimizationReport.model_json_schema()
}
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=4000,
tools=[tool_schema],
tool_choice={"type": "tool", "name": "submit_optimization_report"},
messages=[{
"role": "user",
"content": f"Analyze these Kubernetes workloads and submit an optimization report:\n\n{workload_data}"
}]
)
# Extract tool use result
for block in response.content:
if block.type == "tool_use":
# Pydantic validates automatically — raises ValidationError if invalid
return OptimizationReport(**block.input)
raise ValueError("Model did not call the expected tool")Pattern 2: Retry with Validation Feedback
When Claude returns invalid data, send the error back and let it self-correct:
from pydantic import ValidationError
import time
def get_with_retry(
prompt: str,
model_class: type[BaseModel],
max_retries: int = 3
) -> BaseModel:
client = anthropic.Anthropic()
last_error = None
messages = [{"role": "user", "content": prompt}]
for attempt in range(max_retries):
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=2000,
messages=messages
)
text = response.content[0].text.strip()
# Extract JSON from markdown code blocks if present
if "```" in text:
parts = text.split("```")
for i, part in enumerate(parts):
if part.startswith("json\n"):
text = part[5:]
break
elif i % 2 == 1: # Odd indices are inside code blocks
text = part
break
try:
data = json.loads(text)
return model_class(**data)
except (json.JSONDecodeError, ValidationError) as e:
last_error = e
error_msg = str(e)
# Add the failed response + error to messages for self-correction
messages.append({"role": "assistant", "content": response.content[0].text})
messages.append({
"role": "user",
"content": f"Your response had a validation error:\n{error_msg}\n\nPlease fix it and return valid JSON matching the schema."
})
if attempt < max_retries - 1:
time.sleep(1)
raise ValueError(f"Failed after {max_retries} retries. Last error: {last_error}")Pattern 3: Partial Validation for Large Responses
When you expect a list and the model might return partial data:
from pydantic import BaseModel
from typing import Any
class PartialList(BaseModel):
items: list[Any]
is_complete: bool = True
def parse_list_response(text: str, item_class: type[BaseModel]) -> list[BaseModel]:
"""Parse a list response, validating each item individually."""
text = text.strip()
if "```" in text:
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
try:
raw_list = json.loads(text)
if not isinstance(raw_list, list):
raw_list = raw_list.get("items", raw_list.get("recommendations", []))
except json.JSONDecodeError:
return []
valid_items = []
for i, item in enumerate(raw_list):
try:
valid_items.append(item_class(**item))
except ValidationError as e:
print(f"Item {i} failed validation (skipping): {e}")
return valid_itemsPattern 4: Enum Fields That Always Validate
from enum import Enum
class Severity(str, Enum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class Alert(BaseModel):
title: str
severity: Severity # Pydantic validates this is a valid enum value
affected_service: str
recommended_action: str
# Claude must return "critical", "high", "medium", or "low" for severity
# If it returns "CRITICAL" or "Critical", Pydantic handles case-insensitive matching
# for str Enums automaticallyProduction Checklist
# 1. Always set max_tokens high enough — truncated JSON fails validation
response = client.messages.create(max_tokens=4000, ...)
# 2. Use tool_choice to force structured output
tool_choice={"type": "tool", "name": "your_tool_name"}
# 3. Log validation failures for monitoring
import logging
try:
result = MyModel(**data)
except ValidationError as e:
logging.error("LLM validation failure", extra={"errors": e.errors(), "raw": text})
raise
# 4. Set reasonable field constraints
class Report(BaseModel):
score: float = Field(ge=0, le=100) # 0-100 range
items: list[str] = Field(max_length=20) # No unbounded lists
summary: str = Field(max_length=500) # No runaway text
# 5. Use Optional for fields the model might omit
class Result(BaseModel):
required_field: str
optional_detail: Optional[str] = None # Won't fail if model skips itTesting Your Schema
Before shipping to production, test your schema with adversarial prompts:
import pytest
def test_validation_rejects_invalid_confidence():
with pytest.raises(ValidationError):
ResourceRecommendation(
workload="prod/api/app",
current_cpu="1000m",
recommended_cpu="250m",
current_memory="2Gi",
recommended_memory="512Mi",
monthly_savings_usd=150.0,
confidence="very_high", # Invalid!
reason="Over-provisioned"
)
def test_real_claude_response_validates():
result = get_structured_analysis("Sample workload data...")
assert isinstance(result, OptimizationReport)
assert result.total_monthly_savings >= 0
for rec in result.recommendations:
assert rec.confidence in ("high", "medium", "low")Structured outputs with Pydantic is the difference between an LLM application that works in demos and one that runs reliably at 3 AM in production.
More LLMOps patterns? Read our LLM output validation guide and LLM error handling and retry patterns.
Today I Fixed
Short real fixes from production — posted daily
Stay ahead of the curve
Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.
Related Articles
Build an AI Log Pattern Classifier with Claude API
Build a production-ready log pattern classifier using Claude API that automatically categorizes log lines into errors, warnings, anomalies, and noise — saving on-call engineers hours of manual log triage.
Build an AI SRE Incident Commander with Claude API
Step-by-step tutorial to build an AI incident commander that takes an alert, gathers context from Kubernetes and AWS, generates a structured runbook, and coordinates the incident response — using Claude API with tool use.
LLM Context Window Strategies for Production: Summarization, Chunking, and RAG
Running out of context window in production LLM apps? This guide covers summarization pipelines, sliding window patterns, RAG for infinite context — with Python code for each strategy.