LLMs return text. Your code needs typed objects. Instructor bridges that gap by patching your OpenAI client to return Pydantic models instead of raw strings. When the model’s output doesn’t match your schema, Instructor feeds the validation error back to the LLM and retries automatically. You define the shape of the data you want, and Instructor handles the rest.
This is different from OpenAI’s built-in structured outputs. Instructor gives you custom Pydantic validators, automatic retries with error feedback, and works across multiple providers (Anthropic, Google, Ollama) with the same interface.
Install and Set Up Instructor#
Install the library with pip:
1
| pip install instructor pydantic openai
|
Instructor’s modern API uses from_provider() to create a client. You pass a provider/model string, and it handles SDK initialization and patching internally.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| import instructor
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
client = instructor.from_provider("openai/gpt-4o")
user = client.create(
response_model=User,
messages=[
{
"role": "user",
"content": "Extract user info: Jason is 25, his email is [email protected]",
}
],
)
print(user)
# User(name='Jason', age=25, email='[email protected]')
print(type(user))
# <class '__main__.User'>
|
That user variable is a real Pydantic model instance with full type safety. No JSON parsing, no key lookups, no try/except around json.loads().
Define Output Schemas with Pydantic Models#
The real power shows up when your output schema has constraints. Pydantic’s type system catches problems that raw JSON mode misses.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| import instructor
from pydantic import BaseModel, Field
from typing import Literal
class SentimentAnalysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
confidence: float = Field(ge=0.0, le=1.0, description="Confidence score between 0 and 1")
key_phrases: list[str] = Field(min_length=1, description="Phrases that drove the sentiment")
language: str = Field(description="Detected language of the input text")
client = instructor.from_provider("openai/gpt-4o")
result = client.create(
response_model=SentimentAnalysis,
messages=[
{
"role": "system",
"content": "Analyze the sentiment of the user's text. Be precise with confidence scores.",
},
{
"role": "user",
"content": "The new update completely broke my workflow. Nothing works anymore and support hasn't responded in 3 days.",
},
],
)
print(f"Sentiment: {result.sentiment}")
print(f"Confidence: {result.confidence}")
print(f"Key phrases: {result.key_phrases}")
print(f"Language: {result.language}")
|
The Literal type restricts sentiment to exactly three values. Field(ge=0.0, le=1.0) enforces the confidence range at the Pydantic level. If the model returns 1.5 for confidence, validation fails and Instructor retries.
Handle Retries on Validation Failure#
By default, Instructor retries once when validation fails. You can bump that up with max_retries:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| import instructor
from pydantic import BaseModel, Field, field_validator
class MovieReview(BaseModel):
title: str
year: int = Field(ge=1888, le=2026)
rating: float = Field(ge=0.0, le=10.0)
genres: list[str] = Field(min_length=1, max_length=5)
summary: str
@field_validator("summary")
@classmethod
def summary_length(cls, v: str) -> str:
if len(v.split()) < 10:
raise ValueError(f"Summary must be at least 10 words, got {len(v.split())}")
return v
client = instructor.from_provider("openai/gpt-4o")
review = client.create(
response_model=MovieReview,
max_retries=3,
messages=[
{
"role": "user",
"content": "Review the movie Inception (2010) by Christopher Nolan.",
}
],
)
print(f"{review.title} ({review.year}) - {review.rating}/10")
print(f"Genres: {', '.join(review.genres)}")
print(f"Summary: {review.summary}")
|
When a retry happens, Instructor appends the validation error to the conversation. The LLM sees exactly what went wrong – “Summary must be at least 10 words, got 6” – and adjusts. This feedback loop is what makes it more reliable than a simple retry.
Nested Models and Complex Structures#
Real-world extraction usually involves nested data. Pydantic handles this naturally, and Instructor passes the full schema to the LLM.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| import instructor
from pydantic import BaseModel, Field
from typing import Optional
class Address(BaseModel):
street: str
city: str
state: str
zip_code: str
country: str = "US"
class Experience(BaseModel):
company: str
role: str
years: float = Field(ge=0, description="Years at this position")
current: bool
class CandidateProfile(BaseModel):
name: str
email: str
phone: Optional[str] = None
address: Address
experience: list[Experience] = Field(min_length=1)
skills: list[str] = Field(min_length=2, max_length=20)
total_years_experience: float = Field(ge=0)
client = instructor.from_provider("openai/gpt-4o")
resume_text = """
Sarah Chen
[email protected] | (555) 123-4567
742 Evergreen Terrace, Springfield, IL 62704
Experience:
- Senior ML Engineer at Acme Corp (2021-present, 5 years)
- Data Scientist at DataCo (2018-2021, 3 years)
- Junior Developer at StartupXYZ (2016-2018, 2 years)
Skills: Python, PyTorch, TensorFlow, SQL, Kubernetes, MLflow, Docker, FastAPI
"""
profile = client.create(
response_model=CandidateProfile,
max_retries=2,
messages=[
{
"role": "system",
"content": "Extract a structured candidate profile from the resume text. Be thorough with all fields.",
},
{"role": "user", "content": resume_text},
],
)
print(f"Name: {profile.name}")
print(f"Email: {profile.email}")
print(f"City: {profile.address.city}, {profile.address.state}")
print(f"Experience entries: {len(profile.experience)}")
for exp in profile.experience:
status = "current" if exp.current else "past"
print(f" - {exp.role} at {exp.company} ({exp.years} yrs, {status})")
print(f"Skills: {', '.join(profile.skills)}")
print(f"Total experience: {profile.total_years_experience} years")
|
The nested Address and Experience models get serialized into the JSON schema that the LLM sees. Each sub-model has its own validators, so you get validation at every level of the hierarchy.
Custom Validators with field_validator#
Pydantic’s field_validator decorator lets you enforce business rules that go beyond type checking. Instructor feeds these validation errors back to the LLM, so the model learns what it did wrong.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| import re
import instructor
from pydantic import BaseModel, Field, field_validator
class APIEndpoint(BaseModel):
method: str
path: str
description: str
auth_required: bool
rate_limit_per_minute: int = Field(ge=1, le=10000)
@field_validator("method")
@classmethod
def valid_http_method(cls, v: str) -> str:
allowed = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
v = v.upper()
if v not in allowed:
raise ValueError(f"Invalid HTTP method '{v}'. Must be one of: {allowed}")
return v
@field_validator("path")
@classmethod
def valid_path_format(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError(f"Path must start with '/', got '{v}'")
if not re.match(r"^/[a-z0-9/_\-{}]+$", v):
raise ValueError(
f"Path contains invalid characters. Use lowercase, numbers, hyphens, "
f"underscores, and {{param}} placeholders only."
)
return v
@field_validator("description")
@classmethod
def description_not_generic(cls, v: str) -> str:
generic = ["this endpoint", "this api", "endpoint for"]
if any(phrase in v.lower() for phrase in generic):
raise ValueError(
"Description too generic. Describe what the endpoint does specifically."
)
return v
class APISpec(BaseModel):
service_name: str
base_url: str
endpoints: list[APIEndpoint] = Field(min_length=1)
client = instructor.from_provider("openai/gpt-4o")
spec = client.create(
response_model=APISpec,
max_retries=3,
messages=[
{
"role": "user",
"content": (
"Generate an API spec for a simple todo list service with endpoints "
"for creating, listing, getting, updating, and deleting todos."
),
}
],
)
print(f"Service: {spec.service_name}")
print(f"Base URL: {spec.base_url}")
for ep in spec.endpoints:
auth = "AUTH" if ep.auth_required else "PUBLIC"
print(f" {ep.method:6s} {ep.path:30s} [{auth}] {ep.rate_limit_per_minute}/min")
print(f" {ep.description}")
|
The valid_path_format validator uses a regex to enforce REST conventions. The description_not_generic validator rejects lazy descriptions like “This endpoint handles things.” When the LLM generates something that fails these checks, Instructor sends back the exact error message and the model self-corrects.
Using the Legacy Patch Pattern#
If you’re working with an existing codebase that already has an OpenAI client instance, you can use the manual patching approach instead of from_provider:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| import openai
import instructor
from pydantic import BaseModel
client = instructor.patch(openai.OpenAI(), mode=instructor.Mode.TOOLS)
class TaskItem(BaseModel):
title: str
priority: int
done: bool
task = client.chat.completions.create(
model="gpt-4o",
response_model=TaskItem,
max_retries=2,
messages=[
{"role": "user", "content": "Extract: Buy groceries, high priority, not done yet"}
],
)
print(f"{task.title} (priority: {task.priority}, done: {task.done})")
|
The manual patch gives you control over which mode to use – TOOLS (default for OpenAI), JSON, or MD_JSON. For most cases, from_provider picks the right mode automatically.
Common Errors and Fixes#
ValidationError: 1 validation error for X
This means the LLM’s output failed your Pydantic constraints. If you’re seeing this despite retries, your validators might be too strict for the task. Check if the model has enough context in the prompt to produce valid output. Increase max_retries to 3 or 4 for complex schemas.
instructor.exceptions.InstructorRetryException after max retries
The model failed validation on every attempt. This usually means your schema is asking for something the model can’t reliably produce from the given input. Simplify the schema, add more specific instructions in the system message, or loosen constraints.
openai.APIError: 400 - Invalid schema
Some Pydantic features aren’t supported in OpenAI’s JSON schema conversion. Avoid Union types with more than a few variants, deeply recursive models, and dict[str, Any] as a field type. Stick to concrete types.
Fields returning None when they shouldn’t
If you defined a field as Optional[str] and the model keeps returning None, remove the Optional wrapper. The LLM takes the path of least resistance – if None is valid, it might choose it over doing the extraction work.
Slow responses with high max_retries
Each retry is a full API call. If you’re setting max_retries=5 and hitting them regularly, your schema probably needs simplification. Two retries should handle most cases. If you need more, break the extraction into smaller, focused models.
TypeError: Object of type X is not JSON serializable
This happens when your Pydantic model uses custom types that don’t serialize to JSON schema cleanly. Stick to built-in types (str, int, float, bool, list, dict) and Literal for constrained values.