Production AI App Patterns

Structured LLM Outputs With Instructor and Pydantic Models

If you have ever wrapped an LLM call in a try/except json.JSONDecodeError and hoped for the best, this guide is for you. Getting structured LLM outputs — predictable, typed data instead of free-form prose — is the difference between a demo and a service you can page someone about. Instructor solves this by pairing Pydantic models with an automatic validation-and-retry loop, so your application code receives a real Python object or a real exception, never a half-parsed string.

This tutorial covers installation, your first typed extraction, how the retry loop actually works, custom validators, nested models, streaming, multi-provider usage, and the mistakes that show up once traffic grows. Every example is production-shaped: imports included, error handling where it matters, and no toy {"name": "John"} payloads.

What Are Structured LLM Outputs?

Structured LLM outputs are model responses constrained to a predefined schema — typically JSON matching a set of named, typed fields — rather than free-form text. Instead of parsing prose, your code receives validated data it can pass straight into a database, a queue, or another service. The schema is enforced either by the provider’s API or by a client-side validate-and-retry loop.

Three mechanisms produce them today, and they stack rather than compete. First, prompt instructions (“respond only with JSON”) — cheap, unreliable. Second, provider-native constrained decoding, such as OpenAI’s json_schema response format or Anthropic’s strict tool use, which guarantees syntactic conformance. Third, client-side validation libraries like Instructor, which add semantic checks on top and retry when those checks fail.

That third layer matters more than people expect. Native structured outputs guarantee the shape is correct. They do not guarantee the content is correct — a model can return {"priority": "urgent"} when your enum only allows low | medium | high, or a confidence score of 1.7. Instructor catches those, feeds the validation error back to the model, and asks again.

Why Instructor Instead of Raw JSON Parsing

Instructor is a thin wrapper around your existing provider SDK. It patches the client, accepts a response_model argument, converts your Pydantic model into a tool or JSON schema, validates the response, and retries with the validation error appended to the conversation. You keep the SDK you already use; you gain types.

Here is how the common approaches compare:

ApproachSyntactic validitySemantic validationRetriesType safetyProvider support
Prompt says “return JSON”Not guaranteedNoneYou write itNoneAll
Provider JSON modeGuaranteed valid JSONNoneYou write itNoneMost
Native structured outputsGuaranteed schema matchNoneYou write itNoneOpenAI, Anthropic, Gemini
Instructor + PydanticGuaranteedFull Pydantic validatorsBuilt inFull, via type hints15+ providers

The practical win is the last column combined with the third. Because Instructor sits above the provider, the same Pydantic model works against OpenAI, Anthropic, Gemini, or a local Ollama server. Swapping providers becomes a one-line client change instead of a rewrite of your parsing layer. If you are already comparing model backends, our guide to structured outputs in the OpenAI API covers the native mechanism Instructor builds on top of.

Setting Up Instructor and Your First Pydantic Model

Install Instructor alongside whichever provider SDK you use:

# Instructor plus the OpenAI SDK
pip install "instructor[openai]"

# Expected output (abridged):
# Successfully installed instructor-1.x.x openai-1.x.x pydantic-2.x.x

Now patch the client and define a schema. The example below extracts structured data from an inbound support email — a realistic first use case, because the input is messy and the downstream consumer (a ticketing system) needs clean fields.

import os
from enum import Enum

import instructor
from openai import OpenAI
from pydantic import BaseModel, Field

# from_provider handles client construction and mode selection for you.
client = instructor.from_provider("openai/gpt-4.1-mini")


class Priority(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    URGENT = "urgent"


class SupportTicket(BaseModel):
    """Structured representation of an inbound customer email."""

    summary: str = Field(description="One-sentence summary of the customer's problem")
    priority: Priority = Field(description="Urgency based on business impact, not tone")
    product_area: str = Field(description="Which part of the product is affected")
    customer_sentiment: int = Field(
        ge=1, le=5, description="1 = very negative, 5 = very positive"
    )


EMAIL = """
Hi — we've been unable to log in since this morning. Our whole
support team (about 40 people) is locked out and we have SLAs to hit.
This is the third outage this quarter. Please escalate.
"""

ticket = client.chat.completions.create(
    response_model=SupportTicket,
    messages=[{"role": "user", "content": f"Extract a ticket from:\n\n{EMAIL}"}],
    max_retries=3,
)

print(ticket.priority)            # Priority.URGENT
print(ticket.customer_sentiment)  # 1
print(type(ticket))               # <class '__main__.SupportTicket'>

Why this works: response_model tells Instructor to convert SupportTicket into a tool schema, force the model to call it, and parse the arguments back through Pydantic. Because Priority is an Enum, the schema sent to the model includes the allowed values — the model rarely invents new ones, and Pydantic rejects it when it does. The ge=1, le=5 constraint on customer_sentiment is enforced client-side; a returned 7 triggers a retry rather than corrupting your analytics.

Notice what is absent: no json.loads(), no regex to strip markdown fences, no if "priority" in data guard clauses. Those disappear because the type system now carries that weight. If Pydantic is new to you, our walkthrough of advanced Pydantic validation in FastAPI covers the validator patterns used throughout this post.

How Instructor’s Retry Loop Actually Works

The max_retries=3 argument is the feature people underuse, largely because they assume it is a simple network retry. It is not. Instructor re-prompts on validation failure, and it includes the failure message in the follow-up.

The sequence looks like this:

  1. Instructor converts your Pydantic model to a JSON schema and attaches it as a tool definition.
  2. The provider returns tool-call arguments.
  3. Instructor runs SupportTicket.model_validate_json() on those arguments.
  4. On success, you get the object. On ValidationError, Instructor appends the assistant’s bad response plus the validation error text as a new user message.
  5. The model sees “field customer_sentiment must be less than or equal to 5″ and corrects itself.
  6. After max_retries attempts, Instructor raises InstructorRetryException.

Consequently, your validators become instructions. A well-worded constraint message is effectively a self-correcting prompt, which is why the next section matters so much.

Handle exhaustion explicitly rather than letting it propagate as a generic error:

import logging

from instructor.exceptions import InstructorRetryException

logger = logging.getLogger(__name__)


def extract_ticket(email_body: str) -> SupportTicket | None:
    """Extract a ticket, returning None when the model cannot produce valid output."""
    try:
        return client.chat.completions.create(
            response_model=SupportTicket,
            messages=[{"role": "user", "content": f"Extract a ticket from:\n\n{email_body}"}],
            max_retries=3,
        )
    except InstructorRetryException as exc:
        # n_attempts and last_completion are the two fields worth logging.
        logger.error(
            "Extraction failed after %s attempts. Last raw output: %s",
            exc.n_attempts,
            exc.last_completion,
        )
        return None

Why this works: InstructorRetryException carries n_attempts and last_completion, so your logs contain the actual text the model produced instead of a stack trace ending in Pydantic internals. In practice, repeated failures on the same input almost always point at an ambiguous field description rather than a flaky model.

For finer control, max_retries also accepts a Tenacity Retrying object, which lets you add exponential backoff between attempts. That matters because each retry is a billed request — see our guide to LLM rate limiting and retry strategies for how to combine schema retries with transport-level backoff without stacking them badly.

Adding Real Validation With Pydantic Validators

Type constraints catch obvious errors. Business rules need field_validator and model_validator. Both run inside Instructor’s retry loop, so a raised ValueError becomes a correction instruction.

from typing import Annotated

from pydantic import BaseModel, Field, field_validator, model_validator


class InvoiceLine(BaseModel):
    description: str
    quantity: int = Field(gt=0)
    unit_price_cents: int = Field(ge=0)
    total_cents: int


class Invoice(BaseModel):
    invoice_number: str
    currency: str
    lines: list[InvoiceLine]
    total_cents: int

    @field_validator("currency")
    @classmethod
    def currency_must_be_iso_4217(cls, value: str) -> str:
        allowed = {"USD", "EUR", "GBP", "CHF"}
        upper = value.upper()
        if upper not in allowed:
            raise ValueError(
                f"currency must be one of {sorted(allowed)}, got '{value}'. "
                "Use the three-letter ISO 4217 code printed on the invoice."
            )
        return upper

    @model_validator(mode="after")
    def totals_must_reconcile(self) -> "Invoice":
        computed = sum(line.quantity * line.unit_price_cents for line in self.lines)
        if computed != self.total_cents:
            raise ValueError(
                f"total_cents ({self.total_cents}) does not equal the sum of "
                f"line items ({computed}). Recompute from the line items."
            )
        return self

Why this works: the error messages are written for the model, not for a developer reading a traceback. “Recompute from the line items” is an actionable instruction; “invalid total” is not. On arithmetic-heavy extraction, this single pattern eliminates most silent errors, because the model is forced to reconcile its own numbers before your code ever sees them.

Use mode="after" for cross-field checks like the one above, since it runs once all fields are populated and coerced. Use field_validator for single-field normalization — uppercasing currency codes, stripping whitespace, canonicalizing dates.

One caution: expensive validators multiply cost. A model_validator that calls a database to check whether a SKU exists will run on every retry attempt. Keep external lookups outside the model and validate against them after extraction succeeds.

Handling Missing Data Without Hallucinated Fields

The most common production failure is not malformed JSON. It is a confidently invented value for a field that simply was not present in the source document. A required phone_number: str field guarantees the model produces something, and that something is frequently fiction.

Make optionality explicit and describe it:

from pydantic import BaseModel, Field


class ContactRecord(BaseModel):
    full_name: str
    email: str | None = Field(
        default=None,
        description="Email address, or null if none appears in the document. Do not guess.",
    )
    phone: str | None = Field(
        default=None,
        description="Phone in E.164 format, or null if absent. Do not infer from area context.",
    )
    company: str | None = Field(
        default=None, description="Employer name, or null if not stated"
    )

Why this works: str | None with default=None makes the schema explicitly nullable, and the description tells the model that null is an acceptable, expected answer. Without that permission, models treat every field as a question they must answer. With it, extraction accuracy on sparse documents improves substantially, because “I don’t know” becomes a valid output.

Similarly, add a catch-all when the input may not be extractable at all. A union response model — SupportTicket | NotATicket — lets the model route rather than force-fit, and Instructor validates whichever branch it picks.

Nested Models and Lists for Complex Extraction

Real documents are hierarchical. Pydantic handles nesting natively, and Instructor passes the full nested schema through, so you rarely need to flatten anything.

from datetime import date

from pydantic import BaseModel, Field


class Attendee(BaseModel):
    name: str
    role: str | None = None


class ActionItem(BaseModel):
    task: str = Field(description="Imperative phrasing, e.g. 'Send the revised SOW'")
    owner: str = Field(description="Name of the attendee responsible")
    due_date: date | None = Field(
        default=None, description="ISO 8601 date, or null if no deadline was stated"
    )


class MeetingNotes(BaseModel):
    title: str
    attendees: list[Attendee]
    decisions: list[str] = Field(description="Decisions actually made, not topics discussed")
    action_items: list[ActionItem]

    @model_validator(mode="after")
    def owners_must_be_attendees(self) -> "MeetingNotes":
        names = {a.name for a in self.attendees}
        unknown = {item.owner for item in self.action_items} - names
        if unknown:
            raise ValueError(
                f"action item owners {sorted(unknown)} are not in the attendee list "
                f"{sorted(names)}. Only assign tasks to listed attendees."
            )
        return self

Why this works: the cross-model validator enforces referential integrity across two lists, which no schema constraint can express. This is the class of rule that separates Instructor from native structured outputs — the provider can guarantee owner is a string, but only your validator can guarantee it is a string that appears elsewhere in the same response.

Keep nesting to three levels or fewer. Deeply nested schemas inflate the prompt, raise token cost, and measurably reduce accuracy. When you need more depth, run two extraction passes and join the results in Python.

Streaming Structured Outputs as They Generate

Users tolerate latency far better when they can see progress. Instructor supports two streaming modes, and they solve different problems.

create_partial streams progressively-filled instances of a single model — useful for a form that populates field by field:

for partial in client.chat.completions.create_partial(
    response_model=MeetingNotes,
    messages=[{"role": "user", "content": transcript}],
):
    # Each iteration yields a MeetingNotes with more fields populated.
    render_ui(partial)

create_iterable streams a sequence of complete objects — the right choice when extracting many records from one document:

for item in client.chat.completions.create_iterable(
    response_model=ActionItem,
    messages=[{"role": "user", "content": transcript}],
):
    # Each item is a fully validated ActionItem, available as soon as it completes.
    queue.enqueue(item)

Why this matters: with create_iterable, downstream work starts on record one instead of record N. On a transcript producing twenty action items, that turns a single long wait into a steady stream. For the transport side of streaming — SSE versus WebSockets, reconnection, and backpressure — see our comparison of streaming LLM responses over SSE and WebSockets.

Note the trade-off: partial objects are, by definition, not fully validated. Cross-field validators cannot run until every field exists. Treat streamed partials as display-only and act on the final object.

Using Instructor With Claude and Other Providers

Instructor’s provider abstraction is genuinely useful, not marketing. The same Pydantic models work across backends because Instructor translates them into whatever mechanism each provider exposes — tool use for Anthropic, json_schema for OpenAI, function declarations for Gemini.

import anthropic
import instructor

# Anthropic requires max_tokens on every request.
claude = instructor.from_anthropic(anthropic.Anthropic())

ticket = claude.messages.create(
    model="claude-opus-5",
    max_tokens=4096,
    response_model=SupportTicket,
    messages=[{"role": "user", "content": f"Extract a ticket from:\n\n{EMAIL}"}],
    max_retries=3,
)

For local models, point Instructor at an Ollama endpoint and switch modes, since smaller models often handle plain JSON better than tool calling:

from openai import OpenAI

local = instructor.from_openai(
    OpenAI(base_url="http://localhost:11434/v1", api_key="ollama"),
    mode=instructor.Mode.JSON,
)

Why the mode matters: Mode.TOOLS is the default and the most reliable on frontier models. Mode.JSON asks for raw JSON and is often the only option that works on quantized local models with weak tool-calling support. Mode.TOOLS_STRICT opts into provider-side constrained decoding where available, which reduces retries at the cost of some schema flexibility. Pick per model, not per project. Our guide to Claude tool use explains the underlying mechanism Instructor drives on the Anthropic side.

Real-World Scenario: A Support Ticket Triage Pipeline

Consider a mid-sized SaaS company routing several hundred inbound support emails a day, handled by a small platform team. The initial implementation prompted for JSON and parsed it manually. It worked in staging and degraded steadily in production.

Three failure modes dominated. First, roughly one response in fifty arrived wrapped in markdown fences, breaking json.loads() — a class of bug that only surfaces at volume. Second, the priority field accumulated values like "critical""P1", and "very high" alongside the four the router understood, so tickets silently fell through to a default queue. Third, sentiment scores occasionally exceeded the documented range and skewed the weekly dashboard, which nobody noticed for several weeks.

Migrating to Instructor addressed all three with the same change. The enum eliminated priority drift, because unrecognized values fail validation and trigger a corrective retry. The ge/le constraints bounded sentiment. Markdown fences stopped mattering entirely, since tool-call arguments are not free text.

The trade-off was real and worth stating plainly: retries cost tokens. A schema with tight constraints and vague field descriptions can retry on a meaningful share of requests, and each retry is a full billed call. The team’s fix was not to loosen validation but to rewrite field descriptions as instructions — “urgency based on business impact, not tone” replaced “the priority” — which cut retry rate sharply. Tighten your prompts before you loosen your schema.

Testing and Cost Control for Structured LLM Outputs

Structured LLM outputs are far easier to test than free text, and you should take advantage of that.

For unit tests, skip the model entirely. Your Pydantic models are ordinary classes; construct them directly and assert your downstream logic behaves. Reserve live API calls for a small integration suite pinned to representative fixtures.

For the integration layer, assert on properties rather than exact values. assert ticket.priority in {Priority.HIGH, Priority.URGENT} survives model upgrades; assert ticket.summary == "..." does not.

On cost, three levers matter. First, watch retry rate — instrument it with Instructor’s hooks:

def log_retry(error: Exception) -> None:
    metrics.increment("instructor.validation_retry")


client.on("parse:error", log_retry)

Second, keep schemas lean. Every field description is prompt tokens on every call, including retries. Third, use create_with_completion when you need usage data alongside the parsed object:

ticket, completion = client.chat.completions.create_with_completion(
    response_model=SupportTicket,
    messages=[{"role": "user", "content": EMAIL}],
)
print(completion.usage.total_tokens)

Why this works: create_with_completion returns both the validated model and the raw provider response, so you get token accounting without a second call. Pair it with the techniques in our guide to token counting and budget management for LLM apps to attribute spend per schema.

When to Use Instructor

  • You need typed, validated data flowing into a database, queue, or downstream API
  • Your extraction has business rules that a JSON schema cannot express, such as cross-field arithmetic or referential integrity
  • You want the same schema to work across multiple model providers without rewriting parsing logic
  • Your team already uses Pydantic and FastAPI, so the models are reusable across the stack
  • You are extracting from messy inputs — emails, PDFs, transcripts — where partial or missing data is normal

When NOT to Use Instructor

  • The task is open-ended generation: chat replies, summaries, or creative writing where structure adds nothing
  • You need every last millisecond of latency and a single native structured-outputs call already satisfies your schema
  • Your schema is one flat object with three string fields and no validation rules — native structured outputs are simpler
  • You are working in a language other than Python; look at Zod-based equivalents in the TypeScript ecosystem instead
  • Retries are unaffordable for your workload and you cannot tolerate the occasional extra billed call

Common Mistakes With Instructor and Pydantic

  • Making every field required, which pushes the model to invent values rather than return null
  • Writing validator error messages for developers instead of for the model, so retries repeat the same mistake
  • Setting max_retries high on a schema with contradictory constraints, turning one failure into five billed calls
  • Putting database lookups or HTTP calls inside model_validator, so they run on every retry attempt
  • Nesting models four or five levels deep, inflating token cost and degrading accuracy
  • Treating streamed partial objects as validated, when cross-field validators have not run yet
  • Leaving Mode.TOOLS on a small local model that cannot call tools reliably, instead of switching to Mode.JSON
  • Asserting exact string equality in tests, which breaks on every model version bump

Conclusion

Structured LLM outputs turn an unpredictable text generator into a component you can put behind a type signature. Instructor gets you there with very little ceremony: define a Pydantic model, pass it as response_model, and let the validation-and-retry loop absorb the failures your parsing code used to leak. The real leverage comes from validators — they are simultaneously your data contract and your correction prompt.

Start with one endpoint. Pick the extraction path in your application that currently has the most defensive parsing code, replace it with a Pydantic model and a max_retries=3, then instrument the retry rate. If retries are frequent, rewrite your field descriptions as instructions before touching the schema.

Next, explore Pydantic AI for type-safe agents in Python, which applies the same typed-contract philosophy to multi-step agent workflows.

Leave a Comment