Guarantee Structured JSON from Any LLM Call with OpenAI Structured Outputs and Pydantic OpenAI's structured outputs API, combined with Pydantic, allows developers to receive fully-typed JSON objects from LLM calls, eliminating fragile parsing and schema drift. The tutorial demonstrates extracting structured meeting data using Python, Pydantic models, and the `parse()` method on supported models like GPT-4o. 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. Rachel Goldstein https://sourcefeed.dev/u/rachel goldstein 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 like list str require it openai = 1.40.0 when the parse 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 any gpt-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. python 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. python 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 same response 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 https://sourcefeed.dev/u/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.