
Structured outputs from AI: Pydantic, Instructor and JSON Schema in production
Structured Outputs with JSON Schema or the Instructor library guarantee that the model returns data in exactly the format your code expects - validated either at the API level or in client code. This eliminates the entire string-parsing nightmare: no markdown fences, no comments inside JSON, no fields in the wrong type. If you are building a data extraction pipeline or an LLM integration with any downstream system, this is the only approach suitable for production.
How to stop parsing strings from GPT and start getting data ready to use in code - JSON Schema, Pydantic and the Instructor library.
Tuesday morning, production deployment. The model returns: {"name": "Jan Kowalski", "age": "thirty-two", "tags": "python, django"}. Your code expected age as int, tags as list - and it throws an exception. The model "tried its best", but it couldn't know that the list is ["python", "django"], not a string. This isn't an edge case - it's the daily reality when an LLM and code communicate through a string.
Three Approaches - and Why the First Two Fail
Most teams go through the same phases. Phase 1 - "tell GPT to return JSON" - works for a week, then the model adds a markdown fence or a comment and json.loads blows up. Phase 2 - JSON mode (response_format={"type": "json_object"}) - stable JSON, but without a schema the model decides the field shapes itself. Phase 3 - Structured Outputs with JSON Schema or Instructor - you get exactly what you described, validated either at the API level or in code.
/// EVOLUTION OF STRUCTURED OUTPUTS
3 approaches — from chaos to guaranteed structure
JSON Schema and strict mode - API-side validation
OpenAI Structured Outputs (from GPT-4o) enforce the schema at the tokenisation level - the model only generates tokens matching the defined structure. strict: true + response_format with json_schema guarantees the response always parses without error. Requirements: every object needs additionalProperties: false and all fields in required - you implement optionality via anyOf with {"type": "null"}.
from openai import OpenAIimport jsonclient = OpenAI()SCHEMA = { "name": "order_extraction", "strict": True, "schema": { "type": "object", "properties": { "customer_name": {"type": "string"}, "order_id": {"type": "string"}, "items": { "type": "array", "items": { "type": "object", "properties": { "product": {"type": "string"}, "quantity": {"type": "integer"}, "price_pln": {"type": "number"} }, "required": ["product", "quantity", "price_pln"], "additionalProperties": False } }, "total_pln": {"type": "number"} }, "required": ["customer_name", "order_id", "items", "total_pln"], "additionalProperties": False }}resp = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": "Extract: Jan Kowalski, ORD-001234, 3x coffee 12.99 PLN, 1x tea 8.50 PLN"}], response_format={"type": "json_schema", "json_schema": SCHEMA})order = json.loads(resp.choices[0].message.content)print(order["total_pln"])
The result is always valid JSON matching the schema - zero exceptions from json.loads. The downside is verbosity: for complex objects, JSON Schema quickly becomes unreadable and hard to maintain.
Pydantic as the schema description layer
Instead of writing JSON Schema by hand, describe the structure as a Pydantic class. Model.model_json_schema() generates the schema automatically from type hints and validators. The key: Field(description=...) - the LLM reads field descriptions and fills data far more accurately when it knows what you expect. field_validator lets you add business rules that JSON Schema can't express - sum validation, ID format, conditional rules.
from pydantic import BaseModel, Field, field_validatorfrom typing import Optionalimport reclass OrderItem(BaseModel): product: str = Field(description="Product name exactly as written in the text") quantity: int = Field(ge=1, description="Number of units, min 1") price_pln: float = Field(gt=0, description="Unit price in PLN")class Order(BaseModel): customer_name: str = Field(description="Customer's first and last name") order_id: str = Field(description="Order ID in format ORD-XXXXXX") items: list[OrderItem] = Field(description="List of all order items") total_pln: float = Field(description="Sum of all items in PLN") notes: Optional[str] = Field(default=None, description="Notes if provided, otherwise null") @field_validator("order_id") @classmethod def validate_order_id(cls, v: str) -> str: if not re.match(r"ORD-d{6}$", v): raise ValueError(f"order_id must be ORD-XXXXXX, got: {v}") return v @field_validator("total_pln") @classmethod def validate_total(cls, v: float, info) -> float: if "items" in info.data: expected = sum(i.price_pln * i.quantity for i in info.data["items"]) if abs(v - expected) > 0.01: raise ValueError(f"total_pln {v} != sum of items {expected:.2f}") return v
`field_validator` lets you define business rules - sum validation, ID format, date ranges - that JSON Schema can't handle. A validation error gives you a concrete message you can pass back to the model in the next retry.
Instructor - 3 lines of code instead of your own parser
Instructor wraps the OpenAI client (and 10+ other providers) and turns the response directly into a validated Pydantic object. You don't need json.loads, model.model_validate or manual retry - the library handles it for you with 3 retries by default, sending the validation error message back to the model as context.
import instructorfrom openai import OpenAIfrom pydantic import BaseModel, Fieldfrom typing import Literalclient = instructor.from_openai(OpenAI())class ProductReview(BaseModel): sentiment: Literal["positive", "negative", "neutral"] score: int = Field(ge=1, le=5, description="Rating 1-5") key_issues: list[str] = Field(description="List of main problems or strengths, max 5 points") would_recommend: bool summary: str = Field(max_length=200, description="One-sentence summary")review = client.chat.completions.create( model="gpt-4o", response_model=ProductReview, messages=[ {"role": "user", "content": "Analyse: 'Product arrived damaged, support didn't pick up for 3 days, eventually got a refund but wasted my time. Never again.'"} ])print(review.sentiment)print(review.score)print(review.key_issues)
`response_model=ProductReview` is all you need - Instructor generates the JSON Schema from the class, calls the API, parses the response, validates with Pydantic, and on failure automatically retries with the error appended to the conversation context.
/// INSTRUCTOR — VALIDATION PIPELINE
From a Pydantic class to a validated object
Patterns: extraction, classification, normalisation
Three main use cases differ in their approach to the schema. Extraction (pulling data from text) - use Optional for fields that may not appear; never force fields the model can't fill. Classification - use Literal or Enum instead of str, the model will only choose from allowed values. Normalisation - describe the exact output format with an example in description and use field_validator to verify.
| Pattern | Field type | Key trick | Pitfall |
|---|---|---|---|
| Extraction | Optional[str] | null when field absent from text | Forcing fields that aren't there |
| Classification | Literal["a","b","c"] | Enum instead of str | Too many classes (>10) - quality drops |
| Date normalisation | str + validator | Format example in description | Timezones - always use UTC |
| List of items | list[Model] | "Extract ALL" in description | Duplicates - deduplicate in validator |
| Nested objects | BaseModel in BaseModel | Flat schema is faster and more accurate | Depth >3 - hallucinations |
from enum import Enumfrom typing import Optional, Literalfrom pydantic import BaseModel, Field, field_validatorfrom datetime import datetimeimport instructorfrom openai import OpenAIclass Priority(str, Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" CRITICAL = "critical"class TicketExtraction(BaseModel): title: str = Field(max_length=100, description="Short ticket title") priority: Priority = Field(description="Priority based on urgency and business impact") category: Literal["bug", "feature", "question", "billing"] affected_users: Optional[int] = Field(default=None, ge=1, description="Number of affected users if stated, otherwise null") reported_at: Optional[str] = Field(default=None, description="Date in ISO 8601 format e.g. 2026-06-05T10:30:00Z, null if unknown") is_regression: bool = Field(description="True if it worked before") @field_validator("reported_at") @classmethod def validate_date(cls, v: Optional[str]) -> Optional[str]: if v is None: return v try: datetime.fromisoformat(v.replace("Z", "+00:00")) except ValueError: raise ValueError(f"reported_at must be ISO 8601, got: {v}") return vclient = instructor.from_openai(OpenAI())ticket = client.chat.completions.create( model="gpt-4o", response_model=TicketExtraction, messages=[{"role": "user", "content": "URGENT: login stopped working at 10:30, around 500 users can't log in, it worked before"}])print(ticket.priority)print(ticket.affected_users)
Instructor works with multiple providers - instructor.from_anthropic(), instructor.from_gemini(), instructor.from_mistral() - same Pydantic code, different client.
When structured output fails - 4 scenarios
Even with Instructor you hit walls. Here are the four main ones and how to get out.
1. The model can't fill a required field. Symptom: retry loop, model hallucinates a value just to put "something". Fix: change the field to Optional and add description="null if unknown" - let the model admit missing information.
2. The schema is too complex. Symptom: the model fills a field with a random value instead of null. Fix: simplify to a flat structure. If you need complexity, split into two calls - first extracts flat data, second classifies or normalises.
3. Business validation fails after 3 retries. Symptom: InstructorRetryException. Fix: catch the exception and log the model's last attempt - often the rule is too restrictive or the prompt doesn't contain the information the validator expects. Loosen the validator or enrich the prompt with an example of a correct response.
4. The list has too few elements. Symptom: items has 2 instead of 5 entries. Fix: add "Extract ALL items - don't skip any" to the description. Instructor also supports Iterable[Model] as response_model - the model streams objects incrementally.
---
I build data extraction and classification systems for companies - from simple pipelines to complex multi-step architectures with business validation and monitoring. Get in touch - I start with an analysis of your input data and schema design.
Related articles
/// RELATED_SERVICES
Need these concepts implemented? Explore the services related to this topic.
AI App Development
Custom AI software and AI-powered web applications. MVP development, full stack engineering, and AI systems programming from scratch to production.
View serviceServiceWeb Development
Custom websites, stores and web apps in Next.js and React: landing pages, corporate sites, headless stores, B2B platforms, SaaS interfaces and 3D. 99/100 Lighthouse and SEO built in from day one.
View service/// RELATED_RECORDS
Polish Embedding Models - bge-m3, multilingual-e5 or PolDense? A Test on 900 Questions
Which embedding model should you use for Polish RAG? I tested 10 models and BM25 on 900 real customer questions: results, robustness to missing diacritics and typos, the prefix that costs 30 points, the PIRB ranking, API prices, cost at two scales, licences and code.
AI Adoption in Your Team - Why Licences Sit Unused and How to Get People Using AI
According to Gartner, only 5% of companies that finished a Copilot pilot planned a larger rollout, and just 8% of employees use AI daily. Five kinds of resistance and what works, the role of managers, AI champions, measuring adoption at three levels and a 90-day plan.
How to Choose an AI Implementation Partner - 15 Questions, Warning Signs and Contract Clauses
According to MIT NANDA, 95% of AI pilots show no financial impact, yet deployments with partners succeed in 67% of cases. In-house, partner or hybrid, 15 questions to ask, warning signs, contract clauses and a pilot with success criteria.
Signal received?
Terminate
Silence
Initiate protocol. Establish connection. Let's build something loud.
