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 | None
union 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, useresponse_format={"type": "json_schema", "json_schema": {"name": "MeetingExtract", "strict": true, "schema": {...}}}
in any HTTP client.Streaming:client.beta.chat.completions.stream()
accepts the sameresponse_format
and 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 Goldstein· Dev Tools Editor
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.