Structured Output with the Instructor Library
Getting a language model to return clean, typed data instead of a wall of prose is one of the most common headaches in production AI work, and the instructor library exists to solve exactly that problem. Instructor patches your existing LLM client (OpenAI, Anthropic, and several others) so that instead of parsing a raw string response, you get back a validated Pydantic model, with automatic retries when the model's output does not match your schema. If you have ever written a regex to scrape JSON out of a chat completion, or watched a pipeline break because the model added a trailing comma, this article shows you how to stop doing that.
What Is the Instructor Library
The instructor library is a thin wrapper around LLM SDKs that adds a response_model parameter to your normal completion call. You define your desired output shape as a Pydantic BaseModel, pass it in, and instructor handles three things behind the scenes: it turns your schema into a tool/function definition the model can call, it parses the model's tool call arguments back into your Pydantic class, and it validates the result. If validation fails, instructor automatically sends the error back to the model and asks it to correct itself, up to a retry limit you control.
This matters because raw JSON mode on most providers gives you a string that is *usually* valid JSON but has no guarantee about field names, types, or required keys. Instructor closes that gap by tying the extraction directly to a schema you already trust, since Pydantic is the same validation library most Python web frameworks (FastAPI included) already use.
Install it with pip:
pip install instructor
pip install anthropic
pip install openaiYou only need the SDK for whichever provider you're targeting; instructor detects which client you pass it and wraps accordingly.
Installing and Setting Up Instructor
The core workflow has three parts: define a Pydantic model, wrap your client with instructor.from_openai() or instructor.from_anthropic(), then call the wrapped client with response_model set to your class.
import instructor
from pydantic import BaseModel, Field
from openai import OpenAI
class UserProfile(BaseModel):
name: str
age: int
occupation: str
client = instructor.from_openai(OpenAI())
profile = client.chat.completions.create(
model="gpt-4o-mini",
response_model=UserProfile,
messages=[
{"role": "user", "content": "Extract: Priya is a 29 year old data engineer."}
],
)
print(profile)
print(type(profile))Running this prints a UserProfile instance, not a string. profile.name, profile.age, and profile.occupation are already typed and accessible as normal Python attributes. There is no json.loads, no manual key checking, no try/except around a KeyError.
Defining Your First Structured Output Schema
Pydantic gives you far more than a flat list of fields. You can add descriptions that guide the model's extraction, constrain value ranges, and mark fields optional.
from typing import Optional
from pydantic import BaseModel, Field
class SupportTicket(BaseModel):
summary: str = Field(description="One sentence summary of the customer's issue")
category: str = Field(description="One of: billing, bug, feature_request, other")
urgency: int = Field(ge=1, le=5, description="1 is low urgency, 5 is critical")
customer_email: Optional[str] = Field(default=None, description="Email if mentioned")The description argument on Field is not decoration, it becomes part of the schema instructor sends to the model, so it directly shapes how the model fills each slot. The ge=1, le=5 constraint is enforced by Pydantic after the model responds; if the model returns urgency=7, instructor catches that as a validation error and retries automatically rather than silently letting bad data through.
ticket = client.chat.completions.create(
model="gpt-4o-mini",
response_model=SupportTicket,
messages=[
{
"role": "user",
"content": "My invoice charged me twice this month, this is urgent, "
"please fix it. Contact me at dana@example.com",
}
],
)
print(ticket.model_dump())model_dump() converts the Pydantic object back into a plain dict, which is handy when you need to hand the result to a database write or a downstream API call.
Using Instructor with Anthropic Claude Models
Instructor supports Claude models through the same pattern, using instructor.from_anthropic() instead of from_openai(). The main difference is that Anthropic's Messages API requires max_tokens explicitly, and instructor's Anthropic mode uses Claude's native tool-calling to enforce the schema.
import instructor
from anthropic import Anthropic
from pydantic import BaseModel
class MeetingNotes(BaseModel):
title: str
action_items: list[str]
attendees: list[str]
client = instructor.from_anthropic(Anthropic())
notes = client.chat.completions.create(
model="claude-sonnet-4-5",
max_tokens=1024,
response_model=MeetingNotes,
messages=[
{
"role": "user",
"content": (
"Notes: Sync with Arjun and Meera. Decided to ship the "
"onboarding redesign by Friday. Arjun will write the migration "
"script, Meera will update the docs."
),
}
],
)
print(notes.action_items)
print(notes.attendees)Because instructor normalizes the interface across providers, switching this code from Claude to a GPT model later is mostly a matter of swapping the client wrapper and model name; the response_model and message structure stay the same. That portability is one of the strongest reasons teams reach for instructor instead of hand-rolling provider-specific tool-calling code for every integration.
Validation, Retries, and Self-Correction
The retry loop is where instructor earns its keep in production. When the model returns something that fails Pydantic validation, whether that's a missing required field, a value outside a constrained range, or a malformed nested object, instructor does not just throw an exception at you. It feeds the validation error back to the model as part of the conversation and asks for a corrected response.
from pydantic import BaseModel, field_validator
class InvoiceLineItem(BaseModel):
description: str
quantity: int
unit_price: float
@field_validator("quantity")
@classmethod
def quantity_must_be_positive(cls, v):
if v <= 0:
raise ValueError("quantity must be a positive integer")
return v
item = client.chat.completions.create(
model="gpt-4o-mini",
response_model=InvoiceLineItem,
max_retries=3,
messages=[
{"role": "user", "content": "Line item: 5 units of USB-C cable at $4.99 each"}
],
)
print(item)The max_retries parameter caps how many correction attempts instructor makes before it gives up and raises a ValidationError to your code. In practice, most retries resolve on the first correction pass because the error message tells the model precisely which field and constraint it violated, which is far more actionable feedback than a bare parsing failure.
You can also write custom field_validator and model_validator methods, exactly like you would in a standalone Pydantic project. Instructor does not reinvent validation, it just wires Pydantic's existing validation machinery into the retry loop.
Streaming Partial Objects
For long extractions, waiting for the entire response before you see anything feels slow. Instructor supports partial streaming, where you get progressively more complete versions of your object as tokens arrive.
from instructor import Partial
class Article(BaseModel):
title: str
summary: str
tags: list[str]
stream = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Partial[Article],
stream=True,
messages=[
{"role": "user", "content": "Summarize this article about renewable energy trends..."}
],
)
for partial in stream:
print(partial)Each yielded partial is a version of Article where fields not yet generated are None, and fields that arrived are populated. This is useful for building UIs where you want to render a title as soon as it exists rather than blocking on the full response.
For structured outputs that model a list of items, instructor also provides instructor.Iterable, which streams each fully-formed item in a list as soon as the model finishes it, rather than partially filling one giant list object.
from typing import Iterable as TypingIterable
class Task(BaseModel):
title: str
priority: str
tasks = client.chat.completions.create(
model="gpt-4o-mini",
response_model=TypingIterable[Task],
stream=True,
messages=[
{"role": "user", "content": "Extract tasks: Fix login bug (high). Update README (low). Add tests (medium)."}
],
)
for task in tasks:
print(task.title, task.priority)Extracting Structured Data from Unstructured Text
A common real-world use case is pulling structured records out of documents that were never designed to be machine-readable, like emails, PDFs converted to plain text, or scraped web pages.
class Contact(BaseModel):
full_name: str
company: Optional[str] = None
role: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
class ContactList(BaseModel):
contacts: list[Contact]
raw_signature_block = """
Best,
Rahul Mehta
Senior Product Manager, Northwind Analytics
rahul.mehta@northwind.example
+1-555-0199
"""
result = client.chat.completions.create(
model="gpt-4o-mini",
response_model=ContactList,
messages=[
{"role": "user", "content": f"Extract all contacts:\n{raw_signature_block}"}
],
)
for c in result.contacts:
print(c.full_name, c.company, c.email)Wrapping the target schema in a container model like ContactList is a pattern worth memorizing: it handles the case where zero, one, or many records exist in the source text without you having to write separate code paths for each case.
Handling Nested and Complex Schemas
Instructor handles arbitrarily nested Pydantic models, which lets you model real-world data structures instead of flattening everything into strings.
class Address(BaseModel):
street: str
city: str
country: str
class Order(BaseModel):
order_id: str
items: list[str]
total_amount: float
shipping_address: Address
is_gift: bool = False
order = client.chat.completions.create(
model="gpt-4o-mini",
response_model=Order,
messages=[
{
"role": "user",
"content": (
"Order ORD-4471: 2x wireless mouse, 1x keyboard, total $87.50, "
"ship to 22 Baker Street, London, UK. This is a gift."
),
}
],
)
print(order.shipping_address.city)
print(order.is_gift)Instructor converts the nested Address model into a nested JSON schema automatically, so the model sees the full structure it needs to fill in, including which fields belong under shipping_address versus at the top level of Order.
For schemas where a field could be one of several distinct shapes, use a discriminated union or Literal types to keep the model's choices constrained:
from typing import Literal, Union
class EmailAction(BaseModel):
type: Literal["email"]
recipient: str
subject: str
class CalendarAction(BaseModel):
type: Literal["calendar"]
event_title: str
date: str
class ExtractedAction(BaseModel):
action: Union[EmailAction, CalendarAction]Constraining the type field with Literal values narrows the model's decision space and makes downstream code (a simple if action.type == "email" check) much safer than branching on free-form strings.
Why Use the Instructor Library Over Raw JSON Mode
Most providers offer some flavor of "JSON mode," which forces the model to emit syntactically valid JSON. That solves parsing but not correctness. JSON mode will happily give you valid JSON with the wrong keys, missing fields, or a string where you expected an integer. The instructor library adds a validation layer on top of that guarantee, plus the retry loop that automatically repairs mismatches.
There is also a developer-experience gap. With raw JSON mode you typically write your schema twice, once as a JSON Schema dict for the API call, and once as whatever type your application code expects. Instructor collapses that into a single Pydantic model, so your schema, your validation rules, and your runtime type are the same object.
The tradeoff is a small amount of overhead per call for the retry mechanism and an added dependency. For high-throughput pipelines processing millions of records, you may want to benchmark how often retries actually trigger for your particular schema and prompt combination, since each retry is an additional round trip to the model.
Common Pitfalls and How to Avoid Them
Overly permissive schemas. If every field is Optional, instructor has nothing to enforce, and you lose the main benefit of structured output. Mark fields required unless they are genuinely optional in the source data.
Vague field descriptions. A field named status with no description leaves the model guessing at your intended vocabulary. Use Field(description=...) or, better, constrain it with Literal["open", "closed", "pending"] so there is no ambiguity to guess at.
Ignoring `max_retries`. Leaving retries at a default of zero or one means transient validation failures surface as exceptions in production instead of being quietly repaired. Set a small retry budget (2 to 4 is usually enough) for anything user-facing.
Mixing extraction and generation in one call. Asking a model to both write a creative summary and extract five strict fields in the same schema often produces worse results on both tasks. Split creative generation and structured extraction into separate calls when quality matters.
Not validating at the boundary. Some teams pass instructor's output straight into a database insert without re-checking business rules that live outside Pydantic (like uniqueness or foreign key existence). Instructor validates shape and type, not your entire business logic, so keep your normal application-level checks in place.
FAQ
Does the instructor library work with models other than OpenAI and Anthropic? Yes. Instructor supports a range of providers and local model runners through the same response_model pattern, since it builds on top of each provider's native function-calling or tool-use API. The core workflow (define a Pydantic model, wrap the client, pass response_model) stays consistent across providers, though you should check the current provider list before committing to one for a new project, since support varies by how mature each provider's tool-calling API is.
What happens if the model can never produce valid output within the retry limit? Instructor raises a Pydantic ValidationError (or an instructor-specific exception, depending on version) after exhausting max_retries. Your code should catch this and decide on a fallback: log the failure, retry with a simplified schema, or hand the case off to human review. Silently swallowing this exception is a common mistake; treat it as a signal that either your schema is too strict or your prompt needs more context.
Can I use instructor for function calling beyond structured extraction, like building an agent? Yes, the same schema-plus-validation mechanism that extracts structured data can define the arguments for tools an agent calls. Many teams use instructor as the argument-parsing layer under a custom agent loop, since it gives you validated, typed arguments instead of hoping the model's raw tool call JSON matches what your function expects.
Is instructor slower than calling the API directly? There is a small overhead for schema conversion and Pydantic validation, which is negligible compared to network latency and model generation time. The bigger cost is retries: if your schema or prompt regularly triggers correction loops, that adds real latency. Tightening your Field descriptions and adding examples in the prompt usually reduces retry frequency more effectively than trying to optimize instructor's internals.
Do I need to change my prompts to use instructor? Not dramatically. You still write a normal user message describing the task; instructor handles injecting the schema as a tool definition rather than requiring you to describe the JSON shape in the prompt text yourself. That said, giving the model context about ambiguous fields, either through the prompt or through Field(description=...), still improves accuracy the same way it would with any function-calling setup.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.