Complete Instructor Tutorial: Structured Outputs from LLMs with Pydantic
Hey everyone, in this tutorial I want to introduce you to one of the Python libraries I think is absolutely worth learning if you work with LLMs a lot. It is called Instructor. If you have ever been frustrated because the output from a language model is sometimes clean and sometimes messy, sometimes valid JSON and sometimes with stray text tacked on at the front or back, then Instructor is the answer. I have been using this library in several production projects for a while now, and honestly it completely changed how I code with LLMs.
In this article I will cover everything from scratch, starting with why structured output matters, how to install it, basic usage, all the way to advanced features like nested models, automatic retries, streaming, and multi-provider support. I will also give you plenty of runnable Python code examples that you can try yourself. Let us get started.
Why Structured Output Matters
Before diving into Instructor, let me first talk about a problem we run into constantly when working with LLMs. Imagine you are building an application that needs to extract data from text. Say you have a customer email, and you want to pull out the name, email, and urgency level from it. The most naive approach is to just ask the LLM to return JSON.
The problem is that an LLM is fundamentally a text generator. It has no guarantee that it will produce output in a consistent format. Sometimes it returns valid JSON, sometimes it adds an intro like "Sure, here you go:" before the JSON. Sometimes it wraps everything in a markdown code block. Sometimes the data types are wrong, for example you asked for a number but it gives you a string. This becomes a real headache when you want to process that output further in your code.
The old approach usually involves manual parsing with regex, or trying json.loads() wrapped in a giant try-except. But this is extremely fragile. The moment the output format changes even slightly, your code breaks. On top of that you have to validate everything manually, like making sure the email field is actually an email, making sure age is a positive number, and so on.
This is where Instructor comes in. The idea is simple but brilliant, folks. We define the output structure we want using Pydantic, and Instructor takes care of getting the LLM to produce output matching that structure, along with the validation. If the output does not match, Instructor automatically asks the LLM to fix it. So we get output that is guaranteed to be typed and validated, ready to use directly in code. No more manual parsing that gives you headaches.
For those unfamiliar, Pydantic is a Python library for data validation based on type hints. We define a class with fields and their types, and Pydantic ensures the incoming data matches that definition. Instructor uses Pydantic as the contract between our code and the LLM.
Installation
Alright, let us get practical. Installation is super easy, just one line with pip.
pip install instructor
Instructor automatically brings along the dependencies it needs, including Pydantic. But you still need the LLM provider library, for example OpenAI or Anthropic. So I usually install them together like this.
pip install instructor openai anthropic
If you use a virtual environment (which I strongly recommend), activate your venv before installing. This keeps your project dependencies from getting mixed up with other projects.
python -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install instructor openai
Do not forget to set up your API key. If you use OpenAI, set the environment variable OPENAIAPIKEY. If you use Anthropic, set ANTHROPICAPIKEY. I usually put mine in a .env file and load it with the python-dotenv library.
# .env file
OPENAIAPIKEY=sk-xxxxxxxxxxxxxxxx
Basic Usage
Now we get to the fun part. I will show you the most basic example first so you understand the core concept. Instructor really only has two key ideas: we patch our LLM client, then we pass a responsemodel parameter containing a Pydantic class.
Let us build a simple example. Say we want to extract information about a person from a sentence.
import instructor
from openai import OpenAI
from pydantic import BaseModel
Define the output structure we want
class Person(BaseModel):
name: str
age: int
occupation: str
Patch the OpenAI client with Instructor
client = instructor.fromopenai(OpenAI())
Call the LLM with responsemodel
person = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=Person,
messages=[
{
"role": "user",
"content": "Budi is 28 years old and works as a software engineer."
}
],
)
print(person.name) # Budi
print(person.age) # 28
print(person.occupation) # software engineer
print(type(person)) # main.Person'>
Notice, folks, what gets returned is not a string, not a dictionary, but an actual Person object. So we can directly access person.name, person.age, and so on with editor autocomplete plus type checking. This is completely different from the old approach where we had to parse JSON first and access it with dictionary keys that are prone to typos.
The important parts are these two lines. First, instructor.fromopenai(OpenAI()) which patches the OpenAI client so it understands the responsemodel parameter. Second, responsemodel=Person which tells Instructor the shape of output we expect. Behind the scenes, Instructor converts that Pydantic class into a function schema or JSON schema and sends it to the LLM as instructions.
We can also add descriptions to each field using Field from Pydantic. This is very useful for giving the LLM extra hints about what we want.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(description="The name of the product mentioned")
price: float = Field(description="Product price as a plain number, no separators")
category: str = Field(description="Product category, e.g. electronics or food")
client = instructor.fromopenai(OpenAI())
product = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=Product,
messages=[
{
"role": "user",
"content": "I just bought a gaming laptop for 15 million rupiah."
}
],
)
print(product.name) # gaming laptop
print(product.price) # 15000000.0
print(product.category) # electronics
The description inside Field is sent directly to the LLM as part of the schema, so the model knows exactly what to fill in each field. It is a simple technique but it hugely affects output quality.
Automatic Validation
One of Instructor's biggest strengths is validation. Because we use Pydantic, we can leverage all of Pydantic's validation features. For example we can constrain age to be positive, or require email to be a valid email format.
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, EmailStr
class Contact(BaseModel):
name: str
email: EmailStr
age: int = Field(gt=0, lt=150, description="Age in years")
client = instructor.fromopenai(OpenAI())
contact = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=Contact,
messages=[
{
"role": "user",
"content": "Sari, 32 years old, email sari@example.com"
}
],
)
print(contact) # name='Sari' email='sari@example.com' age=32
If the LLM produces invalid output, for example a negative age or a malformed email, Pydantic raises a validation error. Here is the cool part: Instructor catches that error and automatically asks the LLM to fix its output by telling it what went wrong. We will cover this in more detail in the retry section.
We can also build custom validators using the fieldvalidator decorator. For example we might want to ensure a username is always lowercase.
from pydantic import BaseModel, fieldvalidator
class User(BaseModel):
name: str
username: str
@field
validator("username")
@classmethod
def usernamemustbelowercase(cls, v: str) -> str:
if v != v.lower():
raise ValueError("username must be all lowercase")
return v
If the LLM gives a username with uppercase letters, this validator raises an error, and Instructor asks the model to fix it. So we have full control over the shape and rules of our output.
Advanced Usage
Now we get to the more interesting part, folks. Here I will cover the advanced features that make Instructor genuinely powerful for production applications.
Nested Models
In the real world, data is rarely flat. Often our data has a hierarchical structure, for example one person has many addresses, one order has many items. Instructor handles this seamlessly because Pydantic supports nested models.
import instructor
from openai import OpenAI
from pydantic import BaseModel
from typing import List
class Address(BaseModel):
street: str
city: str
postalcode: str
class Item(BaseModel):
itemname: str
quantity: int
unitprice: float
class Order(BaseModel):
customername: str
shippingaddress: Address
items: List[Item]
total: float
client = instructor.fromopenai(OpenAI())
order = client.chat.completions.create(
model="gpt-4o",
responsemodel=Order,
messages=[
{
"role": "user",
"content": (
"Order for Andi, ship to 10 Merdeka Street, Bandung, "
"postal code 40111. Buy 2 books at 50000 each "
"and 1 pen at 15000."
)
}
],
)
print(order.customername) # Andi
print(order.shippingaddress.city) # Bandung
print(len(order.items)) # 2
print(order.items[0].itemname) # book
print(order.items[0].quantity) # 2
See, folks, we can nest models as deep as we want. Instructor asks the LLM to fill in this entire structure at once. This is incredibly useful for complex data extraction. I often use this pattern to process invoices, legal documents, and other structured data that used to be just raw text.
Lists and Optional Types
Sometimes we do not know how many items will be extracted, or there is a field that might be empty. For that we can use List and Optional from typing.
from pydantic import BaseModel
from typing import List, Optional
class Task(BaseModel):
title: str
deadline: Optional[str] = None
priority: str
class TaskList(BaseModel):
tasks: List[Task]
client = instructor.fromopenai(OpenAI())
result = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=TaskList,
messages=[
{
"role": "user",
"content": (
"Tomorrow I need to finish the financial report (high priority), "
"then reply to the client email, and review the team's code (deadline Friday)."
)
}
],
)
for t in result.tasks:
print(f"{t.title} - priority {t.priority} - deadline {t.deadline}")
With Optional, if the LLM does not find information for a certain field, it can fill in None without error. This makes our extraction more flexible and robust.
Enums for Constrained Values
If you have a field whose value can only come from a certain set of choices, use an Enum. This ensures the LLM only picks from the options we provide.
import instructor
from openai import OpenAI
from pydantic import BaseModel
from enum import Enum
class UrgencyLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class SupportTicket(BaseModel):
summary: str
urgency: UrgencyLevel
client = instructor.fromopenai(OpenAI())
ticket = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=SupportTicket,
messages=[
{
"role": "user",
"content": "Our website is completely down, customers can't check out at all!"
}
],
)
print(ticket.urgency) # UrgencyLevel.CRITICAL
With Enum, we guarantee the output is always one of the valid values. No more LLM giving you "kind of important" or "somewhat urgent" that confuses your logic.
Automatic Retries
This is my favorite feature, folks. Sometimes the LLM produces output that fails validation. Instructor has an automatic retry mechanism. When validation fails, Instructor re-sends the request to the LLM with the error message attached, so the model knows what went wrong and fixes it.
import instructor
from openai import OpenAI
from pydantic import BaseModel, fieldvalidator
class Summary(BaseModel):
content: str
@fieldvalidator("content")
@classmethod
def maxtenwords(cls, v: str) -> str:
wordcount = len(v.split())
if wordcount > 10:
raise ValueError(
f"Summary too long ({wordcount} words), maximum 10 words"
)
return v
client = instructor.fromopenai(OpenAI())
summary = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=Summary,
maxretries=3,
messages=[
{
"role": "user",
"content": "Summarize an article about the benefits of morning exercise for body health."
}
],
)
print(summary.content)
The maxretries=3 parameter means Instructor will try up to 3 times if validation fails. Each time it fails, the Pydantic error message is sent back to the LLM as extra context. So the model gets a chance to learn from its mistake and produce correct output. I think this is brilliant because we do not have to write retry logic manually.
For finer control, we can use Retrying from the tenacity library.
from tenacity import Retrying, stopafterattempt, waitfixed
result = client.chat.completions.create(
model="gpt-4o-mini",
response
model=Summary,
maxretries=Retrying(
stop=stopafterattempt(5),
wait=waitfixed(2),
),
messages=[...],
)
With tenacity, we can configure a more detailed retry strategy, for example adding a delay between attempts or setting more complex stop conditions.
Streaming
If you are building an application that needs to show results in real time, such as a chat UI, streaming is important so the user does not wait too long. Instructor supports streaming for partial objects and for iterables.
First, partial streaming. This gives us an object that gets filled in progressively as the LLM types.
import instructor
from openai import OpenAI
from pydantic import BaseModel
class Profile(BaseModel):
name: str
bio: str
skills: str
client = instructor.fromopenai(OpenAI())
stream = client.chat.completions.createpartial(
model="gpt-4o-mini",
responsemodel=Profile,
messages=[
{
"role": "user",
"content": "Create a profile for a data scientist named Rina."
}
],
)
for partial in stream:
print(partial)
# a Profile object filled in progressively, field by field
Second, iterable streaming. This is useful when we want to extract many objects and process them one at a time as each finishes, without waiting for all of them.
from typing import Iterable
class Quote(BaseModel):
text: str
author: str
client = instructor.fromopenai(OpenAI())
quotestream = client.chat.completions.createiterable(
model="gpt-4o-mini",
responsemodel=Quote,
messages=[
{
"role": "user",
"content": "Give me 5 famous motivational quotes with their authors."
}
],
)
for quote in quotestream:
print(f'"{quote.text}" - {quote.author}')
Streaming makes our application feel much more responsive. Users can see results appear progressively instead of staring at a blank screen until the whole process finishes.
Multiple Providers
This is one of the reasons I really love Instructor. It supports not just OpenAI but also many other providers like Anthropic, Google Gemini, Cohere, Mistral, even local models via Ollama. The API is consistent, so you can swap providers without changing your core logic.
For Anthropic Claude, just change the patch function.
import instructor
from anthropic import Anthropic
from pydantic import BaseModel
class Analysis(BaseModel):
sentiment: str
confidencescore: float
reasoning: str
client = instructor.fromanthropic(Anthropic())
analysis = client.chat.completions.create(
model="claude-sonnet-4-5",
maxtokens=1024,
responsemodel=Analysis,
messages=[
{
"role": "user",
"content": "Analyze sentiment: 'The service was super friendly, I'm satisfied!'"
}
],
)
print(analysis.sentiment) # positive
print(analysis.confidencescore) # 0.95
Notice, the only thing that changed is instructor.fromanthropic(Anthropic()) and the model name. For Claude we also need to set maxtokens because it is required in the Anthropic API. Everything else, from responsemodel to how we access the result, is exactly the same as the OpenAI version.
Modern Instructor also has a unified API via instructor.fromprovider that lets us specify the provider through a string. This is very handy when the provider needs to be configured from an environment variable.
import instructor
from pydantic import BaseModel
class Answer(BaseModel):
content: str
provider is specified via a "provider/model" string
client = instructor.fromprovider("openai/gpt-4o-mini")
answer = client.chat.completions.create(
responsemodel=Answer,
messages=[{"role": "user", "content": "What is the capital of Indonesia?"}],
)
print(answer.content) # Jakarta
With this pattern, we can easily switch between providers just by changing a string, for example from "openai/gpt-4o-mini" to "anthropic/claude-sonnet-4-5". For production apps that need provider flexibility, this is a lifesaver.
Best Practices
After using Instructor for quite a while across various projects, I have a few best practice recommendations to share with you. These are things I learned from experience, sometimes from mistakes I made myself.
First, give clear descriptions on each field. The clearer your descriptions, the better the LLM output. Do not be lazy about writingField(description=...). Think of it like giving instructions to a new assistant who has no context. Good descriptions can reduce output errors significantly.
Second, start with simple models and grow them gradually. Do not immediately build a giant model with 30 nested fields. Start small, test, then add complexity. This makes debugging much easier when something goes wrong.
Third, fully leverage Pydantic validation. Do not just use basic types. Use constraints like gt, lt, minlength, maxlength, and custom validators. The tighter your validation, the more assured the quality of data entering your system. Remember, this validation also automatically becomes feedback for the LLM through the retry mechanism.
Fourth, set maxretries according to your needs. For easy tasks, 2 to 3 retries are usually enough. For complex tasks with strict validation, you might need more. But be careful, too many retries can inflate API costs and increase latency. So balance reliability against cost.
Fifth, choose the right model. Small models like gpt-4o-mini are enough for simple extraction tasks and are cheaper. But for complex structures with lots of nested models and reasoning, larger models usually give more accurate results. Do not waste an expensive model on an easy task.
Sixth, handle exceptions properly. Even though Instructor has retries, there is still a chance all attempts fail. Wrap your calls in try-except and prepare a sensible fallback.
from pydantic import ValidationError
try:
result = client.chat.completions.create(
model="gpt-4o-mini",
responsemodel=Contact,
maxretries=3,
messages=[{"role": "user", "content": inputtext}],
)
except ValidationError as e:
print(f"Validation failed after all retries: {e}")
# do a fallback, e.g. push to a queue for manual processing
Seventh, use Literal or Enum for categorical fields. This narrows the LLM's answer space and makes the output more consistent. Instead of hoping the LLM gives the exact string, constrain the choices from the start.
Eighth, enable logging during development. Instructor integrates with Python's logging library. By seeing the raw requests and responses, you can better understand what is happening behind the scenes and debug more easily when issues arise.
import logging
logging.basicConfig(level=logging.DEBUG)
Conclusion
Alright folks, we have come quite a long way. I hope you now have a clear picture of why Instructor is so worth learning and using. Let us summarize the key points.
Instructor solves one of the most annoying problems when working with LLMs, namely inconsistent and unstructured output. By combining the power of Pydantic for structure definition and validation, plus a seamless patching mechanism into various LLM clients, we get output that is type-guaranteed, validated, and immediately ready to use in code.
Key points to remember. First, the core concept is just two things: patch the client with instructor.fromopenai or similar, then pass a responsemodel containing a Pydantic class. Second, you can leverage all of Pydantic's features from nested models, lists, optional, enum, to custom validators. Third, the maxretries feature makes your system far more robust because the LLM automatically fixes its output when validation fails. Fourth, streaming via createpartial and createiterable makes your application feel responsive. Fifth, multi-provider support keeps you from being locked into a single vendor and makes switching easy.
In my opinion, Instructor is one of those tools that once you try it, you will find it hard to go back to the old way. I personally almost never manually parse JSON from LLMs anymore. I hand everything over to Instructor and Pydantic. The code becomes cleaner, safer, and easier to maintain.
My advice is to just start practicing, folks. Take one small use case in your project, for example extracting data from text, and try implementing it with Instructor. I am confident you will immediately feel the difference. Happy coding, and I hope this tutorial is useful for all of you. See you in the next tutorial.