Guarantee Structured JSON from Any LLM Call with OpenAI Structured Outputs and Pydantic
Skip the fragile JSON parsing and schema drift. Use OpenAI's structured outputs API with Pydantic to get fully-typed objects back from every model call.
What you'll build
You'll write a Python script that sends unstructured text to an OpenAI model and gets back a fully-typed Pydantic object on every call. No prompt-engineering tricks, no json.loads() in a try/except, no silent schema drift.
Prerequisites
- Python 3.10 or later (the
str | Noneunion syntax and lowercase generics likelist[str]require it) openai >= 1.40.0(when theparse()helper and structured outputs support shipped)pydantic >= 2.0- An OpenAI API key exported as
OPENAI_API_KEY - A supported model:
gpt-4o,gpt-4o-mini, or anygpt-4o-*snapshot dated 2024-08-06 or later
Structured outputs don't work on gpt-4-turbo, gpt-3.5-turbo, or pre-August-2024 model snapshots. Passing those will give you a BadRequestError.
1. Install dependencies
pip install "openai>=1.40.0" "pydantic>=2.0"
Export your API key if you haven't already:
export OPENAI_API_KEY="sk-..."
The OpenAI() client picks it up automatically from the environment.
2. Define your schema
Here's a realistic example: extracting structured data from meeting notes.
from pydantic import BaseModel, Field
class ActionItem(BaseModel):
owner: str = Field(description="Full name of the person responsible")
task: str = Field(description="Specific action to be completed")
due_date: str | None = Field(
default=None,
description="Due date in ISO 8601 format if mentioned, otherwise null",
)
class MeetingExtract(BaseModel):
summary: str = Field(description="One-sentence summary of the meeting")
decisions: list[str] = Field(description="Key decisions made, as a list")
action_items: list[ActionItem]
Two things worth being explicit about. First, Field(description=...) does real work here; the model reads those descriptions when populating fields, so be precise. Second, use str for dates, not datetime. OpenAI's strict schema mode maps Pydantic types to JSON Schema primitives and datetime doesn't have a clean mapping. You'll get a BadRequestError if you try it.
3. Call the API
The key is client.beta.chat.completions.parse(). Pass your Pydantic class as response_format and the SDK generates the JSON Schema, enables strict mode, and deserializes the response into your model.
from openai import OpenAI
from pydantic import BaseModel, Field
client = OpenAI()
class ActionItem(BaseModel):
owner: str = Field(description="Full name of the person responsible")
task: str = Field(description="Specific action to be completed")
due_date: str | None = Field(
default=None,
description="Due date in ISO 8601 format if mentioned, otherwise null",
)
class MeetingExtract(BaseModel):
summary: str = Field(description="One-sentence summary of the meeting")
decisions: list[str] = Field(description="Key decisions made, as a list")
action_items: list[ActionItem]
NOTES = """
Product sync, March 12. Attendees: Priya, Dan, Leila.
Decided to drop IE11 support and push the launch to April 1st.
Dan will update the changelog by March 15. Priya owns browser compat testing,
no deadline set. Leila to schedule a stakeholder demo next week.
"""
completion = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Extract structured data from the meeting notes provided.",
},
{"role": "user", "content": NOTES},
],
response_format=MeetingExtract,
)
4. Read the result
The model can refuse if the request triggers a content policy, and generation can be cut off if the output hits the token limit. Check both before touching .parsed.
message = completion.choices[0].message
if message.refusal:
raise ValueError(f"Model refused: {message.refusal}")
result = message.parsed
if result is None:
raise ValueError(
f"Failed to parse response. Finish reason: {completion.choices[0].finish_reason}"
)
print(result.summary)
for item in result.action_items:
print(f" {item.owner}: {item.task} (due: {item.due_date})")
message.parsed is a live MeetingExtract instance with real types, not a dict. You get autocomplete, attribute access, and Pydantic validators for free.
Expected output, roughly:
The team agreed to drop IE11 support and move the launch to April 1st.
Dan: Update the changelog (due: 2024-03-15)
Priya: Browser compatibility testing (due: None)
Leila: Schedule a stakeholder demo (due: None)
Verify it works
Run a quick sanity check after the call:
assert isinstance(result, MeetingExtract), "Expected MeetingExtract"
assert all(isinstance(i, ActionItem) for i in result.action_items)
assert isinstance(result.decisions, list)
finish_reason = completion.choices[0].finish_reason
assert finish_reason == "stop", f"Unexpected finish_reason: {finish_reason}"
print("All checks passed.")
The finish_reason check matters. If the model is cut off mid-generation (value is "length"), message.parsed will be None even with no refusal. The result is None guard in Step 4 catches this before you ever reach the assertions. Set max_tokens high enough for your schema's complexity if you're hitting it.
Troubleshooting
openai.BadRequestError: Invalid schema
Your model uses a type that doesn't map to JSON Schema. Common causes: datetime, Decimal, UUID, bare Any, or a dict without typed values. Swap to str, float, or a concrete nested Pydantic model.
message.parsed is None with no refusal
Check finish_reason. If it's "length", increase max_tokens. If it's "stop" and parsed is still None, the SDK failed to deserialize the content; inspect message.content directly to debug the raw JSON.
AttributeError: 'ChatCompletionMessage' has no attribute 'parsed'
You called client.chat.completions.create() instead of client.beta.chat.completions.parse(). The standard create() response type doesn't include .parsed.
ValidationError on import or at model definition time
A Pydantic v1/v2 conflict. Run python -c "import pydantic; print(pydantic.VERSION)" and confirm you're on 2.x. Pin with pip install "pydantic>=2.0,<3" if another package dragged in v1.
Next steps
- Raw JSON schema via REST: If you're not in Python, use
response_format={"type": "json_schema", "json_schema": {"name": "MeetingExtract", "strict": true, "schema": {...}}}in any HTTP client. - Streaming:
client.beta.chat.completions.stream()accepts the sameresponse_formatand fires events as the object builds, useful for progressive UI updates. - Function calling vs. structured outputs: use structured outputs when you want typed data returned unconditionally. Use tool calling when the model needs to decide whether to invoke a tool at all.
- Schema size limits: OpenAI enforces a maximum of 5 nesting levels and 100 total properties across the entire schema. Flatten aggressively if you hit this ceiling.
Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.
Discussion 0
No comments yet
Be the first to weigh in.