# Guarantee Structured JSON from Any LLM Call with OpenAI Structured Outputs and Pydantic

> Source: <https://sourcefeed.dev/a/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and-p>
> Published: 2026-07-09 07:35:54+00:00

# 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.
