Structured outputs from AI: Pydantic, Instructor and JSON Schema in 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.
/// EWOLUCJA STRUCTURED OUTPUTS
3 podejścia — od chaosu do gwarantowanej struktury
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 — PIPELINE WALIDACJI
Od klasy Pydantic do zwalidowanego obiektu
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_RECORDS
How AI Reads Invoices from Email and Enters Them into ERP
AI can automatically read an invoice from an email attachment — PDF, scan, or phone photo — and enter the data directly into an ERP system without any manual retyping. Full automation of cost invoice processing: from the mailbox to accounting.
Where to Start with AI Implementation in Your Company
AI implementation starts not with choosing a tool, but with identifying one repetitive process that wastes the most human time. Learn step by step how to select, map, and automate that process.
How to Build a Company Internal Knowledge Base with AI (RAG in Practice)
An internal knowledge base built on RAG lets you create your own company chatbot that answers only from your company's documents — not the model's guesses. Safe, up-to-date, precise AI with full control over your data.
Signal received?
Terminate
Silence
Initiate protocol. Establish connection. Let's build something loud.
