n4nAI

How to use OpenAI's structured outputs with Pydantic

Step-by-step guide to using OpenAI structured outputs with Pydantic in Python: define models, call the API, validate responses, and verify success.

n4n Team3 min read571 words

Audio narration

Coming soon — every post will get a voice note here.

OpenAI structured outputs with Pydantic replaces ad-hoc JSON parsing with compiler-checked response shapes. This guide gives you the exact Python setup, API call, and verification loop to get typed objects back from the model without post-processing hacks.

Step 1: Install and import dependencies

Use OpenAI’s Python SDK 1.40+ and Pydantic v2. Earlier Pydantic versions lack the schema introspection the API relies on.

pip install "openai>=1.40" "pydantic>=2.0"

Import what you need:

from openai import OpenAI
from pydantic import BaseModel, Field

If you already have a project, pin these versions in requirements.txt. The structured outputs beta endpoint is stable enough for production, but the method lives under client.beta until OpenAI promotes it.

Step 2: Define your Pydantic models

The model class is the contract. OpenAI structured outputs with Pydantic strictly enforces that every field in the schema appears in the response. Make fields required unless you explicitly allow None.

class Address(BaseModel):
    street: str = Field(description="Street name and number")
    city: str
    zip_code: str = Field(alias="zipCode")

class Person(BaseModel):
    name: str
    age: int = Field(ge=0, le=120)
    address: Address
    interests: list[str] = Field(default_factory=list)

Notes from shipping this:

  • description on fields improves model accuracy. The schema forwards these as prompts.
  • Aliases work, but the model sees zipCode in the JSON, not zip_code. Set populate_by_name=True in model_config if you parse inbound data elsewhere.
  • default_factory is fine; the API still emits the key. If you omit a default, the field must be present.

Avoid deeply recursive models. The structured outputs schema has a depth limit (currently 5 levels). Flatten if you hit it.

Step 3: Call the Structured Outputs beta endpoint

Instantiate the client and call beta.chat.completions.parse. Pass the Pydantic class—not an instance—as response_format.

client = OpenAI()  # or OpenAI(base_url="https://api.openai.com/v1", api_key="sk-...")

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-01",
    messages=[
        {"role": "system", "content": "Extract structured person data from the user."},
        {"role": "user", "content": "Jane Doe, 29, lives at 12 Oak St, Springfield, 12345. Likes climbing and jazz."}
    ],
    response_format=Person,
)

msg = completion.choices[0].message
if msg.refusal:
    raise RuntimeError(f"Model refused: {msg.refusal}")

person = msg.parsed
print(person.name, person.age, person.address.city)

msg.parsed is already a Person instance. If the model violates the schema (rare, but possible on provider errors), parsed is None and msg.content holds the raw string for debugging.

Step 4: Handle validation and parsing errors

OpenAI structured outputs with Pydantic guarantees schema conformance, but network and SDK layers can still fail. Wrap calls in explicit error handling.

from openai import APIError, RateLimitError

try:
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-01",
        messages=messages,
        response_format=Person,
    )
    person = completion.choices[0].message.parsed
    if person is None:
        raise ValueError(completion.choices[0].message.content)
except RateLimitError as e:
    print("Rate limited, back off:", e.response.headers.get("retry-after"))
except APIError as e:
    print("API error:", e.message)

If you use an OpenAI-compatible gateway that forwards response_format, the same exception types apply. One OpenAI-compatible endpoint such as n4n.ai addresses 240+ models and will pass your Pydantic-derived JSON schema through to providers that support strict mode, while handling provider degradation automatically.

Step 5: Process multiple records in one shot

Batch extraction by defining a container model. This avoids N calls for N items.

class People(BaseModel):
    people: list[Person]

text_block = """
Bob, 40, 5 Pine Rd, Rivertown, 67890. Reads.
Alice, 33, 9 Elm Ave, Laketown, 11223. Runs, paints.
"""

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-01",
    messages=[{"role": "user", "content": f"Extract all people:\n{text_block}"}],
    response_format=People,
)

people = completion.choices[0].message.parsed.people
assert len(people) == 2

Keep total output tokens under the model’s limit. For 100+ records, chunk the input.

Step 6: Use async and streaming where it matters

For high-throughput services, use the async client. Structured outputs does not stream partial objects—you get the full parse on completion—but async lets you concurrency-limit many requests.

import asyncio
from openai import AsyncOpenAI

async_client = AsyncOpenAI()

async def extract(text: str) -> Person:
    comp = await async_client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-01",
        messages=[{"role": "user", "content": text}],
        response_format=Person,
    )
    return comp.choices[0].message.parsed

async def main():
    tasks = [extract(f"Person {i}: name Lee, age {i}, ...") for i in range(5)]
    return await asyncio.gather(*tasks)

persons = asyncio.run(main())

If you need token streaming for UX, use stream=True with response_format as a JSON schema dict instead of Pydantic; the parse endpoint does not support token streams of the typed object. Convert the accumulated string with Person.model_validate_json().

Step 7: Verify success with a test harness

A minimal pytest confirms the wiring. This catches regressions when you change models or schemas.

# test_extract.py
from openai import OpenAI
from pydantic import BaseModel

class Tiny(BaseModel):
    ok: bool

def test_structured_output():
    client = OpenAI()
    comp = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-01",
        messages=[{"role": "user", "content": "Return ok true"}],
        response_format=Tiny,
    )
    parsed = comp.choices[0].message.parsed
    assert isinstance(parsed, Tiny)
    assert parsed.ok is True

Run:

pytest test_extract.py -q

You should see a green check. If the test fails with a schema error, print comp.choices[0].message.content to inspect what the model actually returned.

Practical caveats

  • Strict mode rejects additionalProperties. Don’t expect the model to sneak extra fields; it won’t.
  • Enums are supported. Use Literal["red","green","blue"] for constrained strings.
  • If you switch models to one that lacks structured output support, the API returns an error, not silent garbage. Handle it.
  • Cache prompts with cache_control where your provider allows; the gateway or OpenAI will honor hints and cut latency.

OpenAI structured outputs with Pydantic is the cleanest way to get typed data from LLMs today. Define the model, call beta.chat.completions.parse, and let the SDK hand you a validated object.

Tagsstructured-outputsopenaipydanticpython

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured outputs & json mode posts →