SoftwareGuide 4 of 4
What Structured Output Is
Checked by machine against its sources on .
You want data from a model, not prose. A customer writes in, and your support system needs a ticket: which order, what went wrong, whether they want their money back. So you ask the model to "reply in JSON", and most of the time it does — until the day it names a field differently, leaves one out, or wraps the answer in a sentence, and the code that reads it stops at the first line. Asking nicely is not a contract.
Structured Outputs is the contract. OpenAI describes it as ensuring text responses from the model adhere to a JSON schema you define. You write down the shape you want — every field, its type, the values it may take — send that shape with the request, and the model's reply comes back in exactly that shape. In OpenAI's own words, the feature ensures the model will always generate responses that adhere to your supplied JSON Schema, so you need not worry about it omitting a required key or hallucinating an invalid enum value.
This guide builds the ticket from that example, in one file, in three steps. You will send a customer email with a schema and read the ticket that comes back; break one of the schema's rules on purpose and see what the API says; and cut a reply short to see the one case where no ticket arrives at all. Every output on this page is what one run printed, on OpenAI's gpt-6-luna.
The model's own words will differ on your run — one field here is a sentence it writes. The shape will not, and the shape is the point.
By the end of this guide you will
- Write a JSON Schema for the data you need, and send it with a request so the reply follows it.
- Read the reply field by field, and know which parts the schema fixes and which are still the model's choice.
- Recognise the error the API returns for a schema that breaks its rules, and fix the schema.
- Handle the replies that carry no data — cut short, or refused — before your code tries to read them.
Before you start
You need four things. Check each one before you write any code.
- Node.js 22 or newer. Run
node --version. It prints a version such asv24.14.1, which is what the run on this page used. If it prints a lower number or an error, install Node.js from the official site. - An OpenAI API key. Create one on the API keys page of your OpenAI dashboard and copy it somewhere safe. It never goes into the code.
- A folder with the SDK installed. In a terminal, run
mkdir structured-demo, thencd structured-demo, thennpm init -y, thennpm install openai@7.15.0. The last command installs theopenaipackage at the version this run used. - The key in your environment, in the same terminal window you will run the file from. On Windows PowerShell:
$env:OPENAI_API_KEY = "your key here". On macOS or Linux:export OPENAI_API_KEY="your key here". The SDK reads that variable by itself, which is why the code below never mentions the key. If you open a new window, set it again.
Everything on this page ran on gpt-6-luna, the model OpenAI's catalogue points to for cost-sensitive, high-volume workloads — which is what turning emails into tickets is.
Step 1 — Send a schema with the request and read the ticket
Create a file named ticket.mjs and put this in it:
ticketimport OpenAI from 'openai';
const client = new OpenAI(); // reads OPENAI_API_KEY from the environment; the key is never in the code
const email =
'Hi, my order 1042 arrived this morning and the lamp shade is cracked right across. ' +
'I would rather have my money back than wait for another one. ' +
'If you need photos you can call me on 555-0142. Thanks, Dana';
// The contract: every field the ticket has, what each may hold, and nothing else.
const ticketSchema = {
type: 'object',
properties: {
order_id: { type: 'string', description: 'The order number as written in the email, digits only.' },
issue: { type: 'string', enum: ['late', 'damaged', 'wrong_item', 'other'] },
wants_refund: { type: 'boolean', description: 'True only if the customer asks for their money back.' },
summary: { type: 'string', description: 'One sentence a support agent can read in five seconds.' },
contact_phone: { type: ['string', 'null'], description: 'A phone number the customer gave, or null.' },
},
required: ['order_id', 'issue', 'wants_refund', 'summary', 'contact_phone'],
additionalProperties: false,
};
const format = { type: 'json_schema', name: 'support_ticket', schema: ticketSchema, strict: true };
const input = [
{ role: 'system', content: 'Turn the customer email into a support ticket.' },
{ role: 'user', content: email },
];
// A structured reply can end three ways: complete, refused, or cut short. Only the first holds a ticket.
function readTicket(response) {
if (response.status === 'incomplete') {
return { ended: `incomplete (${response.incomplete_details?.reason})`, ticket: null };
}
const content = response.output.filter((item) => item.type === 'message').flatMap((item) => item.content);
const refusal = content.find((part) => part.type === 'refusal');
if (refusal) return { ended: `refused: ${refusal.refusal}`, ticket: null };
return { ended: 'complete', ticket: JSON.parse(response.output_text) };
}
const response = await client.responses.create({ model: 'gpt-6-luna', input, text: { format } });
const { ended, ticket } = readTicket(response);
console.log(`status: ${ended}`);
for (const [field, value] of Object.entries(ticket)) console.log(`${field}: ${JSON.stringify(value)}`);
Output
status: complete
order_id: "1042"
issue: "damaged"
wants_refund: true
summary: "Order 1042 arrived with a cracked lamp shade, and the customer wants a refund rather than a replacement."
contact_phone: "555-0142"
Three parts do the work. ticketSchemais the contract, written in JSON Schema — the standard way to describe the shape of a JSON value. Structured Outputs supports a subset of that language , and the types it supports are String, Number, Boolean, Integer, Object, Array, Enum and anyOf. The ticket uses four of them:order_id and summary are strings, wants_refund is a boolean, and issue is a string limited by enum to four values.
format is how the schema travels with the request. OpenAI's page gives the switch as text: { format: { type: "json_schema", "strict": true, "schema": ... } }, and the code does exactly that, with aname for the schema. strict: true is what turns the schema from a suggestion into the contract.
readTicket is there because a reply can end without a ticket in it. The next two steps show why; for now, notice that it only calls JSON.parse when the reply is complete.
Run it with node ticket.mjs. Here is what it printed:
status: complete— the reply finished, andreadTicketfound a ticket in it.order_id: "1042"— in quotation marks, because the schema saysorder_idis a string. The email wrote "order 1042"; the ticket holds the number alone, as the schema's description asked.issue: "damaged"— one of the four valuesenumallows. The model cannot answer "broken" or "cracked shade": those are not in the list.wants_refund: true— a real boolean, not the word "yes". The email said the customer "would rather have my money back".summary: "Order 1042 arrived with a cracked lamp shade, and the customer wants a refund rather than a replacement."— the one field that is the model's own sentence. Yours will be worded differently; it will still be a string, in this field.contact_phone: "555-0142"— the number from the email.
Now look at what the code did not need. The instruction to the model is one short sentence, "Turn the customer email into a support ticket." — no "reply only with JSON", no "do not add any other text". That is one of the benefits OpenAI lists: simpler prompting, with no need for strongly worded prompts to get consistent formatting. Another is reliable type-safety: no need to validate or retry incorrectly formatted responses. The loop at the end of the step prints every field withObject.entries and never checks whether a field is there, because the schema already said it would be.
Two details of the schema are rules, not style. contact_phone has the type ['string', 'null']: the schema must list every field as required, and a field the email might not contain is expressed as "a string, or null". To use Structured Outputs, all fields or function parameters must be specified as required , and an optional field can be emulated with a union type that includes null. An email with no phone number would come back withcontact_phone set to null — present, and empty. The other rule is the last line of the schema, additionalProperties: false, and Step 2 is about what happens without it.
One more thing you may notice if you run the file twice: the first run can be slower. The first request with any schema has additional latency while the API processes the schema; later requests with the same schema do not.
Step 2 — Break a rule, and watch the API refuse it
Add this below Step 1's code, in the same file:
broken-schemaimport { APIError } from 'openai';
// The same schema with one rule broken: additionalProperties is no longer false.
const loose = { ...ticketSchema };
delete loose.additionalProperties;
try {
await client.responses.create({ model: 'gpt-6-luna', input, text: { format: { ...format, schema: loose } } });
console.log('accepted');
} catch (error) {
if (!(error instanceof APIError)) throw error;
console.log(`refused before the model ran: HTTP ${error.status}`);
console.log(`message: ${error.error?.message ?? error.message}`);
}
Output
refused before the model ran: HTTP 400
message: Invalid schema for response_format 'support_ticket': In context=(), 'additionalProperties' is required to be supplied and to be false.
It sends the same email with the same schema, except that additionalProperties is gone. Run node ticket.mjs again. Step 1's lines print first; then two more. Here is what they said:
refused before the model ran: HTTP 400— the API rejected the request itself. 400 is the HTTP status for a request the server will not process; the model never saw the email.message: Invalid schema for response_format 'support_ticket': In context=(), 'additionalProperties' is required to be supplied and to be false.— the reason, in the API's words.'support_ticket'is thenameyou gave the schema;context=()is an empty path, and the only object in this schema is the top-level one that lost the rule; the rest says exactly what to put back.
The rule behind it: Structured Outputs only generates specified keys and values, so developers must set additionalProperties: falseto opt into it. Without that line the schema would allow the model to add fields you never named — and a contract that allows anything is not a contract, so the API refuses it outright rather than guess.
That refusal is the useful kind. It arrives on the first request you send with the broken schema, in your terminal, with the field named — not weeks later as a ticket with a stray key your database does not expect. When you write a schema of your own, expect a few of these messages before the first ticket comes back, and read each one: it names the rule.
Step 3 — Cut a reply short, and handle what comes back
Add this below Step 2's code:
cut-short// The same request with room for only 16 output tokens: not enough to finish the ticket.
const short = await client.responses.create({ model: 'gpt-6-luna', input, text: { format }, max_output_tokens: 16 });
const cut = readTicket(short);
console.log(`status: ${cut.ended}`);
console.log(`ticket: ${JSON.stringify(cut.ticket)}`);
console.log(`output_text: ${JSON.stringify(short.output_text)}`);
Output
status: incomplete (max_output_tokens)
ticket: null
output_text: ""
The request is Step 1's, with one change: max_output_tokens: 16, a limit far too small to write the ticket in. Run node ticket.mjs a third time. After the earlier lines, three more:
status: incomplete (max_output_tokens)— the reply stopped because it hit the limit.readTicketread the response'sstatus, sawincomplete, and reported the reason the API gave.ticket: null— no ticket, andreadTicketsaid so instead of guessing at one.output_text: ""— the reply's text is empty. The limit ran out before a single character of the ticket was written.
This is the one situation the schema cannot protect you from, and OpenAI's page says so: the model might not generate a valid response matching the schema in the case of a refusal for safety reasons, or when a max tokens limit is reached and the response is incomplete. Had Step 1's code calledJSON.parse(response.output_text) without looking at the status first, it would have been parsing an empty string — which is not JSON, so the call throws, and your program stops on the one reply that carried no data.
The other empty case is a refusal, and readTicket handles it too, though this email never triggers it. Because a refusal does not necessarily follow the schema, the API response includes a field called refusalto show that the model refused the request. That is the second benefit on OpenAI's list: explicit refusals, detectable in code.readTicket looks for a content part of type refusal and returns its text instead of a ticket. Between them, the three branches of readTicket — complete, refused, incomplete — are every way this request can end.
The one rule
The schema guarantees the shape of the answer, not that the answer is right.
What Structured Outputs promises is exactly what its definition says: every required key present, every enum value one of the allowed ones. It does not promise that the model chose the right value. In Step 1,issue: "damaged" is correct because the email describes a cracked shade; an email about a parcel that arrived a week late and damaged would force the model to pick one of the two, and the schema would accept either.
| The schema guarantees | The schema does not guarantee |
|---|---|
order_id is present and is a string |
that order 1042 exists — look it up in your order system |
issue is one of late, damaged, wrong_item, other |
that the model picked the right one |
wants_refund is true or false |
that the customer really asked for a refund |
contact_phone is a string or null |
that the string is a working phone number |
| no field outside the five | anything about a reply that is incomplete or refused — check status first |
So treat a structured reply the way you would treat a form a stranger filled in: the boxes are all there and in the right format, and you still check the answers that matter. For the order number, that check is a lookup — the same order-status function What Tool Use Is gave the model as a tool.
When to use it, and when not
| Situation | Decision | Reason |
|---|---|---|
| You need fields out of free text — emails, forms, notes | Use a JSON Schema response format | The reply arrives as data in a shape your code already expects |
| The model's reply is shown in your own interface, part by part | Use a JSON Schema response format | It suits a structured schema for the model's reply to the user [claim:response-format-when] |
| The model should run a function in your application | Use function calling with strict: true instead |
Function calling is for bridging the model and your application's functionality [claim:function-calling-when]; Structured Outputs works there too [claim:two-forms] |
| You want a paragraph of prose for a person to read | Don't | A schema adds nothing to text that is only ever read |
| A value must be a verified fact — a price, a stock level, an order's real status | Don't take it from the model at all | The schema guarantees a well-formed value, not a true one; look it up |
| Your schema needs a JSON Schema feature the API does not accept | Simplify the schema | Only a subset of JSON Schema is supported [claim:subset], and the API will refuse the rest as it did in Step 2 |
Terms that came up
- JSON — a text format for data: objects with named fields, lists, strings, numbers,
true,falseandnull. - JSON Schema — a standard way to describe the shape a JSON value must have: its fields, their types and their allowed values.
- Structured Outputs — OpenAI's feature that makes the model's reply follow a JSON Schema you supply.
strict: true— the setting that makes the schema binding rather than a hint.enum— a list of the only values a field may take, like the four forissue.- Required field — a field that must always be present; in a strict schema, every field is one.
additionalProperties: false— the rule that forbids fields the schema did not name; strict schemas must set it.- Union with
null— a type such as['string', 'null'], the way to say "this field may be empty". - JSON mode — the older setting that guarantees valid JSON but not any particular shape.
- Refusal — a reply in which the model declines the request; it carries a
refusalfield instead of your data. incomplete— the status of a reply that stopped early, for example atmax_output_tokens.output_text— the reply's text as one string; for a structured reply, the JSON your code parses.
In short
Structured Outputs turns "please reply in JSON" into a contract: you send a JSON Schema with strict: true, and a complete reply always has exactly that shape. The API refuses a schema that breaks its rules before the model runs, and a reply can still end incomplete or refused, so check how it ended before you parse it. The schema guarantees the format of every field, not that its value is true.
What changed recently
No source of this guide has changed since the last check.