Free lesson · GenAI Application Engineering

Extract structured output with Instructor + Pydantic

You will build a StructuredExtractor class in extraction/structured.py using instructor.from_litellm(litellm.acompletion) to create a patched client. The extractor exposes async def extract(model: str, messages: list[dict], response_model: type[T]) -> T calling the patched client with Pydantic response_model for automatic schema enforcement. You define response models: SentimentResult(sentiment: Literal['positive','negative','neutral'], confidence: float, reasoning: str), CodeReview(issues: list[Issue], severity: Literal['low','medium','high']), and EntityExtraction(entities: list[Entity], relationships: list[Relationship]). You configure max_retries=3 for automatic retry on ValidationError. You implement streaming partial objects via instructor stream_partial for real-time output. A FastAPI endpoint POST /api/v1/extract exposes extraction.

Course: Full-Stack GenAI Applications · Chapter 2 · Multi-Provider LLM Gateway with LiteLLM

Free to read — no subscription required.

Introduction

When you parse raw LLM text by hand—string-splitting JSON, regex-matching fields, hoping the model didn't wrap its answer in conversational preamble—your pipeline silently corrupts data the first time a model returns "positive" where you expected a float, or omits a required field entirely. By the end of this lesson you'll be able to extract validated Pydantic objects from any LiteLLM-supported provider using Instructor, with automatic retry on validation failure so malformed outputs become a correction signal instead of a runtime crash.

Key Terminology

  • response_model: The Pydantic class passed to Instructor that defines the JSON Schema injected into the LLM tool call, controlling both the structure of the model's output and the validation rules applied to it.
  • max_retries: The number of times Instructor re-invokes the LLM with appended validation errors before raising an InstructorRetryException, directly impacting both extraction reliability and token cost.
  • from_litellm: The Instructor factory function that patches LiteLLM's acompletion to support response_model, enabling structured extraction across any provider LiteLLM supports without provider-specific code.
  • model_validator: A Pydantic decorator that runs cross-field validation logic after individual field validators pass, used to enforce business invariants that Instructor surfaces as retry correction signals.
  • InstructorRetryException: The exception raised when all retry attempts are exhausted without producing a valid response, signaling to the calling code that extraction has definitively failed for the given model and input combination.

Concepts

Retry Mechanics and Cost Implications

Each Instructor retry consumes additional tokens—the original messages plus the appended validation error plus the model's new response. For a typical extraction with a 500-token input and a 200-token response model schema, a single retry adds approximately 800-1000 tokens of additional input (original context + previous response + error message) plus another 200-token output. At three retries, a failed extraction attempt can cost 4x the tokens of a successful first attempt.

Every retry generates a separate LiteLLM response object with its own usage metadata, so any caller that tracks token spend must aggregate usage across all retry attempts, not just the final successful call. In practice, Instructor aggregates usage across retries in the final response's _raw_response attribute, which downstream token accounting code can access directly.

Defensive Patterns for Production Extraction

Three patterns distinguish production-grade extraction from tutorial examples. First, always define description on every Field—these descriptions become part of the tool schema and materially improve extraction accuracy across all providers. Second, prefer Literal types and Enum classes over bare str fields for any categorical output; the constrained vocabulary reduces hallucination and makes validation failures actionable. Third, use model_validator with mode="after" for cross-field invariants that cannot be expressed as individual field constraints—Instructor captures the raised ValueError message verbatim and includes it in the retry prompt, so write error messages that read as correction instructions (e.g., "Confidence must be below 0.85 for neutral sentiment" rather than "Invalid value").

When extraction must handle adversarial or highly variable input text, add an Optional wrapper around fields that the model might legitimately be unable to populate. A field typed as confidence: float | None = Field(default=None, ...) tells the model that returning None is acceptable when the text is genuinely ambiguous, preventing forced hallucination. But use None-able fields sparingly—every optional field is a field that downstream code must null-check, trading extraction reliability for code complexity.

Code Walkthrough

How Instructor Intercepts the LLM Call

Understanding the interception mechanism is critical before writing any extraction code. Instructor does not post-process raw text. Instead, it patches the completion function to inject a JSON Schema derived from your Pydantic model as a tool (function-calling) definition in the API request. The LLM provider then constrains its output to match that schema, returning structured JSON rather than free-form text. When the JSON arrives, Instructor deserializes it into your Pydantic model, which runs all field validators, Field constraints, and custom model_validator methods. If any validator raises a ValidationError, Instructor catches it, appends the error details to the message history, and retries the call—up to a configurable maximum.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares this as a Mermaid sequence diagram, which visualizes interactions between components over time.
  • Lines 2-5: Define the four participants (actors) in the diagram: Application Code (the caller), Instructor Client (the structured-output wrapper), LiteLLM acompletion (the async LLM routing layer), and LLM Provider (the actual model endpoint).
  • Line 7: The application initiates the flow by calling client.chat.completions.create() on the Instructor client, passing a Pydantic model (SentimentResult) as the desired response_model.
  • Line 24: Closes the alt/else conditional block, ending the sequence diagram.

This diagram illustrates the full round-trip. Notice that the retry loop operates at the Instructor layer, not at the application layer. Your calling code receives either a fully validated Pydantic instance or a raised exception after all retries are exhausted—there is no intermediate state where you hold an unvalidated partial result.

Defining Response Models with Validation Constraints

The quality of your structured extraction depends entirely on how precisely you define your Pydantic response model. Vague models with broad types like str and int produce vague outputs. Production models use Field constraints, Literal types, Annotated validators, and custom model_validator methods to reject ambiguous or incorrect responses at the schema level, which then triggers Instructor's retry mechanism with a specific error message that guides the model toward a correct response.

The following code defines two Pydantic response models that the StructuredExtractor class uses in the lab. The SentimentResult model extracts sentiment analysis with a constrained confidence score, while the EntityList model extracts named entities with enumerated category types. Both models use Field with description parameters that Instructor injects into the tool schema, effectively becoming part of the prompt that instructs the LLM how to populate each field. The model_validator on SentimentResult enforces a business rule that neutral sentiments must have confidence below 0.85, which cannot be expressed through simple field constraints alone.

Code snippet python
1from pydantic import BaseModel, Field, model_validator 2from typing import Literal 3from enum import Enum 4 5class SentimentResult(BaseModel): 6 """Structured sentiment analysis output with validation.""" 7 8 sentiment: Literal["positive", "negative", "neutral"] = Field( 9 description="Overall sentiment classification of the input text" 10 ) 11 confidence: float = Field( 12 ge=0.0, le=1.0, 13 description="Confidence score between 0.0 and 1.0 inclusive" 14 ) 15 reasoning: str = Field( 16 min_length=20, max_length=500, 17 description="Brief explanation justifying the sentiment label" 18 ) 19 20 @model_validator(mode="after") 21 def neutral_requires_low_confidence(self) -> "SentimentResult": 22 if self.sentiment == "neutral" and self.confidence > 0.85: 23 raise ValueError( 24 "Neutral sentiment should have confidence <= 0.85 " 25 "because neutral implies ambiguity" 26 ) 27 return self 28 29class EntityCategory(str, Enum): 30 PERSON = "person" 31 ORGANIZATION = "organization" 32 LOCATION = "location" 33 PRODUCT = "product" 34 35class Entity(BaseModel): 36 name: str = Field(min_length=1, description="The entity surface form") 37 category: EntityCategory = Field( 38 description="Entity type from the allowed categories" 39 ) 40 start_index: int = Field( 41 ge=0, description="Character offset where entity begins in source text" 42 ) 43 44class EntityList(BaseModel): 45 entities: list[Entity] = Field( 46 min_length=1, 47 description="All named entities found in the text, at least one required" 48 )
  • Lines 1-3: Import BaseModel and Field from Pydantic for schema definition, model_validator for cross-field validation, Literal for constrained string unions, and Enum for enumerated categories.
  • Lines 6-7: The SentimentResult class inherits from BaseModel. The docstring becomes part of the tool schema description that Instructor sends to the LLM, so it should clearly describe the expected output structure.
  • Lines 9-11: The sentiment field uses Literal to restrict values to exactly three strings. Any other string the model returns triggers a ValidationError and an automatic retry.
  • Lines 43-47: The EntityList wrapper enforces min_length=1 on the entities list, meaning the model cannot return an empty extraction. If no entities exist in the text, this constraint forces a retry—a deliberate design choice for use cases where the upstream caller guarantees entity-bearing input.

Building the StructuredExtractor Class

With response models defined, the extraction logic itself is surprisingly concise. The StructuredExtractor class in extraction/structured.py wraps the Instructor-patched LiteLLM client and exposes a single extract method that accepts any Pydantic BaseModel subclass as the response_model parameter. The class constructor calls instructor.from_litellm(litellm.acompletion) to create a patched async client. This patched client intercepts every call, injects the Pydantic schema as a tool definition, and handles retry logic transparently. The extract method accepts a model string following LiteLLM's provider-prefixed convention (e.g., "gemini/gemini-2.5-flash", "anthropic/claude-sonnet-4-20250514") so the same extractor works across all providers configured in your gateway.

Code snippet python
1import litellm 2import instructor 3from pydantic import BaseModel 4from typing import TypeVar 5 6T = TypeVar("T", bound=BaseModel) 7 8class StructuredExtractor: 9 """Unified structured output extraction via Instructor + LiteLLM.""" 10 11 def __init__(self, max_retries: int = 3, temperature: float = 0.0): 12 self.client = instructor.from_litellm(litellm.acompletion) 13 self.max_retries = max_retries 14 self.default_temperature = temperature 15 16 async def extract( 17 self, 18 model: str, 19 content: str, 20 response_model: type[T], 21 system_prompt: str = "Extract structured data from the provided text.", 22 temperature: float | None = None, 23 ) -> T: 24 """Extract a validated Pydantic object from LLM output. 25 26 Args: 27 model: LiteLLM model string (e.g., 'openai/gpt-4o'). 28 content: The text to extract structured data from. 29 response_model: Pydantic model class defining the schema. 30 system_prompt: Instruction for the extraction task. 31 temperature: Override default temperature for this call. 32 33 Returns: 34 A validated instance of response_model. 35 36 Raises: 37 instructor.exceptions.InstructorRetryException: 38 After max_retries validation failures. 39 """ 40 result: T = await self.client.chat.completions.create( 41 model=model, 42 messages=[ 43 {"role": "system", "content": system_prompt}, 44 {"role": "user", "content": content}, 45 ], 46 response_model=response_model, 47 max_retries=self.max_retries, 48 temperature=temperature or self.default_temperature, 49 ) 50 return result
  • Lines 1-4: Import litellm for the unified completion interface, instructor for the patching mechanism, BaseModel as the bound for the generic type variable, and TypeVar for generic return typing.
  • Line 6: Define a generic type variable T bounded to BaseModel. This allows the extract method to return the exact Pydantic subclass passed as response_model, preserving type safety through the call chain so that IDE autocompletion and mypy understand the return type.
  • Lines 9-10: The StructuredExtractor class encapsulates all extraction configuration. The docstring serves as documentation for lab students navigating the codebase.
  • Line 51: The method returns the validated Pydantic instance directly. No additional parsing, no None checks, no try/except needed at the call site for normal operation. If extraction fails after all retries, the exception propagates to the caller.

Do's and Don'ts

Do's

  1. Do define Field(description=...) on every Pydantic model field — Instructor injects these descriptions directly into the tool schema that the LLM receives, turning them into inline prompt instructions that guide the model to populate each field correctly before any retry is needed.
  2. Do use Literal, Enum, and ge/le constraints on fields where you can — constraints like Literal["positive", "negative", "neutral"] and float with ge=0.0, le=1.0 let Pydantic reject out-of-range values at deserialization time, which triggers Instructor's automatic retry with the specific ValidationError message appended to the conversation rather than crashing your pipeline.
  3. Do encode cross-field business rules in a @model_validator(mode="after") method — rules that can't be expressed as single-field constraints (such as requiring confidence <= 0.85 when sentiment == "neutral") must live here so that a ValueError raised inside the validator propagates through Instructor's retry loop with a descriptive correction message, steering the model toward a valid response.

Don'ts

  1. Don't parse LLM output with string-splitting, regex, or json.loads on raw text — doing so bypasses the tool-calling interception that Instructor sets up via client.chat.completions.create(response_model=...), meaning you lose both the schema-constrained JSON generation and the automatic retry loop, and a single malformed response silently corrupts data instead of triggering correction.
  2. Don't use broad types like str or int where constrained types are appropriate — a field typed as plain str instead of Literal["positive", "negative", "neutral"] gives the LLM unconstrained output space and prevents ValidationError from firing on invalid values, so Instructor never retries and you receive unvalidated free-form text disguised as a structured object.
  3. Don't handle ValidationError yourself in the call site when max_retries is sufficient — wrapping client.chat.completions.create() in a bare except ValidationError that swallows the error means you accept a partially-filled or invalid SentimentResult/EntityList instance; the error should only be caught after retries are exhausted, because Instructor's retry loop uses the error details as a correction signal in the message history.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering