Skip to content

title: Instructor Philosophy - Design Principles and Approach description: Understand the core philosophy behind Instructor's design. Learn about type safety, validation, and the principles that guide structured LLM output development.


Philosophy

Great tools make hard things easy without making easy things hard. That's Instructor.

Start with what developers know

Most AI frameworks invent their own abstractions. We don't.

import instructor
from pydantic import BaseModel


# What you already know (Pydantic)
class User(BaseModel):
    name: str
    age: int


# What Instructor adds
client = instructor.from_provider("openai/gpt-4.1-mini")
_user = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
)  # That's it

If you know Pydantic, you know Instructor. No new concepts, no new syntax, no 200-page manual.

Your escape hatch is always there

The worst frameworks are roach motels - easy to get in, impossible to get out. Instructor is different:

import instructor
from pydantic import BaseModel


class User(BaseModel):
    name: str
    age: int


# With Instructor
client = instructor.from_provider("openai/gpt-4.1-mini")
_result = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
)

# Want to go back to raw API? Just remove response_model:
client = instructor.from_provider("openai/gpt-4.1-mini")
_result = client.create(messages=[{"role": "user", "content": "Say hello"}])

# Or use the provider directly:
from openai import OpenAI

_raw_client = OpenAI()  # Back to vanilla

We patch, we don't wrap. Your code, your control.

Show, don't hide

Bad frameworks hide complexity. Good tools help you understand it.

import instructor
from pydantic import BaseModel


class User(BaseModel):
    name: str
    age: int


# See exactly what Instructor sends
instructor.logfire.configure()  # Full observability

client = instructor.from_provider("openai/gpt-4.1-mini")
result = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
)

# Access raw responses
_raw_response = result._raw_response  # See what the LLM actually returned

When something goes wrong (and it will), you can see exactly what happened.

Composition beats configuration

No YAML files. No decorators. No magic. Just functions.

import instructor
from pydantic import BaseModel

client = instructor.from_provider("openai/gpt-4.1-mini")


class User(BaseModel):
    name: str
    age: int


class Company(BaseModel):
    name: str
    industry: str


class Analysis(BaseModel):
    user: User
    company: Company


# Build complex systems with simple functions
def extract_user(text: str) -> User:
    return client.create(
        response_model=User, messages=[{"role": "user", "content": text}]
    )


def extract_company(text: str) -> Company:
    return client.create(
        response_model=Company, messages=[{"role": "user", "content": text}]
    )


def analyze_email(email: str) -> Analysis:
    user = extract_user(email)
    company = extract_company(email)
    return Analysis(user=user, company=company)


# Compose however makes sense for YOUR application
_analysis = analyze_email("Please introduce Jane from Acme.")

Start simple, grow naturally

The best code is code that grows with your needs:

import instructor
from instructor import Partial
from pydantic import BaseModel, field_validator

client = instructor.from_provider("openai/gpt-4.1-mini")


class User(BaseModel):
    name: str
    age: int


# Day 1: Just get it working
_user = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
)


# Day 7: Add validation
class User(BaseModel):
    name: str
    age: int

    @field_validator("age")
    def check_age(cls, value: int) -> int:
        if value < 0 or value > 150:
            raise ValueError("Invalid age")
        return value


# Day 14: Add retries for production
_user = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
    max_retries=3,
)


# Day 30: Add streaming for better UX
def update_ui(_partial: Partial[User]) -> None:
    pass


for partial in client.create(
    response_model=Partial[User],
    messages=[{"role": "user", "content": "Jane is 33"}],
    stream=True,
):
    update_ui(partial)

Each addition is one line. No refactoring. No migration guide.

What we intentionally DON'T do

No prompt engineering

We don't write prompts for you. You know your domain better than we do.

# We DON'T do this:
# @instructor.prompt("Extract the user information carefully")
# def extract_user(text: str):
#     ...


# You write your own prompts:
text = "Jane is 33"
_messages = [
    {"role": "system", "content": "You are a precise data extractor"},
    {"role": "user", "content": f"Extract user from: {text}"},
]

No new abstractions

We don't invent concepts like "Agents", "Chains", or "Tools". Those are your domain concepts.

import instructor
from pydantic import BaseModel

# We DON'T do this:
# class UserExtractionAgent(instructor.Agent):
#     tools = [instructor.WebSearch(), instructor.Calculator()]


class User(BaseModel):
    name: str
    age: int


def search_web(query: str) -> str:
    return f"Results for {query}"


client = instructor.from_provider("openai/gpt-4.1-mini")


# You build what makes sense:
def extract_user_with_search(query: str) -> User:
    # Your logic, your way
    search_results = search_web(query)
    return client.create(
        response_model=User, messages=[{"role": "user", "content": search_results}]
    )


_user = extract_user_with_search("Find Jane")

No framework lock-in

Your code should work with or without us:

import instructor
from pydantic import BaseModel


# This is just a Pydantic model
class User(BaseModel):
    name: str
    age: int


# This is just a function
def process_user(user: User) -> dict:
    return {"name": user.name.upper(), "adult": user.age >= 18}


client = instructor.from_provider("openai/gpt-4.1-mini")

# Instructor just connects them to LLMs
user = client.create(
    response_model=User,
    messages=[{"role": "user", "content": "Jane is 33"}],
)

_result = process_user(user)  # Works with or without Instructor

The result

By following these principles, we get:

  • Tiny API surface: Learn it in minutes, not days
  • Zero vendor lock-in: Switch providers or remove Instructor anytime
  • Debuggable: When things break, you can see why
  • Composable: Build complex systems from simple parts
  • Pythonic: If it feels natural in Python, it feels natural in Instructor

In practice

Here's what building with Instructor actually looks like:

from enum import Enum
from typing import List

import instructor
from pydantic import BaseModel


# Your domain models (not ours)
class Priority(str, Enum):
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"


class Ticket(BaseModel):
    title: str
    description: str
    priority: Priority
    estimated_hours: float


# Your business logic (not ours)
def prioritize_tickets(tickets: List[Ticket]) -> List[Ticket]:
    return sorted(tickets, key=lambda t: (t.priority.value, -t.estimated_hours))


# Connect to LLM (one line)
client = instructor.from_provider("openai/gpt-4.1-mini")

# Extract structured data (simple function call)
tickets = client.create(
    response_model=List[Ticket],
    messages=[{"role": "user", "content": "Parse these support tickets: ..."}],
)

# Use your business logic
_prioritized = prioritize_tickets(tickets)

No framework. No abstractions. Just Python.

The philosophy in one sentence

Make structured LLM outputs as easy as defining a Pydantic model.

Everything else follows from that.