The minimal way to make an LLM return validated, typed objects: patch the model client so create() takes a response_model and hands back a Pydantic instance, re-asking the model whenever validation fails. Not an agent framework, just the structured-output slice, done well.
The mental model
Instructor is a thin library. It wraps a normal LLM client (OpenAI, Anthropic, Gemini, and more) and adds a single capability: create() now accepts a response_model, a Pydantic BaseModel, and returns a validated instance of it instead of a raw string. Under the hood it turns your model into a JSON schema or tool definition, prompts the LLM to fill it, parses the reply, and runs Pydantic validation. If validation fails, it feeds the error message back to the model and asks again, up to max_retries times. When it returns, you have a typed object your IDE understands, not a paragraph you have to json.loads and pray over.
That is the whole product. It is not an agent framework: no graph, no tool loop, no memory, no orchestration. Contrast it with Pydantic AI, which is a full agent framework built by the Pydantic team. Instructor is just the "parse and validate the model output" slice, and it is deliberately small enough to add to code you already have.
flowchart LR P["your prompt
plus schema"] --> LLM["LLM"] LLM --> PV["parse and
validate"] PV -->|"valid"| OBJ["typed
object"] PV -->|"invalid"| RE["re-ask with
the error"] RE --> LLM classDef model fill:#EEE6FF,stroke:#6D28D9,color:#09244B; classDef code fill:#D6F5E3,stroke:#1B7F46,color:#09244B; class LLM model; class P,PV,OBJ,RE code;
It is the try/parse/validate/retry loop you would otherwise write by hand, packaged as one argument. You describe the shape you want as a Pydantic model; Instructor makes the model produce that shape or keeps asking until it does.
Install & setup
Instructor is Python-first (a TypeScript port exists, but we use Python throughout). It builds on Pydantic v2. Install it alongside your provider SDK, or install a bundle extra.
# the library (pulls in pydantic; openai client is a common default)
pip install instructor
# or grab a provider bundle extra
pip install "instructor[anthropic]" # or [google-genai], [vertexai], ...
# set the key for whatever provider you target
export OPENAI_API_KEY="sk-..."
Setup · patching a client
There are two entry points. The explicit one wraps a client object you construct yourself, which is best when you already configure the SDK (base URL, org, timeouts). The provider-string one lets Instructor build the client for you from a single string, which is the terse, provider-agnostic path.
import instructor
from openai import OpenAI
# 1) patch a client you constructed yourself
client = instructor.from_openai(OpenAI())
# same shape for other providers:
# from anthropic import Anthropic
# client = instructor.from_anthropic(Anthropic())
# client = instructor.from_gemini(...) # or from_genai for the google-genai SDK
The provider-string form is newer and the docs lead with it. from_provider takes a "provider/model" string, builds the right client, and returns it patched. Because the call surface after that is identical, you can swap providers by editing one string. Pass async_client=True for the async variant.
import instructor
# 2) name the provider and model; instructor builds and patches the client
client = instructor.from_provider("openai/gpt-4.1-mini")
# client = instructor.from_provider("anthropic/claude-sonnet-4-5")
# client = instructor.from_provider("google/gemini-2.5-flash")
# client = instructor.from_provider("ollama/llama3.2") # local, via litellm-style routing
A patched client is still your provider client: client.chat.completions.create(...) without a response_model behaves exactly as before. Instructor only changes behaviour when you pass response_model. You can also call the terser client.create(...) that the patch adds.
Basic extraction
Define a Pydantic model, pass it as response_model, and the return value is an instance of that model, already typed and validated. The Field descriptions are not decoration: they become part of the schema the model sees, so they double as inline prompting.
import instructor
from pydantic import BaseModel, Field
client = instructor.from_provider("openai/gpt-4.1-mini")
class User(BaseModel):
name: str
age: int = Field(description="age in whole years") # description guides the model
user = client.create(
response_model=User,
messages=[{"role": "user", "content": "Jason is a 25 year old scientist"}],
)
print(user.name, user.age) # Jason 25 -> a real User, not a dict or a string
isinstance(user, User) # True
That is the ninety-percent case. Everything below is about making that one call more robust: validation, retries, nesting, streaming, and graceful "not found".
Validation & retries · the core value
This is why Instructor exists. Pydantic validation runs on the parsed output. If it raises, Instructor does not just fail: it takes the validation error message, appends it to the conversation, and re-asks the model to correct itself. The re-ask is guided, the model is told exactly what was wrong, so the second attempt is usually right. Set the budget with max_retries.
from pydantic import BaseModel, field_validator
class User(BaseModel):
name: str
age: int
@field_validator("name")
@classmethod
def name_must_be_uppercase(cls, v: str) -> str:
if v != v.upper():
raise ValueError("name must be entirely UPPERCASE") # this text is fed back
return v
user = client.create(
response_model=User,
max_retries=3, # up to 3 attempts before giving up
messages=[{"role": "user", "content": "extract: jason is 25"}],
)
print(user.name) # JASON -> model self-corrected after the first reply failed validation
The re-ask loop, drawn out: the model answers, Pydantic checks it, and only a passing object leaves the function. A failing one loops back carrying the reason.
flowchart LR C["client.create
response_model set"] --> M["LLM drafts
the object"] M --> V{"pydantic
validates"} V -->|"passes"| OUT["return typed
instance"] V -->|"raises"| F["append error text,
retries left"] F -->|"budget remains"| M F -->|"budget spent"| ERR["raise
ValidationError"] classDef model fill:#EEE6FF,stroke:#6D28D9,color:#09244B; classDef code fill:#D6F5E3,stroke:#1B7F46,color:#09244B; class M model; class C,V,OUT,F,ERR code;
For fine control, pass a Tenacity Retrying (or AsyncRetrying) object instead of an integer, so you can add exponential backoff, stop conditions, or retry only on specific exceptions.
from tenacity import Retrying, stop_after_attempt, wait_fixed
user = client.create(
response_model=User,
max_retries=Retrying(stop=stop_after_attempt(3), wait=wait_fixed(1)),
messages=[{"role": "user", "content": "extract: jason is 25"}],
)
Validators · Annotated + AfterValidator
Instead of begging the model in prose to "please only return valid data", encode the rule as a validator and let the retry loop enforce it. The most reusable form is Annotated with AfterValidator: a plain function that runs after Pydantic parses the field and either returns a cleaned value or raises. Because it is attached to the type, you can reuse it across models.
from typing import Annotated
from pydantic import BaseModel, AfterValidator, Field
def no_placeholder(v: str) -> str:
if v.strip().lower() in {"n/a", "unknown", "none"}:
raise ValueError("return a real value, not a placeholder")
return v.strip()
# a reusable, self-validating type
RealName = Annotated[str, AfterValidator(no_placeholder)]
class Contact(BaseModel):
name: RealName
email: Annotated[str, Field(pattern=r"[^@]+@[^@]+\.[^@]+")] # regex is validation too
# if the model emits "unknown" or a malformed email, instructor re-asks with the reason
contact = client.create(response_model=Contact, max_retries=2, messages=[...])
A validator that raises is worth more than a paragraph of instructions, because it is checked. The model cannot talk its way past it; it either satisfies the rule or the call fails loudly. That is validation at the boundary, made mechanical.
Nested models & lists
Pydantic models nest, and Instructor extracts the whole tree in one call. Use a nested BaseModel for structure and list[...] for repetition. This is how you go from "pull one field" to "parse a whole document into a typed object graph".
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
class User(BaseModel):
name: str
age: int
addresses: list[Address] # a list of nested models, all validated
user = client.create(
response_model=User,
messages=[{"role": "user", "content": "Ana, 31, lives at 5 Oak St, Leeds and 2 Elm Rd, York"}],
)
print(user.addresses[0].city) # Leeds
To extract a top-level list of records rather than one object, you can set response_model=list[User] directly, but for many records the streaming create_iterable below is usually the better tool.
Streaming · partials and iterables
Instructor gives you two streaming primitives, and they solve different problems. Do not confuse them.
Use create_partial when you want one object to appear progressively, every field made Optional so you can render it as it fills. This is what powers a form that populates live while the model thinks.
class Profile(BaseModel):
name: str
headline: str
summary: str
stream = client.create_partial(
response_model=Profile,
messages=[{"role": "user", "content": "write a profile for a data engineer"}],
)
for partial in stream:
print(partial) # Profile(name='Sam', headline=None, ...) -> fields populate over time
Use create_iterable when the model should emit a stream of complete objects, each yielded the moment it is done. Ideal for extracting every entity from a passage without waiting for the full list.
class User(BaseModel):
name: str
age: int
users = client.create_iterable(
response_model=User,
messages=[{"role": "user",
"content": "Ivan is 28; his friends Alex, John and Mary are 25, 30 and 27"}],
)
for user in users:
print(user) # one full User at a time: Ivan, then Alex, then John, then Mary
| Call | Yields | Reach for it when |
|---|---|---|
create | one finished object | the default; you just want the result |
create_partial | the same object, growing | live-rendering one record as it fills |
create_iterable | many objects, streamed | extracting a list of entities incrementally |
The Maybe pattern
If you force a model to fill a schema when the answer is not present, it hallucinates one. The fix is a wrapper type that lets the model say "not found" in a structured way. Instructor ships Maybe, or you can hand-roll a result/error model. Either way, the model gets a legitimate path for "no data", which measurably cuts hallucination.
from pydantic import BaseModel, Field
from typing import Optional
from instructor import Maybe
class User(BaseModel):
name: str
age: int
# Maybe[User] wraps result + an error flag + a message
resp = client.create(
response_model=Maybe[User],
messages=[{"role": "user", "content": "extract a user from: the weather is nice today"}],
)
if resp.result is None:
print("no user found:", resp.message) # structured miss, not a fabricated User
else:
print(resp.result.name)
Rolling your own is trivial and often clearer, because the field names and the error flag show up verbatim in the schema the model reads.
class MaybeUser(BaseModel):
result: Optional[User] = Field(default=None)
error: bool = Field(default=False)
message: Optional[str] = Field(default=None, description="why extraction failed")
Hooks · logging & observability
Register callbacks on client events to log requests, count parse failures, or emit traces, all without touching your call sites. Four events fire across the completion and parsing lifecycle.
client = instructor.from_provider("openai/gpt-4.1-mini")
def log_kwargs(*args, **kwargs):
print("calling model:", kwargs.get("model"))
def log_parse_error(error):
print("validation failed, will re-ask:", error) # great for a retry counter
client.on("completion:kwargs", log_kwargs) # args sent to the provider
client.on("parse:error", log_parse_error) # pydantic validation raised
# also: "completion:response" (raw reply) and "completion:error" (provider/retry error)
| Event | Fires when | Use it for |
|---|---|---|
completion:kwargs | before the provider call | logging the prompt and params |
completion:response | raw provider reply returns | token counts, latency, caching |
parse:error | Pydantic validation raises | counting retries, debugging schemas |
completion:error | the provider call errors | alerting on outages and rate limits |
Multi-provider & caching
Because Instructor patches the provider client but keeps its interface, the same create(response_model=...) call runs against OpenAI, Anthropic, Gemini, Ollama, and others. You choose the backend once, at construction, and the extraction code never changes. That makes the provider a config value, not a rewrite.
flowchart TB OAI["from_openai
OpenAI"] --> PATCH["instructor
patched client"] ANT["from_anthropic
Anthropic"] --> PATCH GEM["from_gemini
Google"] --> PATCH PATCH --> API["one call:
create, response_model set"] API --> OBJ["validated
pydantic object"] classDef model fill:#EEE6FF,stroke:#6D28D9,color:#09244B; classDef code fill:#D6F5E3,stroke:#1B7F46,color:#09244B; class OAI,ANT,GEM model; class PATCH,API,OBJ code;
Provider-specific features still come through. On Anthropic you can pass prompt-caching controls in the usual way, and Instructor forwards them; the schema you send for a large, stable model definition is exactly the kind of prefix worth caching. When you want provider-agnostic routing plus fallbacks, from_provider with a litellm-style "provider/model" string is the seam to reach for.
Putting it together
A realistic job: turn a scrappy pasted receipt into a typed object, enforce that the line items actually sum to the stated total, and let the model self-correct if they do not. One fuzzy step (the extraction), a hard code rule on both sides (the validators, the assertion).
import instructor
from pydantic import BaseModel, model_validator, field_validator
client = instructor.from_provider("openai/gpt-4.1-mini")
class LineItem(BaseModel):
description: str
qty: int
unit_price: float
@field_validator("qty")
@classmethod
def positive_qty(cls, v: int) -> int:
if v <= 0:
raise ValueError("qty must be a positive integer")
return v
class Receipt(BaseModel):
merchant: str
items: list[LineItem]
total: float
@model_validator(mode="after")
def total_must_match(self) -> "Receipt":
computed = round(sum(i.qty * i.unit_price for i in self.items), 2)
if abs(computed - self.total) > 0.01:
raise ValueError(f"items sum to {computed}, but total says {self.total}")
return self # cross-field check: the error text guides the re-ask
raw = """
THE CORNER CAFE
2x flat white 3.50
1 almond croiss 3.20
total 10.20
"""
receipt = client.create(
response_model=Receipt,
max_retries=3, # if the sum is off, the model gets told and tries again
messages=[
{"role": "system", "content": "Extract the receipt exactly. Do not invent items."},
{"role": "user", "content": raw},
],
)
print(receipt.merchant, receipt.total, len(receipt.items))
# -> a validated Receipt whose line items provably reconcile to the total
the model_validator is the whole point: a rule code can prove, enforced by the retry loop
Production
max_retries (2 to 3 is typical). Retries cost real tokens and latency, so treat them as a budget, not a free safety net.parse:error and completion:error to count failures and feed a dashboard; a rising parse-error rate means your schema and your prompts have drifted apart.ValidationError, and your service needs a fallback path.from_provider(..., async_client=True) or from_openai(AsyncOpenAI())) and await client.create(...) so extraction does not block your event loop under load.response_model classes are your contract, changing a field is an API change; review them like one.Gotchas
A validator the model realistically cannot satisfy (an impossible regex, a rule that contradicts the data) will burn every retry and then raise anyway, at full token cost each attempt. If you see repeated parse:error events on the same field, the schema is wrong, not the model. Loosen the rule or improve the field description before adding more retries.
Every retry is another full completion. A max_retries=5 on a large prompt can quietly 5x your token bill and latency on the hard cases. Keep the budget small, and prefer fixing the prompt or schema over cranking the number up.
There is no tool loop, no memory, no orchestration here. If you need the model to call tools across multiple turns, that is LangGraph, the OpenAI Agents SDK, or Pydantic AI. Reaching for Instructor to build an agent means rebuilding all of that by hand. Use it for the one thing it does: validated output from a single call.
Structured output can be produced via tool calls, JSON mode, or native structured outputs, and not every provider supports every Mode. If extraction quality is poor on a given backend, check the docs for the recommended mode for that provider rather than assuming the default is best.
Reach for it when
Skip it when
The principle it teaches
Parse and validate at the boundary. This is Session 2 in a single argument: the model produces a draft, Pydantic is the gate, and nothing untyped leaves the function. Instructor is that gate packaged, and the course's own extract() helper is the same idea written by hand with Zod and OpenAI structured outputs.
A Week 1 tool, the hardened version of the opening hook where a demo dies the moment raw output goes unvalidated. It recurs in every later lab that needs a model to hand back something typed rather than a paragraph: the classifier in the router, the arguments for a tool, the fields pulled from a document. Learn the pattern here and you reach for it everywhere. When a step grows into a full tool-using agent, you graduate to Pydantic AI or LangGraph and keep Instructor for the typed-output slice inside it.
common/llm.ts extract() helper; Pydantic AI or LangGraph when a node needs strictly-typed output.