Skip to main content
Lesson 10 of 16 · Prompt Engineering for Developers

JSON Mode and Schema Enforcement

Lesson 10 of 16 Updated November 3, 2026 Developer
On this page
  1. JSON Mode and Schema Enforcement
  2. JSON Mode vs. Structured Output
  3. Structured Output with JSON Schema
  4. Rules for Strict Structured Output
  5. Helper for Building Schemas
  6. Example: Structured Data Extraction
  7. Measuring What the Constraint Brings
  8. Key Takeaways

Lesson updated November 3, 2026 based on OpenAI documentation: temperature and top_p are only accepted with a reasoning effort of none, whereas GPT-5.6 models reason by default (medium). Calls that set these parameters must therefore specify reasoning={"effort": "none"} (Responses API) or reasoning_effort="none" (Chat Completions). Sources: Using GPT-6 · GPT-5.6 Terra.

JSON Mode and Schema Enforcement

OpenAI’s Structured Output guarantees that the model’s response is valid JSON conforming to a schema you define. No more fragile parsing with regex or format errors—the constraint is applied at the generation level itself.

This nuance is critical, and it changes everything for a developer. Asking “respond in JSON” in the prompt is a plea: the model obeys most of the time, but one day it prefixes its response with “Here is the requested JSON:”, adds Markdown backticks, or forgets a comma, and your json.loads throws an exception in production at 3 a.m. on the week’s largest batch. With Structured Output, the constraint applies during decoding: tokens that would produce invalid or non-compliant JSON simply cannot be chosen. You stop begging and start enforcing.

JSON Mode vs. Structured Output

OpenAI offers two levels of constraint, and the distinction hinges on a single word. JSON Mode guarantees valid JSON with no structural constraints; Structured Output guarantees JSON conforming to a JSON Schema you wrote.

JSON Mode solves half the problem. You are sure you can parse the response, but not sure what it contains: the model might name a field annee on one request and year on the next, or wrap its list in a resultats object it didn’t use the day before. For a prototype or raw display intended for a human, this suffices; as soon as the output feeds code that accesses fields by name, you need the second level.

from openai import OpenAI

client = OpenAI()

# Simple JSON mode (valid JSON, free structure)
response = client.responses.create(
    model="gpt-5.6-terra",
    instructions="Always respond in JSON.",
    input="List 3 programming languages with their year of creation.",
    text={"format": {"type": "json_object"}}
)

import json
data = json.loads(response.output_text)
print(data)

Structured Output with JSON Schema

This is the most powerful feature. You define a JSON schema, and the API guarantees the output adheres to it. The example below analyzes the sentiment of a customer review and illustrates three complementary uses of the schema.

The enum on sentiment prevents the model from inventing an intermediate category like “rather positive”: it must choose between the four provided values, making the output directly usable in a filter or dashboard. The description of the score field acts as a local mini-prompt, indicating the expected scale without requiring you to explain it in the general instructions, and it remains attached to the field even if the prompt evolves. Finally, the aspects array allows you to decompose a contradictory review—here, a product liked but delivery failed—instead of crushing that nuance into a single score that helps no one.

response = client.responses.create(
    model="gpt-5.6-terra",
    input="Analyze the sentiment of: 'The product is excellent "
          "but the delivery was catastrophic.'",
    text={
        "format": {
            "type": "json_schema",
            "name": "sentiment_analysis",
            "schema": {
                "type": "object",
                "properties": {
                    "sentiment": {
                        "type": "string",
                        "enum": ["positive", "negative", "mixed", "neutral"]
                    },
                    "score": {
                        "type": "number",
                        "description": "Score from -1.0 (very negative) to 1.0 (very positive)"
                    },
                    "aspects": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "aspect": {"type": "string"},
                                "sentiment": {
                                    "type": "string",
                                    "enum": ["positive", "negative", "neutral"]
                                }
                            },
                            "required": ["aspect", "sentiment"],
                            "additionalProperties": False
                        }
                    },
                    "summary": {"type": "string"}
                },
                "required": ["sentiment", "score", "aspects", "summary"],
                "additionalProperties": False
            },
            "strict": True
        }
    }
)

result = json.loads(response.output_text)
# Guaranteed: result has exactly the defined fields
print(f"Sentiment: {result['sentiment']}")
print(f"Score: {result['score']}")
for aspect in result["aspects"]:
    print(f"  - {aspect['aspect']}: {aspect['sentiment']}")

Rules for Strict Structured Output

The strict: true mode imposes its own constraints on the schema. All fields must appear in required, and additionalProperties must be false on every object. Allowed types are string, number, integer, boolean, array, object, and null, plus enum to constrain values. However, neither pattern nor minLength/maxLength are accepted: fine-grained validation happens on the client side.

These rules can be surprising on the first try because they forbid the optional field as usually written in JSON Schema. The trade-off is comfortable: your code never has to test whether a key exists; it is always there. For a field that can legitimately be empty—a phone number missing from a document, an end date for an ongoing contract—the answer is not to remove it from required but to allow it to be null. As for the absence of pattern and length bounds, it signals the next lesson: the schema guarantees the form, but business validation remains your job once the response is received.

Helper for Building Schemas

Manually writing the text.format block for every call quickly becomes tedious and prone to typos—a missing name key, a misplaced strict, and the error comes back from the API without indicating where to look. A few lines of wrapper code centralize the structure, apply a low temperature by default (since variability adds nothing to extraction), and make the call site readable.

def structured_query(prompt: str, schema: dict,
                     schema_name: str = "response",
                     model: str = "gpt-5.6-terra",
                     system: str = "") -> dict:
    """Wrapper for Structured Output requests."""
    response = client.responses.create(
        model=model,
        reasoning={"effort": "none"},  # temperature requires effort none
        instructions=system or "Respond in JSON according to the provided schema.",
        input=prompt,
        text={
            "format": {
                "type": "json_schema",
                "name": schema_name,
                "schema": schema,
                "strict": True
            }
        },
        temperature=0.1
    )
    return json.loads(response.output_text)

Example: Structured Data Extraction

Here is the use case that alone justifies learning this technique: transforming an invoice received as plain text into an object usable by your accounting system. The schema describes three levels—the invoice header, a nested vendor object, and a lines array where each element has the same shape. The model must therefore both locate information scattered throughout the document and place it in the right spot, including for a line expressed in hours rather than units, where the unit price is understood as an hourly rate.

Note that totals appear in the schema as fields to extract, not to recalculate. We ask the model to read; control arithmetic happens afterward in your code, where a simple comparison between the sum of the lines and the extracted total_ht will flag documents for human review.

invoice_schema = {
    "type": "object",
    "properties": {
        "invoice_number": {"type": "string"},
        "date": {"type": "string"},
        "vendor": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "siret": {"type": "string"},
                "address": {"type": "string"}
            },
            "required": ["name", "siret", "address"],
            "additionalProperties": False
        },
        "lines": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "description": {"type": "string"},
                    "quantity": {"type": "integer"},
                    "unit_price_excl_tax": {"type": "number"},
                    "vat_percent": {"type": "number"}
                },
                "required": ["description", "quantity",
                             "unit_price_excl_tax", "vat_percent"],
                "additionalProperties": False
            }
        },
        "total_excl_tax": {"type": "number"},
        "total_incl_tax": {"type": "number"}
    },
    "required": ["invoice_number", "date", "vendor",
                  "lines", "total_excl_tax", "total_incl_tax"],
    "additionalProperties": False
}

invoice_text = """
Invoice No. 2026-0042
Date: 15/03/2026
Vendor: TechServ SARL, SIRET 123 456 789 00012
12 rue de la Paix, 75002 Paris

- REST API Development x1: 3500.00 EUR excl. tax (VAT 20%)
- Cloud hosting (3 months) x1: 450.00 EUR excl. tax (VAT 20%)
- Technical support x10h: 150.00 EUR excl. tax/h (VAT 20%)

Total excl. tax: 5450.00 EUR
Total incl. tax: 6540.00 EUR
"""

result = structured_query(
    invoice_text,
    invoice_schema,
    schema_name="invoice",
    system="Extract the data from this invoice."
)

Measuring What the Constraint Brings

Apply this principle to a document you know well: define a JSON schema to extract key information from a resume—name, experience, skills, education—and test it on three plain-text resumes deliberately chosen for their heterogeneous layouts. Verify that the output respects the schema even when a section is completely missing.

Then repeat the same extraction without Structured Output, simply asking for JSON in the prompt, and keep the six outputs side by side. Comparing the two series shows what the constraint brings: the difference rarely lies in the extracted content, almost always in the stability of field names and the presence of expected keys.

Key Takeaways

  • Structured Output guarantees JSON conforming to the schema at the generation level
  • Use strict: true for maximum compliance
  • All fields must be required with additionalProperties: false
  • Ideal for data extraction, classification, and processing pipelines
  • Fine-grained validation (length, pattern) happens on the client side after receipt