Type-Safe LLM Outputs with Pydantic & Instructor: Enforcing Complex Validations and Retries
Tired of malformed JSON crashing your downstream APIs? Learn how to enforce strict type safety, custom field validators, and automated self-correction retries using Pydantic and Instructor.
The Illusion of Structured Outputs
Modern frontier models support json_mode or response_format: { type: 'json_object' }. However, having valid JSON syntax does not mean the output conforms to your application's domain logic.
An LLM might return valid JSON where an email field contains 'not-an-email', an array expected to have 5 items contains 0, or a price field contains a string with currency symbols instead of an integer in cents. Downstream TypeScript or Python backend services crash when encountering these subtle type discrepancies.
The Instructor library (built on top of Pydantic) transforms LLM outputs into guaranteed, type-safe Python dataclasses. If the model generates a field that violates a Pydantic validator, Instructor automatically feeds the validation error back to the model, executing self-correction retries in the background.
In this guide, we master Instructor and Pydantic patterns for bulletproof AI data pipelines.
Installing and Initializing Instructor with OpenAI or Anthropic
Instructor patches the standard OpenAI or Anthropic client, adding the response_model parameter to completions.create().
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, EmailStr, field_validator
# Patch client with Instructor capabilities
client = instructor.from_openai(OpenAI())
class UserProfile(BaseModel):
name: str = Field(description="Full legal name of the user")
email: EmailStr = Field(description="Validated corporate email address")
age: int = Field(ge=18, le=120, description="Age must be between 18 and 120")
skills: list[str] = Field(min_length=1, description="At least one technical skill")
@field_validator("skills")
@classmethod
def validate_skills(cls, skills: list[str]) -> list[str]:
# Enforce uppercase normalized skill tags
return [s.strip().upper() for s in skills if s.strip()]Automated Self-Correction Retries on Validation Failures
The crowning feature of Instructor is max_retries. If the model outputs an age of 14, Pydantic throws a ValidationError (Input should be greater than or equal to 18).
Instead of crashing your application, Instructor catches the exception, attaches the validation error message to the conversation history, and prompts the model to correct its mistake automatically.
untrusted_bio = "Hey, I'm Alex. Contact me at alex[at]example.com. I'm 15 and love Python and React."
try:
user: UserProfile = client.chat.completions.create(
model="gpt-4o",
response_model=UserProfile,
max_retries=3, # Automatically retries if validation fails
messages=[
{"role": "system", "content": "Extract the user profile from the text."},
{"role": "user", "content": untrusted_bio}
]
)
print(f"Successfully extracted: {user.name}, {user.email}, Age: {user.age}")
except Exception as e:
print(f"Extraction failed after 3 retries: {e}")Nested Schemas, Enums, and Discriminated Unions
Real-world data is rarely flat. Pydantic allows you to nest models, define Python Enums for deterministic status codes, and use Discriminated Unions to handle polymorphic response types.
from enum import Enum
from pydantic import BaseModel, Field
class TaxStatus(str, Enum):
EXEMPT = "exempt"
STANDARD = "standard"
REDUCED = "reduced"
class LineItem(BaseModel):
description: str
quantity: int = Field(gt=0)
unit_price_cents: int = Field(gt=0)
class Invoice(BaseModel):
invoice_number: str
tax_status: TaxStatus
items: list[LineItem]
@property
def total_cents(self) -> int:
return sum(item.quantity * item.unit_price_cents for item in this.items)TypeScript Equivalents: Enforcing Schemas with Zod and OpenAI
For fullstack teams working in TypeScript and Node.js ecosystems, the Zod library provides identical runtime type safety when paired with OpenAI's official zodResponseFormat helper.
TypeScript interfaces exist only during compile time, but Zod schemas parse, validate, and transform JSON payloads at runtime while automatically generating JSON Schema definitions for LLM tool calling.
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const openai = new OpenAI();
const ProductExtractionSchema = z.object({
productName: z.string().min(2),
sku: z.string().regex(/^[A-Z]{3}-\d{4}$/, "SKU must match format ABC-1234"),
priceInCents: z.number().int().positive(),
tags: z.array(z.string()).nonempty(),
inStock: z.boolean(),
});
export type ProductExtraction = z.infer<typeof ProductExtractionSchema>;
export async function parseProductListing(rawText: string): Promise<ProductExtraction> {
const completion = await openai.beta.chat.completions.parse({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "Extract inventory product specs." },
{ role: "user", content: rawText },
],
response_format: zodResponseFormat(ProductExtractionSchema, "product_specs"),
});
const product = completion.choices[0].message.parsed;
if (!product) throw new Error("Model failed to adhere to schema");
return product;
}Dynamic Runtime Business Logic Validation
Static types (string, number, boolean) only guarantee format. True enterprise reliability requires dynamic business validation: ensuring a zip code matches the extracted state, or verifying that item quantities match inventory limits.
Using Pydantic's @model_validator(mode='after'), you can execute cross-field consistency checks. If a business logic assertion fails, Instructor injects the explicit assertion message into the conversation context for the next correction turn.
Performance, Schema Overhead, and Strict Mode
Passing complex JSON schemas with dozens of nested properties consumes prompt tokens on every request. Keep field descriptions concise and focused on disambiguation rather than prose.
Whenever available, enable 'strict: true' mode in OpenAI API calls. Strict mode guarantees 100% adherence to the JSON schema grammar by compiling the schema directly into model sampling logits, eliminating malformed JSON syntax altogether.
- Enable Strict Mode: Constrains token sampling directly against schema grammars.
- Set max_retries = 2: 95% of subtle validator failures resolve on the first correction retry.
- Sanitize Schema Field Docs: Avoid verbose essay descriptions inside schema definitions.
Does Instructor support open-source models like Llama 3 on Ollama?
Yes! Instructor supports Ollama, vLLM, and Hugging Face via the instructor.from_openai() wrapper pointed to your local server URL.
How many retries are usually needed for difficult validations?
In 95% of cases, frontier models (like GPT-4o or Claude 3.5 Sonnet) correct their errors on the very first retry once presented with the Pydantic error message.
Can Instructor handle streaming structured output?
Yes! Instructor supports response_model=Iterable[MyClass], streaming validated objects chunk-by-chunk as the LLM emits them.
What is the difference between Pydantic Field descriptions and system prompt instructions?
Field descriptions are attached directly to the JSON schema property definitions, giving the model localized semantic context for exact fields.
Can Pydantic models execute asynchronous database lookups during validation?
Yes! With Pydantic v2 and custom async validators, you can verify whether an extracted organization ID actually exists in PostgreSQL before accepting the LLM response.