Complete Instructor Tutorial: Structured Outputs from LLMs with Pydantic

# Tutorial Lengkap Instructor: Output Terstruktur dari LLM dengan Pydantic Halo temen-temen, di tutorial ini aku mau ngajak kalian kenalan sama salah satu library Python yang menurutku wajib banget d...

By Ruby Abdullah · · tutorial
LLMPythonPydanticStructured OutputInstructor

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

@fieldvalidator("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",

responsemodel=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 writing Field(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, min
length, 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.

Related Articles

Instructor: Getting Structured Output from LLMs with Python

Instructor: Mendapatkan Structured Output dari LLM dengan Python Salah satu tantangan terbesar saat bekerja dengan Large...

PydanticAI Tutorial: A Type-Safe Agent Framework for LLM Apps

Membangun Agen LLM yang Type-Safe dengan PydanticAI PydanticAI adalah framework agen dari tim di balik Pydantic, diranca...

Complete Guidance Tutorial: Constrained Generation and Structured Output from LLMs

Tutorial Lengkap Guidance: Constrained Generation dan Structured Output dari LLM Halo temen-temen, di tutorial kali ini ...

Mirascope Tutorial: A Pythonic Toolkit for Building LLM Applications

Tutorial Mirascope: Toolkit Pythonic untuk Membangun Aplikasi LLM Pendahuluan Mirascope adalah library Python yang menye...