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.