Building with the API

OpenAI Structured Outputs: JSON Schema

4 min read

Structured Outputs make the model’s reply conform to a JSON schema you supply. It is enforcement, not prompting — so you can drop the retry-on-bad-JSON wrapper.

Raw JSON schema

curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-6-astra",
    "input": "Extract: John Smith (john@example.com) wants the Enterprise plan.",
    "text": {
      "format": {
        "type": "json_schema",
        "name": "contact",
        "strict": true,
        "schema": {
          "type": "object",
          "properties": {
            "name": {"type": "string"},
            "email": {"type": "string"},
            "plan": {"type": "string"}
          },
          "required": ["name", "email", "plan"],
          "additionalProperties": false
        }
      }
    }
  }'

The format object needs all four fields: type, a name for the schema, the schema itself, and strict: true. Under strict, every property must be listed in required and additionalProperties must be false.

Python with Pydantic

from pydantic import BaseModel
from openai import OpenAI

class Contact(BaseModel):
    name: str
    email: str
    plan: str

client = OpenAI()  # reads OPENAI_API_KEY

response = client.responses.parse(
    model="gpt-6-astra",
    input="Extract: Jane Doe (jane@co.com) wants Enterprise.",
    text_format=Contact,
)

contact = response.output_parsed
print(contact.email)  # jane@co.com

responses.parse() builds the schema from the Pydantic model, sends it, and returns a validated Contact on output_parsed. No json.loads, no schema written twice.

JavaScript with Zod

import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";

const Contact = z.object({
  name: z.string(),
  email: z.string(),
  plan: z.string(),
});

const client = new OpenAI(); // reads OPENAI_API_KEY

const response = await client.responses.parse({
  model: "gpt-6-astra",
  input: "Extract: Jane Doe (jane@co.com) wants Enterprise.",
  text: { format: zodTextFormat(Contact, "contact") },
});

console.log(response.output_parsed.email);

zodTextFormat(schema, name) converts the Zod object into the wire format and gives output_parsed its type.

The same schemas as function tools

tools = [{
    "type": "function",
    "name": "get_weather",
    "description": "Retrieves current weather for the given location.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {"location": {"type": "string"}},
        "required": ["location"],
        "additionalProperties": False,
    },
}]

response = client.responses.create(model="gpt-6-astra", input="Weather in Paris?", tools=tools)

for item in response.output:
    if item.type == "function_call":
        print(item.name, item.arguments)  # get_weather {"location":"Paris, France"}

Function tools sit flat on the tool object (type, name, description, parameters, strict) — there is no nested function wrapper in the Responses API. arguments arrives as a JSON string, so parse it, then send the result back as {"type": "function_call_output", "call_id": item.call_id, "output": "..."} in the next input.

Refusals

for item in response.output:
    if item.type == "refusal":
        print("Model declined:", item.refusal)

A safety refusal is a distinct output item, not malformed JSON — check for it before assuming your parse failed.


Next: your first OpenAI request · reliable JSON from Claude.

Open the full interactive version (with copy buttons) ↗

← All guides