Getting LLMs to emit strict JSON is brittle until you enforce a schema. The instructor library structured outputs pattern wraps the OpenAI SDK with Pydantic models, turning fuzzy completions into validated Python objects. This tutorial builds a working extraction pipeline from scratch.
Prerequisites
- Python 3.10+ (Pydantic v2 requires it)
pip install openai instructor pydantic- An API key from OpenAI, or any OpenAI-compatible endpoint. If you point the client at n4n.ai’s OpenAI-compatible endpoint, one URL covers 240+ models and gives automatic fallback when a provider is rate-limited.
Set the key in your environment:
export OPENAI_API_KEY="sk-..."
Patch the OpenAI client
Instructor monkeypatches the OpenAI SDK so chat.completions.create accepts a response_model argument. You lose no native functionality.
import os
from openai import OpenAI
import instructor
# Standard OpenAI
client = instructor.from_openai(
OpenAI(api_key=os.environ["OPENAI_API_KEY"])
)
# Alternative: route through a gateway
# client = instructor.from_openai(
# OpenAI(api_key=os.environ["N4N_KEY"], base_url="https://api.n4n.ai/v1")
# )
The returned client behaves like the normal OpenAI client but understands Pydantic.
Define your output schema
Pydantic is the contract. Field descriptions get injected into the prompt as schema hints.
from pydantic import BaseModel, Field
class UserProfile(BaseModel):
name: str
age: int = Field(description="Age in whole years")
interests: list[str] = Field(description="Short tags, lowercase")
Run your first extraction
Call the model exactly as you would with the OpenAI SDK, but pass the model class.
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "Jane is 29, loves climbing and jazz."}
],
response_model=UserProfile,
)
print(resp)
Expected output:
name='Jane' age=29 interests=['climbing', 'jazz']
The instructor library structured outputs flow validated the types before returning. If the model emitted age: "29", Instructor would coerce or retry.
Nested and repeated structures
Real payloads are rarely flat. Define composed models and let Instructor handle the recursion.
from pydantic import BaseModel
class Address(BaseModel):
city: str
zip_code: str
class Contact(BaseModel):
name: str
address: Address
phones: list[str]
text = "Sam lives in Berlin, 10115. Reach him at +49-30-1234 or +49-170-555."
contact = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": text}],
response_model=Contact,
)
print(contact)
Expected:
name='Sam' address=Address(city='Berlin', zip_code='10115') phones=['+49-30-1234', '+49-170-555']
Retries and validation errors
Models hallucinate malformed data. Instructor retries with the validation error fed back into the context. Configure max_retries at patch time.
client = instructor.from_openai(OpenAI(), max_retries=2)
try:
bad = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Color is blue, no age given"}],
response_model=UserProfile,
)
except Exception as e:
print("Failed after retries:", e)
If the model cannot satisfy age: int, Instructor exhausts retries and raises. Catch it and fall back to a sane default or a cheaper model.
Stream partial objects
For long responses, stream partial Pydantic objects as they populate.
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Tom, 42, likes skiing, ramen, rust"}],
response_model=UserProfile,
stream=True,
)
for partial in stream:
print(partial)
You’ll see incremental fills:
name='Tom' age=0 interests=[]
name='Tom' age=42 interests=[]
name='Tom' age=42 interests=['skiing']
name='Tom' age=42 interests=['skiing', 'ramen', 'rust']
This is useful for UX where you render fields as they arrive.
Enforce enum constraints
Pydantic Enum or Literal narrows outputs without post-processing.
from enum import Enum
class Tier(str, Enum):
free = "free"
pro = "pro"
ent = "enterprise"
class Account(BaseModel):
user: str
tier: Tier
acc = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Megan upgraded to enterprise"}],
response_model=Account,
)
print(acc.tier)
Prints Tier.ent. The instructor library structured outputs approach maps the enum cleanly; raw JSON mode would return a string you’d still need to validate.
When to use Instructor vs raw JSON mode
OpenAI’s native response_format={"type": "json_object"} gives you a JSON blob and nothing more. You still parse, validate, and retry manually. Instructor buys you:
- Automatic schema → function-args conversion
- Pydantic validation with retry loops
- Streaming partials
- Clean Python types
If you need absolute minimal latency and are hand-rolling validation, raw mode is fine. For any pipeline that touches production, the instructor library structured outputs pattern removes a class of silent failures.
Meter and route in production
When you deploy, wrap the client with your gateway’s routing headers. n4n.ai honors client routing directives and forwards provider cache-control hints, so you can pin a model or force cache revalidation per call without changing application code. Per-token usage metering shows up in responses as usual via usage fields.
# Example extra headers if your gateway supports them
client = instructor.from_openai(
OpenAI(
base_url="https://api.n4n.ai/v1",
default_headers={"x-routing": "fallback:anthropic,openai"}
)
)
That’s the whole loop: define schema, patch client, call with response_model, handle validation. Ship it.