Getting reliable JSON from language models used to mean begging the prompt to behave. The openai python sdk structured outputs pydantic integration changes that: you declare a schema, and the model is constrained to emit matching JSON.
Prerequisites
- Python 3.10 or newer
openai>=1.50.0(providesclient.beta.chat.completions.parse)pydantic>=2.7.0- An API key from a provider that supports structured outputs (OpenAI, or any OpenAI-compatible gateway)
Install the dependencies:
pip install "openai>=1.50.0" "pydantic>=2.7.0"
Export your key:
export OPENAI_API_KEY="sk-..."
Define your first structured model
Pydantic does the heavy lifting. Define a BaseModel that describes the exact shape you want. The SDK converts it to a JSON schema and sends it as response_format.
from pydantic import BaseModel, Field
class Ticket(BaseModel):
title: str = Field(description="Short summary of the issue")
severity: str = Field(description="low, medium, or high")
tags: list[str] = Field(default_factory=list)
That’s the openai python sdk structured outputs pydantic pattern in its simplest form: a plain class, typed fields, optional docstrings via Field.
Call the model and parse
Instantiate the client and call beta.chat.completions.parse. Pass the model class as response_format.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "You classify support tickets."},
{"role": "user", "content": "The login button is broken for Safari users."},
],
response_format=Ticket,
)
ticket = response.choices[0].message.parsed
print(ticket)
Expected output
Running the snippet prints a validated Ticket instance:
title='Broken login button on Safari' severity='high' tags=['login', 'safari', 'bug']
The parsed attribute is already a Ticket, not a dict. If the model tries to emit something off-schema, the API rejects it before you get the response.
Why structured outputs beat prompt engineering
Before structured outputs, you passed response_format={"type": "json_object"}. That only guaranteed syntactically valid JSON, not that keys existed or types matched. You still wrote validation and retry loops for KeyError. Structured outputs move the constraint into the generation step. For providers that use constrained decoding, invalid branches are never sampled. For others, the service post-validates and reprompts. Either way, your Python code receives a schema-correct object or a clean error.
Handling refusals and validation
Structured outputs constrain shape, not content. The model can still refuse. The message object exposes .refusal.
msg = response.choices[0].message
if msg.refusal:
print("Model refused:", msg.refusal)
else:
ticket = msg.parsed
If the API returns malformed JSON (rare, but possible on flaky gateways), msg.parsed is None and msg.content holds the raw string. Wrap parsing in a try/except if you bypass .parsed.
Validation errors
Pydantic validation runs on the SDK side after the API guarantees schema adherence. Mismatches usually indicate a bug in your model class (e.g., unsupported type). Keep types JSON-native: str, int, float, bool, list, dict, and nested BaseModel.
Nested structures and enums
Real tickets have authors and priorities. Use nested models and Enum for closed sets.
from enum import Enum
from pydantic import BaseModel, Field
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class Author(BaseModel):
name: str
email: str | None = None
class Ticket(BaseModel):
title: str
priority: Priority
author: Author
tags: list[str] = Field(default_factory=list)
Call it the same way:
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract ticket data."},
{"role": "user", "content": "Jane (jane@acme.io) says: payment fails with 500 error."},
],
response_format=Ticket,
)
t = response.choices[0].message.parsed
print(t.priority, t.author.name, t.tags)
Expected output:
Priority.HIGH Jane ['payment', 'error']
The enum forces the model to pick one of three strings. If it emits "critical", the API errors instead of silently coercing.
Using an OpenAI-compatible gateway
You are not locked to a single vendor. The openai python sdk structured outputs pydantic flow works against any endpoint that speaks the OpenAI chat protocol. Set base_url and api_key accordingly.
client = OpenAI(
base_url="https://api.n4n.ai/v1", # OpenAI-compatible, 240+ models, auto fallback
api_key="your-gateway-key",
)
This routes to hundreds of models and falls back automatically when a provider is rate-limited, while still forwarding your response_format schema. Per-token metering and cache-control hints pass through unchanged.
Practical patterns
Retry on transient errors
Network hiccups happen. Wrap the call:
import time
from openai import APIError
def parse_with_retry(client, **kwargs):
for attempt in range(3):
try:
return client.beta.chat.completions.parse(**kwargs)
except APIError as e:
if attempt == 2:
raise
time.sleep(2 ** attempt)
Forcing a specific provider
Some gateways accept a header or model prefix. With n4n.ai you can pass a routing directive via extra_headers:
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=messages,
response_format=Ticket,
extra_headers={"x-n4n-route": "openai"},
)
That honors client routing without changing your code structure.
Streaming caveat
beta.chat.completions.parse is non-streaming. If you need tokens as they arrive, use stream=True with response_format={"type": "json_schema", ...} and parse the concatenated string yourself. For most extraction tasks, waiting for the full object is fine and simpler.
Testing your models offline
You can instantiate Pydantic models directly in unit tests without calling the API.
def test_ticket_model():
t = Ticket(
title="x",
priority=Priority.LOW,
author=Author(name="bob"),
)
assert t.priority == Priority.LOW
Inspect the generated schema with Ticket.model_json_schema(). This is exactly what the SDK sends, so you can confirm field descriptions and required keys before spending tokens.
Migrating from dict-based code
Old pattern:
import json
data = json.loads(response.choices[0].message.content)
title = data["title"]
severity = data.get("severity", "low")
New pattern:
ticket = response.choices[0].message.parsed
title = ticket.title
severity = ticket.severity # typed, defaults applied
The migration is mechanical: replace dict access with attribute access and let Pydantic handle defaults.
Limitations
- Not every model supports structured outputs; check provider docs.
- Schema complexity limits apply. Keep schemas under a few hundred properties and avoid deep recursion.
Uniontypes and arbitrarydictkeys with dynamic names are not supported in strict mode. Use enums or fixed nested models.parseis synchronous. For async, useawait client.beta.chat.completions.parse(...)inside anasyncfunction; the method is awaitable.
Wrapping up
The openai python sdk structured outputs pydantic path removes the JSON-whispering from your codebase. Define models, call parse, and handle refusals. For production, point the client at a gateway that gives you fallback and metering, and keep your Pydantic models strict.