Structured Outputs: Guaranteed JSON
- Define a JSON schema for response_format
- Receive and validate structured output in code
- Design schemas for extraction, classification, and report use cases
Why Informal Instructions Fail at Scale
Adding "respond in JSON format" or "return a JSON object with fields name, email, and company" to a prompt works most of the time. The problem is most of the time.
Without schema enforcement, a model will occasionally wrap its output in markdown code fences, add an explanation before the JSON, omit a field it considers optional, or change a field name because the training data was inconsistent. Each of those variations breaks a downstream JSON parser that expects a fixed shape.
At 100 calls per day, a 1% failure rate is one broken response. At 1,000 calls per day, it is 10. At 10,000, it is 100. The failure rate isn't fixed — it drifts upward as prompts change, edge-case inputs appear, and model behavior shifts between versions.
JSON schema enforcement eliminates this class of failure. When you provide a schema, the model's token sampling is constrained at the decoding layer — the model is structurally incapable of producing output that violates the schema. You don't get a best-effort JSON object; you get a guaranteed one.
Defining text.format in the Responses API
The Chat Completions API used a response_format parameter to request structured output. The Responses API — the correct interface for GPT-6 Astra — moves this to text.format. The schema itself is the same; only where it sits in the request changes.
Here is a complete example. The task is extracting a contact record from unstructured text:
from openai import OpenAI
import json
client = OpenAI()
contact_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"company": {"type": "string"}
},
"required": ["name", "email", "company"],
"additionalProperties": False
}
response = client.responses.create(
model="gpt-6-astra",
text={
"format": {
"type": "json_schema",
"name": "contact_extraction",
"strict": True,
"schema": contact_schema
}
},
input="Hi, I'm Alex Kim from Acme Corp. Reach me at alex@acme.com."
)
contact = json.loads(response.output_text)
print(contact["name"]) # Alex Kim
print(contact["email"]) # alex@acme.com
Two constraints apply to every schema passed with strict: True: every property defined on an object must appear in required, and every object must set additionalProperties: False. If a field might genuinely be absent in the source text, represent it as a union with null — {"anyOf": [{"type": "string"}, {"type": "null"}]} — and still list it in required. Strict mode doesn't allow you to omit a field from the schema; it requires you to be explicit about every possible value, including the absence of one.
Schema Design Patterns
Three schema shapes cover most production use cases.
Extraction schema (flat). Use when you want the model to pull specific fields from unstructured text. Keep it flat — one level of properties with all fields required. Adding nesting to an extraction schema rarely improves accuracy and makes client-side handling more complex.
job_schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"company": {"type": "string"},
"location": {"type": "string"},
"remote": {"type": "boolean"},
"salary_min": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
"salary_max": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
},
"required": ["title", "company", "location", "remote", "salary_min", "salary_max"],
"additionalProperties": False
}
Classification schema (enum + confidence). Use when the model must assign a category from a fixed list. The enum constraint is the key feature: the model cannot return a value outside the list you define, which makes downstream routing reliable. Add a confidence score to let the application handle low-confidence cases differently.
classification_schema = {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "general"]
},
"confidence": {"type": "number"},
"reasoning": {"type": "string"}
},
"required": ["category", "confidence", "reasoning"],
"additionalProperties": False
}
One caution on enum design: the model will pick the closest matching value from your list — it won't error if none fit well. If your real data requires a category you haven't listed, the model will choose the nearest match rather than surfacing the gap. Test your enum values against representative inputs before deploying.
Report schema (nested with $defs). Use when the output has multiple structured sections. $defs lets you define a sub-schema once and reference it in multiple places, which keeps the schema readable and avoids duplication. Schemas support up to five levels of nesting; flatten beyond that.
report_schema = {
"$defs": {
"Section": {
"type": "object",
"properties": {
"heading": {"type": "string"},
"content": {"type": "string"},
"key_points": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["heading", "content", "key_points"],
"additionalProperties": False
}
},
"type": "object",
"properties": {
"title": {"type": "string"},
"summary": {"type": "string"},
"sections": {
"type": "array",
"items": {"$ref": "#/$defs/Section"}
}
},
"required": ["title", "summary", "sections"],
"additionalProperties": False
}
Client-Side Validation
Strict mode guarantees that the response matches the schema's type constraints and enum values. It does not guarantee semantic correctness — a model can return a syntactically valid email field containing "not-an-email" if the input is ambiguous. Client-side validation catches these gaps and handles failures explicitly rather than letting corrupt data flow downstream.
The jsonschema library is the standard tool for this in Python:
import json
import jsonschema
from openai import OpenAI
client = OpenAI()
def extract_with_validation(text, schema, retries=1):
for attempt in range(retries + 1):
effort = "high" if attempt > 0 else "medium"
response = client.responses.create(
model="gpt-6-astra",
text={"format": {
"type": "json_schema", "name": "extraction",
"strict": True, "schema": schema
}},
reasoning={"effort": effort},
input=text
)
data = json.loads(response.output_text)
try:
jsonschema.validate(data, schema)
return data
except jsonschema.ValidationError as e:
if attempt == retries:
raise
print(f"Validation failed on attempt {attempt + 1}: {e.message}")
return None
The retry logic bumps reasoning effort on a validation failure. A first-pass failure at medium effort is often recoverable at high — the model's additional iterations catch edge cases missed on the first pass. Log validation failures to a monitoring pipeline: a rising failure rate on a specific schema signals that the schema needs updating or the input distribution has drifted.
Structured Outputs vs Function Calling
Structured outputs and function calling both involve the model and a JSON schema, which causes confusion about when to use each. The distinction is direct: structured outputs are for when you want the model to return data; function calling is for when you want the model to trigger an action.
With structured outputs, the model always returns a response — it has no choice. You pass a schema describing what the response should look like, and the model fills it in. The model doesn't decide whether to respond; it only decides what values to put in the fields.
With function calling, the model decides. Given a set of available tools, the model chooses which ones to call, when to call them, and what arguments to pass. That decision-making capacity is what makes function calling appropriate for agentic tasks — and what makes it wrong when there is no decision to be made.
A practical rule: if the sentence describing your task ends with "from the text" or "from the document," it is probably structured outputs. If it ends with "in the system" or "for the user," it is probably function calling. The overlap case — when the model must both extract data and act on it — is where you combine them: structured outputs to format the extracted data, function calling to do something with it.
- response_format with a JSON schema guarantees structure — informal instructions drift at scale
- Define required fields and use enum constraints to make schemas self-validating
- Use structured outputs for extraction; use function calling when the model needs to trigger an action