Getting JSON out of a language model used to mean prompting for braces and then writing a parser to recover from missing fields. This openai structured outputs code example walks through the native structured outputs feature of the OpenAI API, which enforces a JSON schema at the API boundary so the response deserializes cleanly every time.
Prerequisites
- Python 3.10 or newer
openaiPython package >= 1.40.0 (pip install openai)pydantic>= 2.0 (pip install pydantic) for local validation- An OpenAI API key exported as
OPENAI_API_KEY - A model that supports structured outputs (e.g.
gpt-4o-2024-08-06orgpt-4o-mini-2024-07-18)
Version matters. The json_schema response format type and strict mode shipped in the mid-2024 API refresh. Older SDKs silently ignore response_format or reject the schema. If you are behind a corporate proxy or using a gateway, the request shape is identical as long as the endpoint is OpenAI-compatible.
Define the target schema
Write the schema first, not the prompt. Suppose you ingest receipts and need to pull out vendor, date, line items, and totals.
{
"type": "object",
"properties": {
"vendor": { "type": "string" },
"date": { "type": "string", "format": "date" },
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": { "type": "string" },
"quantity": { "type": "integer" },
"unit_price": { "type": "number" }
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": false
}
},
"total": { "type": "number" }
},
"required": ["vendor", "date", "line_items", "total"],
"additionalProperties": false
}
Strict mode requires additionalProperties: false on every object and all keys listed in required. That is not optional; the API rejects the schema otherwise. The format: date hint is documentation, not enforcement—validate it locally later.
First call: basic structured output
Set up the client and send a receipt as a user message.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
schema = {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"date": {"type": "string", "format": "date"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False
}
},
"total": {"type": "number"}
},
"required": ["vendor", "date", "line_items", "total"],
"additionalProperties": False
}
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract the receipt data."},
{"role": "user", "content": "Coffee Bean, 2024-05-12. 2x Espresso at 3.50, 1x Muffin at 2.00. Total 9.00"}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "receipt",
"strict": True,
"schema": schema
}
}
)
print(resp.choices[0].message.content)
Expected output (formatted for readability):
{
"vendor": "Coffee Bean",
"date": "2024-05-12",
"line_items": [
{"description": "Espresso", "quantity": 2, "unit_price": 3.50},
{"description": "Muffin", "quantity": 1, "unit_price": 2.00}
],
"total": 9.00
}
The model cannot return a bare string or a dict missing total. If it tries, the API returns an error instead of malformed JSON. The openai structured outputs code example above is the minimal viable call.
Validate locally with Pydantic
The API guarantees shape, but not semantic correctness (e.g. total could be arithmetically wrong). Validate with a typed model and tighten the date.
from pydantic import BaseModel, field_validator
from typing import List
from datetime import date
class LineItem(BaseModel):
quantity: int
unit_price: float
class Receipt(BaseModel):
vendor: str
date: str
line_items: List[LineItem]
total: float
@field_validator("date")
def iso_date(cls, v):
return date.fromisoformat(v).isoformat()
import json
raw = resp.choices[0].message.content
parsed = Receipt.model_validate(json.loads(raw))
print(parsed.vendor, parsed.total)
Output:
Coffee Bean 9.0
If the model returns 05/12/2024, the validator raises ValueError. Catch it and route to a correction prompt or dead-letter queue.
Handling provider degradation
Structured outputs are only useful if the request succeeds. When you call OpenAI directly, a 429 or 500 leaves you to retry. If you put an OpenAI-compatible endpoint such as n4n.ai in front of the model, the same response_format payload works unchanged and the gateway can apply automatic fallback when a provider is rate-limited or degraded, while still metering per token. That is the only change needed: point base_url at the gateway.
client = OpenAI(
api_key=os.environ["GATEWAY_KEY"],
base_url="https://api.n4n.ai/v1"
)
# rest of the call is identical
Common schema mistakes
- Forgetting
additionalProperties: falseon nested objects. Strict mode rejects the whole schema. - Using
nullableoranyOfin ways the subset does not allow. Supported keywords are limited; avoidpatternif you can. - Defining deeply recursive types. The API caps depth and breadth; keep schemas flat.
- Setting
strict: truebut omitting a field fromrequired. The two are coupled. - Assuming
formatdoes type coercion. It does not.
Streaming structured output
As of the gpt-4o series, structured outputs work with streaming. You get delta chunks that concatenate to valid JSON. Do not parse each chunk; accumulate then validate.
stream = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract receipt data."},
{"role": "user", "content": "Coffee Bean, 2024-05-12. 2x Espresso at 3.50, 1x Muffin at 2.00. Total 9.00"}
],
response_format={
"type": "json_schema",
"json_schema": {"name": "receipt", "strict": True, "schema": schema}
},
stream=True
)
parts = []
for chunk in stream:
if chunk.choices[0].delta.content:
parts.append(chunk.choices[0].delta.content)
full = "".join(parts)
Receipt.model_validate(json.loads(full))
Streaming does not change the schema contract. The final string is still strictly validated by the API before the stream closes.
Error handling
Wrap the call so transient failures do not crash the pipeline.
from openai import APIError
try:
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": "Coffee Bean 2024-05-12 total 9"}],
response_format={
"type": "json_schema",
"json_schema": {"name": "receipt", "strict": True, "schema": schema}
}
)
except APIError as e:
print(f"status {e.status_code}: {e.message}")
# retry or fallback
A 400 with invalid_schema means your JSON Schema violates the strict subset. A 429 means rate limit; back off.
Full runnable script
import os, json
from openai import OpenAI
from pydantic import BaseModel, field_validator
from typing import List
from datetime import date
class LineItem(BaseModel):
quantity: int
unit_price: float
class Receipt(BaseModel):
vendor: str
date: str
line_items: List[LineItem]
total: float
@field_validator("date")
def iso_date(cls, v):
return date.fromisoformat(v).isoformat()
schema = {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"date": {"type": "string", "format": "date"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False
}
},
"total": {"type": "number"}
},
"required": ["vendor", "date", "line_items", "total"],
"additionalProperties": False
}
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract receipt data."},
{"role": "user", "content": "Coffee Bean, 2024-05-12. 2x Espresso at 3.50, 1x Muffin at 2.00. Total 9.00"}
],
response_format={"type": "json_schema", "json_schema": {"name": "receipt", "strict": True, "schema": schema}}
)
receipt = Receipt.model_validate(json.loads(resp.choices[0].message.content))
print(f"{receipt.vendor} charged {receipt.total}")
Run it. You should see Coffee Bean charged 9.0.
When not to use structured outputs
If your schema changes per request based on arbitrary user input, generating JSON Schema dynamically is fine but keep it small. For free-form summarization, JSON mode adds latency with no benefit. Use structured outputs when the downstream code is rigid and a missing key breaks the pipeline. The openai structured outputs code example shown here is production-shaped: schema first, strict mode on, local validation second, gateway fallback if you need resilience.