← All sessions, cheat sheets & labs
Week 1 · Deep dive · Structured output

Instructor

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.

structured outputopen sourcePydanticvalidation + retryv1.15
On this page
Mental model Install Setup & patching Basic extraction Validation & retries Field validators Nested & lists Streaming The Maybe pattern Hooks Multi-provider Full example Production Gotchas Course fit

The mental model

One patched client, one new argument: response_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;
In plain English

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

One package, plus whatever provider you already use.

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

Two ways in: patch a client you built, or name a provider.

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
Same object, extended

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

Describe the shape, get the shape.

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

A validation error becomes the next prompt.

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

Enforce rules at the type, not in the prompt.

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=[...])
Why this matters

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

Compose models; extract many at once.

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

Two different streams: one growing object, or many objects.

Instructor gives you two streaming primitives, and they solve different problems. Do not confuse them.

create_partial · one object, filling in

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

create_iterable · many objects, one by one

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
CallYieldsReach for it when
createone finished objectthe default; you just want the result
create_partialthe same object, growinglive-rendering one record as it fills
create_iterablemany objects, streamedextracting a list of entities incrementally

The Maybe pattern

Give the model an escape hatch so it stops guessing.

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

Tap the lifecycle without wrapping every call.

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)
EventFires whenUse it for
completion:kwargsbefore the provider calllogging the prompt and params
completion:responseraw provider reply returnstoken counts, latency, caching
parse:errorPydantic validation raisescounting retries, debugging schemas
completion:errorthe provider call errorsalerting on outages and rate limits

Multi-provider & caching

One call surface, many backends.

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

Parsing a messy receipt, validated and retried.

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

From notebook to service.

Gotchas

What trips people up.

Over-strict schemas cause retry loops

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.

Retries are not free

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.

Instructor is not an agent framework

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.

Mode and provider support vary

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

Course principle

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.

Where it fits your labs

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.

Use in
Week 1, the hardened structured-output hook, and any later lab that needs typed model output.
week 1
Pairs with
The course common/llm.ts extract() helper; Pydantic AI or LangGraph when a node needs strictly-typed output.