Kvmzen Blog
← Back to Tech in practice

OpenAI Structured Outputs Guide: How to Get GPT to Return Schema-Valid JSON

Tech in practice ·~12 min read

Writing a JSON Schema and structured data pipeline in a code editor

Once model output hits a database, a form, or a downstream Agent, “please return JSON” in the prompt is not a contract. OpenAI Structured Outputs uses constrained decoding to pin the reply to your JSON Schema: keys stay present, enums stay in the set, types do not drift between string and number. This guide is for engineers doing extraction, ticket classification, eval scoring, and multi-step workflows — the path from JSON Mode to strict: true.

100%
Schema match rate on supported models
2
Entry points: Chat Completions / Responses
10
Max object nesting depth

Why JSON Mode is not enough

JSON Mode (type: "json_object") only guarantees that a JSON parser can read the payload. It does not lock key names, required fields, enum sets, or numeric types. The three production failures we see most: missing severity, 42 written as "42", and an invented urgent-plus outside the enum. Strong typing downstream then fails on random samples.

Capability JSON Mode Structured Outputs
Valid JSON Yes Yes
Honors your JSON Schema No (prompt only) Yes (constrained decoding)
How you enable it json_object json_schema + strict: true
Typical models Earlier GPT-4o / GPT-3.5 and similar gpt-4o-2024-08-06, gpt-4o-mini, and later snapshots
Refusal handling May still emit a JSON-shaped refusal Separate refusal field

OpenAI treats Structured Outputs as the successor to JSON Mode: new work should start from a Schema, not from three retries after parse failure. If you are also comparing token bills across models, see Kimi K3 vs GPT-5.5 API cost — structured output barely grows payload size; retries and long reasoning are what move the invoice.

Two entry points, one Schema contract
Chat Completions puts the Schema on response_format; the Responses API puts it on text.format. The field path differs; strict, additionalProperties: false, and “everything required” do not.

Minimal working request

This ticket-extraction Schema covers most production needs: string, enum, integer, and a null union for optional fields. The model must return every key; if there is no account ID, emit null instead of dropping the key.

Chat Completions · response_format
{
  "model": "gpt-4o-2024-08-06",
  "messages": [
    {"role": "system", "content": "Extract the user description into a support ticket object."},
    {"role": "user", "content": "Checkout page returns 500 when using a saved card. Started today. Account acct_8842."}
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "support_ticket",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "summary": { "type": "string" },
          "category": {
            "type": "string",
            "enum": ["billing", "bug", "account", "other"]
          },
          "severity": { "type": "integer" },
          "account_id": { "type": ["string", "null"] }
        },
        "required": ["summary", "category", "severity", "account_id"],
        "additionalProperties": false
      }
    }
  }
}

On the Responses API, place the same Schema under text.format, with type, name, schema, and strict as siblings. The first request for a Schema compiles constraints and may be a bit slower; that Schema is then cached and later calls return to normal latency.

Laptop with code and a structured API debugging setup
Treat the Schema as an API contract: run unit tests against the same definition locally, then hand it to the model

Rules a strict Schema must satisfy

With strict: true, you are no longer sending “loose JSON Schema.” You are sending the supported subset. Violations return 400 — not “best effort.”

  • Root must be object: the root cannot be anyOf or an array. If Zod’s discriminatedUnion emits a top-level anyOf, wrap it, e.g. { "result": ... }.
  • Every object needs additionalProperties: false: nested objects included. Miss one and the request is rejected.
  • Every key in properties must appear in required: optional meaning uses "type": ["string", "null"] or anyOf plus null.
  • Supported types: string, number, integer, boolean, object, array, enum, anyOf.
  • String constraints: pattern, format (email, date-time, uuid, ipv4, and similar).
  • Numbers and arrays: minimum / maximum / multipleOf; minItems / maxItems.
  • Explicitly unsupported: allOf, not, if/then/else, dependentRequired, and other combinators.
  • Size limits: about 5000 object properties total, 10 nesting levels; combined length of property names, definition names, and enum strings ≤ 120,000 characters; at most 1000 enum values.
Fine-tuned models are stricter
On fine-tuned models, pattern, format, minLength, minimum, minItems, and similar constraints may still be unsupported. Validate the Schema on a base snapshot first, then decide whether to fine-tune.

Tool calls vs message body

The same strict rules apply to function / tool parameters. The intent differs: a tool Schema describes “what the model should call”; response_format / text.format describe “what shape the model should reply with.” Use the latter for extraction, scoring, and UI state; use the former for weather, file writes, and commands. If you are comparing terminal agents with GUI control, see how Claude Code relates to OpenAI Computer Use — that is the “how to act” layer; this article is the data contract before and after the action.

Parse straight into objects with the SDK

Hand-written JSON Schema often drops required. Official SDKs ship Pydantic / Zod helpers: generate the Schema from types, and get a parsed object on the response. Typical Python:

Python · Pydantic
from typing import Literal, Optional
from pydantic import BaseModel
from openai import OpenAI

class SupportTicket(BaseModel):
    summary: str
    category: Literal["billing", "bug", "account", "other"]
    severity: int
    account_id: Optional[str]

client = OpenAI()
completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "Extract a support ticket."},
        {"role": "user", "content": "Checkout page 500, account acct_8842."},
    ],
    response_format=SupportTicket,
)
ticket = completion.choices[0].message.parsed
if completion.choices[0].message.refusal:
    raise RuntimeError(completion.choices[0].message.refusal)
print(ticket.category, ticket.severity)
One Schema as the source of truth
The same Pydantic / Zod model should drive the API request, unit tests, and downstream validation. Do not recopy the field list into the prompt — docs rot before the code does.

Errors, refusals, and a ship checklist

Run this list before production. It kills most “curl works locally, pipeline returns 400” incidents.

  • 400 Unsupported schema: missing additionalProperties: false, a property not in required, root anyOf, or use of allOf.
  • Model refusal: when safety policy fires, content is not stuffed into the Schema. Read refusal, show it in the UI, and do not retry as a JSON parse failure.
  • Optional fields become empty strings: if the contract says null, accept null. Map to SQL NULL before insert; do not guess again.
  • Enums too wide: keep business states to 5–8 values. Hundreds of enums burn context and approach the 1000-value cap.
  • Key order: output keys follow Schema declaration order, so streaming clients can parse field by field.
  • Prompts still matter: Schema owns shape; the prompt owns meaning. “severity 1–5, 5 is a site-wide outage” belongs in system; lock the integer range again with minimum / maximum.

Ship three habits: Schema in Git; separate metrics for refusal, 400, and timeout; a regression set of real tickets, not “Hello, return JSON.” Once parsing is stable, you can spend time on classification accuracy and latency instead of weekly regex patches.

Extraction pipelines are easier on a Mac mini

The Structured Outputs loop is short: tweak Schema, run a small sample, inspect the parsed object. Python, Node.js, Docker, and Homebrew are ready on macOS — no WSL first. Apple Silicon unified memory lets you keep the editor, local validators, and long-context evals open without swapping during type checks.

A Mac mini M4 idles around 4W, which is a good fit for overnight regression sets. macOS crash rates are low; Gatekeeper and SIP also cut the chance of a polluted dependency script. Versus a same-price Windows box, unattended Agent evals are kinder on both uptime and power.

If you want a node that stays online for JSON regression and model bake-offs, see Kvmzen plans and move the Schema contract off a laptop onto a fixed-spec cloud Mac.

Limited-time offer

More than a Mac — your development base in the cloud

Dedicated compute · Global nodes · Monthly subscription · No hardware to buy

Back to home
Limited-time offer View plans