Ask an LLM for JSON and it usually obliges — until the one time it wraps the JSON in prose, renames a field, or returns a number as a word. Then your json.loads throws, and a feature that demoed perfectly breaks in production. String parsing of model output is a trap.
Structured outputs fix this. Instead of hoping the model returns clean JSON, you hand it a schema and get back a validated object every time. This guide shows the modern Python approach end to end: define the shape with Pydantic, enforce it with OpenAI Structured Outputs, go provider-agnostic with instructor, and handle the cases that still fail. Every snippet runs on the current libraries.
- Comfortable with Python and basic classes
- New to Pydantic? Read the BaseModel primer and the type hints primer
- An OpenAI API key set in your environment — see the API key primer
- Structured outputs return validated data, not text you parse. You define a schema; the API guarantees the response matches it.
- Pydantic is the schema layer. One
BaseModeldescribes the shape and does the validation. responses.parseis the OpenAI-native path;instructoris the provider-agnostic one.- Design still matters — enums, optional fields, and a retry on validation errors are what make it production-safe.
What structured outputs are (and why parsing fails)#
Structured outputs flip the problem. Normally you ask for JSON in the prompt and hope. With structured outputs, you attach a schema, and the model is constrained to produce output that fits it. The result is parsed and validated for you.
Prompt-and-hope fails because “usually valid” is not “always valid.” At scale, the model will eventually add a markdown fence, a trailing comment, or a field you did not ask for. A schema the API enforces removes an entire class of production bugs — the ones where the model was almost right.
Step 1: Define the shape with Pydantic#
Everything starts with the schema. A Pydantic model is the single source of truth for the fields and their types.
from pydantic import BaseModel
class Ticket(BaseModel):
category: str
priority: str
summary: strThat is the whole contract. Pydantic both describes the shape and validates it, so you write the structure once and reuse it everywhere. New to it? The BaseModel primer covers the basics.
Step 2: Enforce it with OpenAI Structured Outputs#
Pass the model to responses.parse as text_format. The SDK sends the JSON Schema, and you get a parsed, validated object back on output_parsed.
from openai import OpenAI
from pydantic import BaseModel
class Ticket(BaseModel):
category: str
priority: str
summary: str
client = OpenAI() # reads OPENAI_API_KEY
response = client.responses.parse(
model="gpt-5.4-mini",
input="Classify this message: My invoice is wrong and I was charged twice!",
text_format=Ticket,
)
ticket = response.output_parsed # a validated Ticket instance
print(ticket.category, "|", ticket.priority)No json.loads, no cleanup. response.output_parsed is a real Ticket, so the rest of your code works with a typed object, not a string.
Prefer the Chat Completions API? The same pattern works with client.chat.completions.parse(...), passing your model as response_format and reading completion.choices[0].message.parsed.
Go provider-agnostic with instructor#
If you use more than one model provider, the instructor library gives you one API across all of them. It patches the client and returns your Pydantic model directly.
import instructor
from openai import OpenAI
from pydantic import BaseModel
class Ticket(BaseModel):
category: str
priority: str
summary: str
client = instructor.from_openai(OpenAI())
ticket = client.chat.completions.create(
model="gpt-5.4-mini",
response_model=Ticket,
messages=[{"role": "user", "content": "Classify: my package never arrived."}],
)
print(ticket.category) # instructor returns the Pydantic object directlySame response_model pattern, any provider — swap the client and model string and the rest is identical. instructor also adds automatic retries on validation errors, which we come back to below.
Handle the hard cases#
Real schemas are more than three strings. Structured outputs handle enums, optional fields, and nested models — and using them is how you stop the model from inventing values.
from enum import Enum
from pydantic import BaseModel
class Priority(str, Enum):
low = "low"
normal = "normal"
high = "high"
class Item(BaseModel):
name: str
qty: int
class Order(BaseModel):
order_id: str
priority: Priority # constrained to the enum values
note: str | None = None # optional field
items: list[Item] # nested list of modelsAn Enum constrains priority to three values, so the model cannot return “urgent-ish.” A nested list[Item] gives you structured line items, not a blob. Encode your rules in the schema and the model has to follow them — that is cheaper and more reliable than policing output in a prompt.
When outputs still fail#
Structured outputs remove most failures, not all. Two still happen. The model can refuse a request on safety grounds, in which case there is no parsed object — check for a refusal before using the result. And a value can miss a business rule your types do not capture, which raises a Pydantic ValidationError.
The fix for the second is a bounded retry: catch the error, feed it back, and ask the model to correct itself. instructor does this for you; if you roll your own, cap the attempts so a stubborn case cannot loop forever.
Never trust a parsed object as correct just because it is valid. Structured outputs guarantee the shape, not the facts. For anything that drives money or actions, validate the values and add evals before you ship.
Common mistakes#
- Parsing strings by hand. If you are running
json.loadson model text, you are one odd response away from a crash. Use a schema. - JSON-shaped, not validated. A model returning JSON is not the same as validated data. Keep Pydantic in the loop.
- Over-loose types.
streverywhere lets the model invent values. Use enums and constraints to pin them down. - No retry path. Validation errors will happen. Catch them and retry with the error, with a hard cap on attempts.
- Assuming valid means correct. The shape is guaranteed; the content is not. Check values that matter.
Summary#
Structured outputs turn model responses from text you hope to parse into data you can trust. Define the shape once with Pydantic, enforce it with responses.parse or go cross-provider with instructor, encode your rules with enums and nested models, and keep a retry path for the cases that slip through. That is the difference between a demo that parses today and a feature that holds up in production.
- Building agents? The OpenAI Agents SDK tutorial uses the same idea via its
output_type. - Foundations? Read the Pydantic BaseModel primer.
- Shipping to prod? Add evals so a schema or value regression fails a test.
Wiring this into your own app? Tell me the shape you’re trying to extract — a ticket, an invoice, a resume — and I’ll help you design the schema so the model can’t wander.

