OpenAI Structured Outputs JSON Schema: Python Guide

Learn how to use OpenAI Structured Outputs with JSON Schema in Python. See strict schema examples, validation tips, error handling, and OurToken-compatible API setup.

O
OurToken Team//14 min
OpenAI Structured Outputs JSON Schema: Python Guide

OpenAI structured outputs json schema is the practical way to make model responses behave like application data instead of loose prose. If your app needs a support ticket, invoice summary, lead profile, moderation decision, or RAG citation object, a prompt that says "return JSON" is not enough. You need a schema the model must follow, code that validates the result, and a rollout path that does not break production when a refusal, truncation, or model change happens.

This guide shows the Responses API path for Structured Outputs, using text.format with type: "json_schema", strict: true, and a JSON Schema that is intentionally small enough to run in production. The examples use the OurToken OpenAI-compatible Responses endpoint with the gpt-5.6-terra model ID, because the live GPT-5.6 Terra API route documents a Responses-compatible API surface. If you are moving an older Chat Completions integration, read the Responses API migration guide first so you know what changes at the request and response boundary.

The goal is not to create the cleverest schema. The goal is to create a contract that your application can trust.

OpenAI Structured Outputs JSON Schema Setup

Structured Outputs are useful when the model's answer is consumed by software rather than only read by a person. A dashboard can render a validated object. A queue worker can route a ticket from a validated category. A billing job can reject an invalid extraction before it touches downstream systems. That is the difference between "the model probably answered in JSON" and "the model produced data that matches the contract we asked for."

OpenAI's official Structured Outputs guide describes two main paths: function calling with structured parameters, and structured model replies with a JSON Schema response format. This article focuses on the second path in the Responses API: user-facing structured replies with text.format. Use function calling when the model needs to call your tools. Use Structured Outputs for the final answer when your app needs a typed response.

Exact values to copy

ItemValueWhy it matters
SDK base URLhttps://api.ourtoken.ai/v1The OpenAI SDK appends the endpoint path
Raw HTTP endpointhttps://api.ourtoken.ai/v1/responsesThe cURL endpoint for Responses API calls
Auth headerAuthorization: Bearer $OURTOKEN_API_KEYOurToken uses Bearer auth for API requests
Example model IDgpt-5.6-terraA Responses-compatible OpenAI model route on OurToken
Structured fieldtext.formatResponses API location for JSON Schema output control
Strict modestrict: trueAsks the model to adhere to the supplied schema
Output capmax_output_tokensPrevents runaway structured responses

Create or manage the key on the OurToken API Keys page, then store it as an environment variable. Do not paste the key into source code, CI logs, or blog comments.

export OURTOKEN_API_KEY="sk-..."

On Windows PowerShell:

$env:OURTOKEN_API_KEY="sk-..."

For the official OpenAI request shape, compare these examples with the OpenAI text generation guide and the OpenAI Responses create reference. The important detail is that Responses API examples use text: { format: ... }; Chat Completions examples use response_format. Mixing those two surfaces is the most common copy-paste bug.

Schema design rules

Start with a schema that is strict, boring, and obvious. A good production schema has these properties:

  1. The root is an object.
  2. Every property your app reads is listed in required.
  3. Every object has additionalProperties: false.
  4. Enums are short and match product logic.
  5. Optional-looking values are represented as empty strings, empty arrays, or explicit enum states unless your chosen model route supports nullable unions.
  6. Descriptions explain business meaning, not prompt instructions.

The official OpenAI guide explicitly calls out additionalProperties: false for objects in Structured Outputs. This is not decoration. It prevents the model from inventing extra keys that your app quietly ignores today and accidentally starts depending on tomorrow.

Here is the schema used in the rest of the article:

{
  "type": "object",
  "properties": {
    "customer_intent": {
      "type": "string",
      "enum": ["billing", "bug", "feature_request", "account", "other"],
      "description": "The main reason the customer contacted support."
    },
    "priority": {
      "type": "string",
      "enum": ["low", "normal", "high", "urgent"],
      "description": "Operational urgency for the support queue."
    },
    "summary": {
      "type": "string",
      "description": "One concise sentence describing the issue."
    },
    "needs_human": {
      "type": "boolean",
      "description": "True when the issue should be escalated to a person."
    },
    "confidence": {
      "type": "number",
      "minimum": 0,
      "maximum": 1,
      "description": "Model confidence in the classification."
    }
  },
  "required": [
    "customer_intent",
    "priority",
    "summary",
    "needs_human",
    "confidence"
  ],
  "additionalProperties": false
}

This example is deliberately small. Deep nesting, very broad enums, and huge schemas increase maintenance risk. If your product needs twenty fields, split the job into two extraction passes or use a narrower schema per workflow. A schema is an API contract, not a wish list.

Build a Strict Schema That Survives Production

The best Structured Outputs implementation is not the one with the most JSON Schema features. It is the one your team can test, version, and monitor. Production failures usually come from one of four places: a schema that is too loose, a schema that is too complex, code that assumes output is always present, or a rollout that changes the schema without versioning.

A workable architecture looks like this:

User or document input
  -> prompt builder with schema version
  -> OurToken Responses API endpoint
  -> OpenAI-compatible model route
  -> Structured Outputs JSON Schema response
  -> refusal / truncation / status checks
  -> local JSON parse and schema validation
  -> typed application object
  -> queue, database, workflow, or UI

That final validation step is still worth keeping. Structured Outputs greatly reduces malformed replies, but your application should still treat every external response as data crossing a boundary. Parse it, validate it, log the schema version, and reject it before it reaches irreversible product logic.

Structured Outputs vs JSON mode

JSON mode and Structured Outputs solve different problems. JSON mode asks for valid JSON. Structured Outputs asks for JSON that follows your schema. That difference is small in a demo and enormous in production.

RequirementJSON modeStructured Outputs with JSON Schema
Produces valid JSONYesYes
Enforces required keysNoYes, when schema and strict mode are used
Prevents extra keysNoYes, with additionalProperties: false
Controls enumsNoYes
Good for quick prototypesYesYes, if schema is small
Good for queues and databasesRisky without retriesBetter default
Typical Responses API fieldtext.format.type = "json_object"text.format.type = "json_schema"

OpenAI recommends using Structured Outputs instead of JSON mode when possible. The main exception is compatibility. If a model or route does not support JSON Schema output yet, JSON mode can be a temporary fallback, but it should not be treated as equivalent. JSON mode can still return { "priority": "critical" } when your product only supports low, normal, high, and urgent.

When to use function calling instead

Use Structured Outputs when the model is producing a final answer for your app. Use function calling when the model needs to choose and call a tool. The official OpenAI function calling guide covers the tool path, and OurToken also has an OpenAI-compatible tool-calling guide for multi-model implementations.

Use caseBetter fitReason
Extract a ticket object from user textStructured OutputsYou need a typed final object
Generate a moderation result for a queueStructured OutputsThe app consumes a fixed schema
Decide whether to call search_docsFunction callingThe model must select a tool
Call get_invoice_status with argumentsFunction callingYour application executes the function
Return final answer plus citationsStructured Outputs or toolsUse tools for retrieval, schema for final typed answer

Many production systems use both. A RAG app might call a retrieval tool first, then return a structured answer containing answer, citations, and confidence. If you are building that kind of workflow, the OpenAI-compatible API for RAG guide is a useful companion because retrieval quality and response structure need to be tested together.

Python and cURL Examples for OurToken

The following examples use OurToken's OpenAI-compatible Responses endpoint. They are written as documentation-verified examples, not as proof that your individual account, quota, and selected model route are enabled for every optional Responses API field. Before shipping, run the smoke test with your own key, chosen model route, and production schema.

Responses API request

Start with cURL because it removes SDK ambiguity. If this request fails, fix the endpoint, model ID, key, or account access before debugging Python.

curl https://api.ourtoken.ai/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OURTOKEN_API_KEY" \
  -d '{
    "model": "gpt-5.6-terra",
    "instructions": "Extract a support ticket. Return data that matches the provided schema.",
    "input": "Customer: I was charged twice after upgrading. I need this fixed before our finance close tomorrow.",
    "max_output_tokens": 600,
    "text": {
      "format": {
        "type": "json_schema",
        "name": "support_ticket",
        "strict": true,
        "schema": {
          "type": "object",
          "properties": {
            "customer_intent": {
              "type": "string",
              "enum": ["billing", "bug", "feature_request", "account", "other"]
            },
            "priority": {
              "type": "string",
              "enum": ["low", "normal", "high", "urgent"]
            },
            "summary": { "type": "string" },
            "needs_human": { "type": "boolean" },
            "confidence": {
              "type": "number",
              "minimum": 0,
              "maximum": 1
            }
          },
          "required": ["customer_intent", "priority", "summary", "needs_human", "confidence"],
          "additionalProperties": false
        }
      }
    }
  }'

A successful response should contain structured output text that parses as JSON. Depending on SDK and route details, usage may be returned with OpenAI-style input_tokens and output_tokens, or compatibility fields such as prompt_tokens and completion_tokens. Normalize usage before cost reporting instead of hard-coding one provider's field names.

Now install the Python dependencies:

pip install openai jsonschema

Then call the same endpoint through the OpenAI Python SDK:

import json
import os
from typing import Any

from jsonschema import Draft202012Validator
from openai import OpenAI

TICKET_SCHEMA: dict[str, Any] = {
    "type": "object",
    "properties": {
        "customer_intent": {
            "type": "string",
            "enum": ["billing", "bug", "feature_request", "account", "other"],
        },
        "priority": {
            "type": "string",
            "enum": ["low", "normal", "high", "urgent"],
        },
        "summary": {"type": "string"},
        "needs_human": {"type": "boolean"},
        "confidence": {"type": "number", "minimum": 0, "maximum": 1},
    },
    "required": [
        "customer_intent",
        "priority",
        "summary",
        "needs_human",
        "confidence",
    ],
    "additionalProperties": False,
}

client = OpenAI(
    api_key=os.environ["OURTOKEN_API_KEY"],
    base_url="https://api.ourtoken.ai/v1",
)

response = client.responses.create(
    model="gpt-5.6-terra",
    instructions="Extract a support ticket. Return data that matches the provided schema.",
    input="Customer: I was charged twice after upgrading. I need this fixed before our finance close tomorrow.",
    max_output_tokens=600,
    text={
        "format": {
            "type": "json_schema",
            "name": "support_ticket",
            "strict": True,
            "schema": TICKET_SCHEMA,
        }
    },
)

payload = json.loads(response.output_text)
Draft202012Validator(TICKET_SCHEMA).validate(payload)
print(payload)

That is the happy path. It is fine for a first smoke test, but production code needs to account for refusals, incomplete output, and response-shape differences.

Handle refusals and incomplete output

Structured Outputs can still produce a refusal when the user request triggers safety behavior. The OpenAI guide documents refusals as programmatically detectable output content. Also, if the response hits max_output_tokens, you may not have a complete JSON object.

Use a small extraction helper that checks output items before parsing:

import json
from typing import Any


def read_field(obj: Any, name: str, default: Any = None) -> Any:
    if isinstance(obj, dict):
        return obj.get(name, default)
    return getattr(obj, name, default)


def extract_structured_text(response: Any) -> str:
    status = read_field(response, "status")
    if status and status not in {"completed", "incomplete"}:
        raise RuntimeError(f"Unexpected response status: {status}")

    incomplete = read_field(response, "incomplete_details")
    if incomplete:
        raise RuntimeError(f"Incomplete response: {incomplete}")

    output_text = read_field(response, "output_text")
    if output_text:
        return output_text

    for output_item in read_field(response, "output", []) or []:
        for content_item in read_field(output_item, "content", []) or []:
            item_type = read_field(content_item, "type")
            if item_type == "refusal":
                raise RuntimeError(f"Model refusal: {read_field(content_item, 'refusal')}")
            if item_type == "output_text":
                return read_field(content_item, "text", "")

    raise RuntimeError("No structured output text found")


def parse_ticket(response: Any, schema: dict[str, Any]) -> dict[str, Any]:
    text = extract_structured_text(response)
    payload = json.loads(text)
    Draft202012Validator(schema).validate(payload)
    return payload

This looks slightly more careful than the demo code because it is meant to survive real traffic. It does not assume the response always has output_text; it checks refusals; it fails closed when the response is incomplete. For workflows that write to a database or trigger support escalations, failing closed is cheaper than accepting a malformed object.

For streaming, follow the same principle. Buffer only the output text deltas you intend to parse, handle refusal deltas separately, and validate the final JSON after the stream closes. Do not validate partial chunks as if they were complete objects.

Validation, Errors, and Cost Control

Structured Outputs are a reliability feature, but they also affect cost. Invalid JSON, missing keys, and bad enum values often create retries. Retries consume tokens, increase latency, and make failure modes hard to debug. A strict schema does not make a model free, but it can reduce waste by turning prompt-formatting uncertainty into an explicit contract.

For GPT-5.6 Terra, the live OurToken model page lists 250K context, 128K max output, $0.50/M input tokens, $3.00/M output tokens, $0.05/M cached input, and $0.625/M cache writes at the time this article was prepared. Always check the live model page before committing a production budget, because model routing and prices can change.

A simple cost model for a structured extraction endpoint:

ScenarioAssumptionApproximate cost with Terra pricingLesson
1,000 clean requests2,000 input tokens and 300 output tokens each$1.00 input + $0.90 output = $1.90The schema itself should stay compact
8% retry rate from invalid JSONSame request repeated for 80 requestsAbout $0.15 extraFormat failures turn directly into token waste
Reused schema and system instructionsRepeated prefix is cacheable when route/account supports itCached input can be cheaper than full inputPut stable instructions before variable user text
Oversized schemaAdds 1,000 input tokens per requestAbout $0.50 per 1,000 requestsDo not ship a schema larger than the task needs

Prompt caching deserves its own implementation plan. The OpenAI prompt caching guide explains the OpenAI behavior, and OurToken's OpenAI-compatible prompt caching guide explains how to reason about cached input in a multi-model gateway. The important accounting rule is simple: do not double count cached tokens. If a usage object reports both total input tokens and cached-token details, cached tokens are a breakdown of the input total, not an extra category to add on top.

A small usage normalizer keeps dashboards sane:

from typing import Any


def usage_to_counts(usage: Any) -> dict[str, int]:
    if usage is None:
        return {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}

    data = usage if isinstance(usage, dict) else usage.model_dump()
    prompt_details = data.get("prompt_tokens_details") or data.get("input_tokens_details") or {}

    input_tokens = data.get("input_tokens", data.get("prompt_tokens", 0)) or 0
    output_tokens = data.get("output_tokens", data.get("completion_tokens", 0)) or 0
    cached_tokens = prompt_details.get("cached_tokens", 0) or 0

    return {
        "input_tokens": int(input_tokens),
        "output_tokens": int(output_tokens),
        "cached_tokens": int(cached_tokens),
    }

This helper does not compute a bill by itself. It just normalizes the names that different SDKs and compatibility layers may expose. Billing logic should subtract cached input from full-price input only when the route returns a documented cached-token breakdown and the model page lists a separate cached-input price.

The production checklist is short:

  1. Keep the schema version in code and logs.
  2. Validate the model output locally before writing to a database.
  3. Treat refusals as a separate application state.
  4. Treat incomplete responses as failed extractions, not partial successes.
  5. Cap max_output_tokens based on the largest valid object you expect.
  6. Track schema-validation failures, retries, latency, and usage together.
  7. Re-test after changing model ID, schema, prompt, or route.

When the endpoint starts serving real traffic, sample failures into a private review queue. Do not log raw API keys, full private prompts, or sensitive customer text. A good log entry contains request ID, schema version, route, status, finish or incomplete reason, token counts, and a redacted error category.

Conclusion and FAQ

OpenAI structured outputs json schema is the right default when model output becomes part of your product's data path. Use the Responses API text.format field, keep the schema strict, set additionalProperties: false, validate locally, and handle refusals and incomplete output as first-class states. JSON mode is useful for quick prototypes, but schema adherence is what lets queues, dashboards, and databases trust the result.

If you want to test this with a current OpenAI model route, start from the GPT-5.6 Terra API page, create a key on OurToken API Keys, and run the cURL smoke test before moving to Python. Keep the first schema small. Once the first route is reliable, add validation metrics and compare retry rate, latency, and token usage against your old JSON-mode or prompt-only implementation.

FAQ

What is OpenAI Structured Outputs JSON Schema?

It is a way to ask a compatible OpenAI model to return JSON that follows a supplied JSON Schema. In the Responses API, the structured final answer is configured with text.format and type: "json_schema".

Is Structured Outputs the same as JSON mode?

No. JSON mode aims to return valid JSON. Structured Outputs aims to return JSON that follows your schema, including required fields, enum values, and blocked extra keys when additionalProperties: false is used.

Should I use text.format or response_format?

Use text.format with the Responses API. Use response_format with Chat Completions. Do not mix the two request shapes in the same example.

Can I use the OpenAI Python SDK with OurToken?

Yes, for OpenAI-compatible routes. Set base_url="https://api.ourtoken.ai/v1", pass your OurToken key as the SDK API key, and call the endpoint documented by the selected model page. For the examples in this article, the SDK method is client.responses.create.

Do I still need local validation?

Yes. Structured Outputs improves schema adherence, but production systems should still parse and validate any external response before writing it to storage or triggering irreversible actions.

What should I do if the model refuses?

Treat refusal as a separate state. Show an appropriate user message, send the item to review, or ask for safer input. Do not try to parse a refusal as if it were your schema object.

Why does my route reject the schema field?

First confirm that you are using /v1/responses with text.format, not /v1/chat/completions with a Responses-style body. Then confirm that your chosen model route supports Structured Outputs. If the route only documents Chat Completions structured output, use its documented response_format path instead.