JSON Schema and Strict Mode
The text.format Parameter
Lesson 1 previewed the mechanism this lesson explains in full: passing a text parameter to client.responses.create() with a format specifying "type": "json_schema", a schema describing the exact shape of the desired output, and "strict": True. This is the core mechanism behind structured outputs, and understanding each piece of it — what it does, why it's shaped the way it is, and what happens when a piece is removed or changed — is the foundation the rest of this unit builds on.
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the name, age, and city from: 'Maria Gomez, 34, lives in Austin.'",
text={
"format": {
"type": "json_schema",
"name": "person_extraction",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"},
},
"required": ["name", "age", "city"],
"additionalProperties": False,
},
"strict": True,
}
},
)
print(response.output_text)
# '{"name":"Maria Gomez","age":34,"city":"Austin"}'
The output is a JSON-formatted string, guaranteed to conform exactly to the schema — every field present, every type correct, no unexpected extra fields. It is still, technically, a string (response.output_text), so json.loads() is still the step that turns it into a native Python dict — but unlike Lesson 1's free-text approach, that json.loads() call is now guaranteed to succeed, and the resulting dictionary is guaranteed to have exactly the shape the schema described.
What JSON Schema Is
JSON Schema is a widely used, standardized vocabulary for describing the shape of JSON data — independent of, and predating, its use here for constraining model output. It's the same specification used in many other contexts (API request/response validation, configuration file validation, form generation), which means the skill of writing a JSON Schema is broadly transferable well beyond this specific API. At its core, a schema is itself a JSON object describing constraints: what "type" a value must be ("string", "integer", "number", "boolean", "array", "object", or "null"), what "properties" an object must have and what each one's own schema looks like, which of those properties are "required", and whether "additionalProperties" beyond the ones explicitly listed are allowed.
# A schema is just a JSON-serializable description of a shape, not code
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "integer"},
"is_urgent": {"type": "boolean"},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "priority", "is_urgent", "tags"],
"additionalProperties": False,
}
Reading this schema top to bottom: it describes an object with exactly four properties — a string title, an integer priority, a boolean is_urgent, and an array tags whose individual elements must themselves be strings ("items": {"type": "string"} describes the schema for each element of the array, not the array itself). Every one of these four keys is listed in "required", meaning a schema-valid response must include all four; and "additionalProperties": False means a response containing any key beyond these four would not be considered schema-valid.
What strict: True Actually Does
The "strict": True flag is the piece of this mechanism that turns a schema from a description into an enforced constraint. Without it, older or non-strict structured-output modes may only guide the model toward the schema as a strong preference — closer to a very precise version of Lesson 1's formatting instructions — without guaranteeing the output actually validates against it. With strict: True, the generation process is constrained at the token level so that the model is literally incapable of producing a token sequence that would violate the schema — every token generated for the output is filtered against what the schema still permits at that point in the structure, so the result is not "usually" valid JSON matching the schema; it is guaranteed to be, by construction, in exactly the same sense that a compiler-enforced type system guarantees a variable's declared type, rather than merely encouraging a programmer to respect it.
# Without strict mode, similar functionality may exist but without the same
# construction-level guarantee — always confirm current behavior for your
# SDK version if you see structured-output code omitting "strict": True.
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract structured data from: 'Order #4471, total $58.20, status: shipped.'",
text={
"format": {
"type": "json_schema",
"name": "order_extraction",
"schema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"total": {"type": "number"},
"status": {"type": "string"},
},
"required": ["order_id", "total", "status"],
"additionalProperties": False,
},
"strict": True,
}
},
)
Note: The exact requirements and constraints strict mode imposes on schema shape (which JSON Schema features are supported, whether every property must be listed in
required, whether nested objects need their own"additionalProperties": False) are specific, version-dependent implementation details. Always confirm the current supported subset of JSON Schema against your installed SDK version's documentation before designing a schema for production use, rather than assuming every JSON Schema feature you might know from other contexts is necessarily supported here.
Why additionalProperties: False Matters
It's worth understanding precisely why this particular flag is required (and, in strict mode, typically enforced as mandatory) rather than left as an optional nicety. Without it, a schema only describes a minimum shape — properties that must be present — while leaving the door open for the model to add extra, unrequested fields it judges relevant. This might sound harmless, but it reintroduces exactly the kind of format uncertainty structured outputs are meant to eliminate: code written against data["order_id"], data["total"], and data["status"] doesn't care whether extra fields exist, but code that iterates over data.keys() expecting exactly the three requested fields, or that serializes the dictionary directly into a strict downstream schema, would break in the presence of unexpected extra keys the schema author never anticipated.
# Without additionalProperties: False, a model might reasonably add a
# helpful-seeming extra field the schema never asked for
{
"order_id": "4471",
"total": 58.20,
"status": "shipped",
"confidence": "high", # unrequested — breaks strict downstream assumptions
}
Setting "additionalProperties": False closes this door entirely: the schema becomes a complete, exhaustive description of the object's shape, not merely a floor. This is a meaningful difference in what "structured" actually guarantees, and it's why production schemas should set this explicitly on every object level (including nested objects, which each need their own "additionalProperties": False if they should be similarly closed) rather than relying on it as an implicit default.
Required Fields and What "Required" Really Means Here
Marking a field as "required" in a JSON Schema being used for structured outputs means the model is constrained to always include that key in its output — it does not, on its own, mean the model will always have a confident, correct value to put there. This distinction matters enormously in practice and is worth sitting with before moving further: a "required" field guarantees presence, not certainty or correctness of the value inside it.
schema = {
"type": "object",
"properties": {
"customer_name": {"type": "string"},
"sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},
},
"required": ["customer_name", "sentiment"],
"additionalProperties": False,
}
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the customer name and sentiment from: 'this product is fine i guess'",
text={"format": {"type": "json_schema", "name": "review_extraction", "schema": schema, "strict": True}},
)
print(response.output_text)
# The customer name is never mentioned — the model MUST still produce a
# "customer_name" key (required), likely with an empty string or a
# reasonable placeholder, since the schema doesn't allow omitting it.
This is a genuinely important design consideration Lesson 4 of this unit develops in far more depth: when a field might legitimately be missing or unknowable from the input, the schema needs to be designed to allow for that explicitly (typically via a nullable type, covered next), rather than marking the field "required" and hoping the model invents something reasonable when the true answer is "not present in the input at all."
Nullable Fields for Legitimately Missing Data
JSON Schema supports describing a field whose value may be either a specific type or null, using an array of types.
schema = {
"type": "object",
"properties": {
"customer_name": {"type": ["string", "null"]},
"sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},
},
"required": ["customer_name", "sentiment"],
"additionalProperties": False,
}
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the customer name and sentiment from: 'this product is fine i guess'",
text={"format": {"type": "json_schema", "name": "review_extraction", "schema": schema, "strict": True}},
)
print(response.output_text)
# '{"customer_name":null,"sentiment":"neutral"}'
Notice customer_name is still listed as "required" — required here means the key must be present in the output object, not that its value can't be null. This combination — required key, nullable value — is the correct pattern for a field that should always be addressed by the model but may legitimately have no answer, and it is meaningfully different, both in schema design and in downstream code, from a field that's genuinely optional and might be entirely absent from the object (a distinction that matters more once nested, nontrivial data structures are involved, covered later in this lesson).
Enums for Constrained Categorical Values
Beyond basic types, JSON Schema supports an "enum" constraint — a fixed list of allowed values a string (or any type) must be one of — which is directly useful for classification tasks where the answer needs to be one of a known, closed set of categories rather than any arbitrary string.
schema = {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical_support", "account_access", "feature_request", "other"],
},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
},
"required": ["category", "urgency"],
"additionalProperties": False,
}
response = client.responses.create(
model="gpt-5.6-luna",
input="Classify this support ticket: 'I can't log into my account and I need access urgently for a client call in an hour.'",
text={"format": {"type": "json_schema", "name": "ticket_classification", "schema": schema, "strict": True}},
)
print(response.output_text)
# '{"category":"account_access","urgency":"high"}'
Under strict mode, category is guaranteed to be one of exactly the five listed strings — not merely likely to be, but structurally incapable of being anything else, since the constrained generation process only permits tokens that keep the output within the schema's allowed values at every step. This is a substantial improvement over Lesson 1's free-text classification, where a model might output "account access" (a space instead of an underscore), "login issue" (a category never defined at all), or any number of other superficially reasonable variations that would each require separate handling in downstream code — an enum constraint eliminates that entire category of inconsistency by construction.
Arrays and Nested Objects
Real extraction tasks frequently involve more than flat key-value pairs — a list of items, a nested address object, a collection of line entries. JSON Schema supports this directly through "type": "array" (with an "items" schema describing each element) and through nesting "type": "object" schemas inside other object properties.
schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"quantity": {"type": "integer"},
},
"required": ["name", "quantity"],
"additionalProperties": False,
},
},
"shipping_address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"},
"zip_code": {"type": "string"},
},
"required": ["street", "city", "zip_code"],
"additionalProperties": False,
},
},
"required": ["order_id", "items", "shipping_address"],
"additionalProperties": False,
}
response = client.responses.create(
model="gpt-5.6-luna",
input=(
"Order #7723: 2 wireless mice and 1 keyboard, "
"ship to 44 Oak Street, Denver, 80202."
),
text={"format": {"type": "json_schema", "name": "full_order", "schema": schema, "strict": True}},
)
import json
data = json.loads(response.output_text)
print(data["items"]) # [{'name': 'wireless mice', 'quantity': 2}, {'name': 'keyboard', 'quantity': 1}]
print(data["shipping_address"]) # {'street': '44 Oak Street', 'city': 'Denver', 'zip_code': '80202'}
Notice each nested object — the individual item schema, the shipping address schema — needs its own "required" and "additionalProperties": False, exactly as the top-level object does; strictness is enforced per-object throughout the schema's structure, not only at the outermost level. This is the point at which the value of structured outputs over free-text parsing becomes dramatically clearer than it was for Lesson 1's flat three-field example: hand-written parsing logic for a nested, variable-length list of order items, each with its own sub-fields, would be substantially more complex and more fragile than the flat case already was — while the schema-based approach scales to this nested shape with no additional parsing logic required at all, only a more elaborate schema.
Building Schemas Programmatically
Because a JSON Schema is just a Python dictionary, it can be constructed and reused programmatically rather than hand-written inline for every call — a meaningful convenience once an application defines several related extraction schemas.
def build_classification_schema(field_name: str, categories: list[str]) -> dict:
"""Reusable helper for a common pattern: a single categorical field."""
return {
"type": "object",
"properties": {
field_name: {"type": "string", "enum": categories},
},
"required": [field_name],
"additionalProperties": False,
}
sentiment_schema = build_classification_schema("sentiment", ["positive", "neutral", "negative"])
priority_schema = build_classification_schema("priority", ["low", "medium", "high", "critical"])
def classify(text: str, schema: dict, schema_name: str) -> dict:
response = client.responses.create(
model="gpt-5.6-luna",
input=text,
text={"format": {"type": "json_schema", "name": schema_name, "schema": schema, "strict": True}},
)
return json.loads(response.output_text)
print(classify("This is the best purchase I've made all year!", sentiment_schema, "sentiment_check"))
Structuring schema construction this way — as small, composable, testable Python functions rather than large inline dictionary literals repeated at every call site — keeps a codebase with several extraction features consistent and makes a schema easy to unit test independently of any actual API call, simply by asserting on the dictionary structure build_classification_schema() returns.
What Happens When a Field's Type Is Removed or Changed
It's worth directly observing what changes when a schema constraint is altered, to build intuition for how tightly the schema controls the output. Removing "type": "integer" in favor of "type": "string" for a numeric field, for instance, changes what's structurally guaranteed about that field, even if the underlying data represented is conceptually the same number.
# With age typed as an integer
schema_int = {"type": "object", "properties": {"age": {"type": "integer"}}, "required": ["age"], "additionalProperties": False}
# Output: {"age": 34} — a JSON number, ready for arithmetic without conversion
# With age typed as a string
schema_str = {"type": "object", "properties": {"age": {"type": "string"}}, "required": ["age"], "additionalProperties": False}
# Output: {"age": "34"} — a JSON string, requiring int() before arithmetic
Neither is "wrong" in isolation — the right choice depends entirely on what the consuming code expects — but the schema is what decides the answer definitively and consistently, rather than leaving it to whatever the model happens to produce on a given call, which is precisely the property Lesson 1 established free-text parsing cannot offer.
Testing Schema-Based Extraction Without a Live Call
Following this course's consistent dependency-injection testing pattern, code that consumes a structured-output response can be tested by substituting a fake response object carrying pre-built JSON text, verifying downstream parsing and business logic without needing a live API call for every test.
class FakeResponse:
def __init__(self, output_text: str):
self.output_text = output_text
def process_extraction(response) -> dict:
"""The logic under test — parses and lightly post-processes a structured response."""
data = json.loads(response.output_text)
data["processed"] = True
return data
def test_process_extraction_handles_valid_schema_output():
fake = FakeResponse('{"order_id": "4471", "total": 58.20, "status": "shipped"}')
result = process_extraction(fake)
assert result["order_id"] == "4471"
assert result["total"] == 58.20
assert result["processed"] is True
print("PASS: process_extraction correctly parses schema-valid JSON")
test_process_extraction_handles_valid_schema_output()
Because strict mode guarantees the shape of the JSON text a real call would produce, a test like this one can safely assume schema-valid input when constructing its fake response — the thing actually worth testing is your own downstream logic (process_extraction() here), not whether the API itself honors its own schema guarantee, which is the platform's responsibility rather than something your test suite needs to re-verify on every run.
When the Schema Itself Is Invalid
It's worth distinguishing two very different kinds of failure that can occur around structured outputs: the model producing output that doesn't match a valid schema (which strict mode is specifically designed to make impossible) versus the schema definition itself being malformed or unsupported, which is a request-time error your code needs to handle like any other invalid API call.
malformed_schema = {
"type": "object",
"properties": {
"age": {"type": "integer"},
},
# Missing "required" and "additionalProperties" — depending on strict
# mode's exact requirements for your SDK version, this may be rejected
# outright rather than silently accepted with looser guarantees.
}
try:
response = client.responses.create(
model="gpt-5.6-luna",
input="Extract the age from: 'She is 29.'",
text={"format": {"type": "json_schema", "name": "age_extraction", "schema": malformed_schema, "strict": True}},
)
except Exception as e:
print(f"Schema was rejected at request time: {e}")
This is a fundamentally different failure mode from anything Lesson 1 discussed: it happens immediately, at the point the request is submitted, rather than being discovered later when downstream code tries to use a malformed result — closer to a syntax error caught at compile time than a logic error discovered at runtime. Treating schema validation errors as a distinct, expected category in your application's error handling (rather than lumping them in with generic API failures) makes it much faster to diagnose a schema authoring mistake specifically, rather than assuming a transient service issue and retrying a request that will fail identically every time until the schema itself is corrected.
Comparing Free-Text Parsing and Schema-Enforced Output Directly
To make the concrete difference from Lesson 1 fully explicit, consider running the exact same underlying extraction task both ways and comparing what each approach actually guarantees about its result.
prompt = "The customer, Aisha Rahman, rated the service 2 out of 5 stars and said it was too slow."
# Free-text approach (Lesson 1) — format is a hope, not a guarantee
free_response = client.responses.create(model="gpt-5.6-luna", input=f"Extract the customer name and rating from: {prompt}")
print(free_response.output_text)
# Some plausible, unpredictable shape — a sentence, a list, a colon-separated pair
# Schema-enforced approach (this lesson) — format is guaranteed by construction
schema_response = client.responses.create(
model="gpt-5.6-luna",
input=f"Extract the customer name and rating from: {prompt}",
text={
"format": {
"type": "json_schema",
"name": "rating_extraction",
"schema": {
"type": "object",
"properties": {
"customer_name": {"type": "string"},
"rating": {"type": "integer"},
},
"required": ["customer_name", "rating"],
"additionalProperties": False,
},
"strict": True,
}
},
)
data = json.loads(schema_response.output_text)
print(data) # {'customer_name': 'Aisha Rahman', 'rating': 2} — guaranteed shape, every time
Running both versions repeatedly against the same prompt is a useful, concrete exercise: the free-text version's output shape will drift across calls in exactly the ways Lesson 1 described, while the schema-enforced version's shape stays identical every time — only the specific extracted values change, which is exactly the property that makes the schema-enforced result safe to feed directly into downstream code without any of Lesson 1's defensive parsing logic.
Schemas Evolve — Plan for Versioning
A schema used in a production application is not a one-time artifact; it changes as requirements evolve — a new field is needed, an existing field's type needs to change, a category needs to be added to an enum. Because a schema directly shapes the data your application's downstream code depends on, changing it carries the same kind of compatibility risk as changing a database column or an API response shape, and deserves similar care.
# A schema, versioned explicitly by name, so a change doesn't silently affect
# code that isn't ready for it — and so both versions can be run side by side
# during a migration if needed
TICKET_SCHEMA_V1 = {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical_support", "other"]},
},
"required": ["category"],
"additionalProperties": False,
}
TICKET_SCHEMA_V2 = {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical_support", "account_access", "other"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]}, # new field
},
"required": ["category", "urgency"],
"additionalProperties": False,
}
Keeping old and new schema versions as distinct, explicitly named constants (rather than mutating one shared schema object in place) makes it straightforward to run both during a transition period, to see exactly what changed in a code review by diffing two named objects, and to roll back to a previous version cleanly if a new field or category turns out to need further revision — treating schema changes with the same deliberateness as any other change to a data contract your application depends on.
Common Mistakes
Omitting "additionalProperties": False on a nested object, assuming strictness only needs to be declared once at the top level, and being surprised when a nested object in the output contains an unexpected extra field.
Marking a field "required" when the underlying data may genuinely be absent from the input, without also making its type nullable — forcing the model into either inventing a plausible-sounding but fabricated value or producing a schema violation, neither of which is the intended behavior; Lesson 4 covers this specific design tension directly.
Assuming every JSON Schema feature familiar from other tools or specifications is supported here without checking current documentation — structured-output schema support is typically a defined subset of the full JSON Schema specification, and assuming otherwise can lead to a schema that's rejected outright or silently ignored in part.
Treating a "required" field as a guarantee of correctness rather than presence — the schema guarantees the key exists and has the right type; it says nothing about whether the model correctly identified the right value for it, which remains a content-level concern no schema alone resolves.
Best Practices
Set "additionalProperties": False explicitly at every object level in a schema, top-level and nested alike, to make the schema a complete, closed description of the expected shape rather than only a minimum floor.
Use "enum" for any field whose valid values form a known, closed set, rather than a plain unconstrained string, to get the same construction-level guarantee for categorical correctness that typed fields provide for structural correctness.
Design nullable, required fields for data that may legitimately be missing from the input, rather than omitting the field from "required" (which risks the model dropping it in a way your code doesn't expect) or marking it required-and-non-nullable (which risks the model fabricating a value to satisfy the schema).
Build schemas as reusable, testable functions or constants in your codebase, rather than large inline dictionary literals repeated across call sites, and version them the way you would any other piece of code your application's correctness depends on.
Confirm current strict-mode requirements and supported JSON Schema features against your installed SDK version's documentation before relying on any specific advanced feature, since this is exactly the kind of platform detail that evolves faster than a course's written material can track with certainty.