Skip to content
AI Solutions12 min read

What Structured Outputs Actually Guarantee — And Three Ways You Get Nothing

Structured outputs are described as guaranteeing that a model returns valid JSON matching your schema. That is true with three exceptions your code must handle, and the schema language is a restricted subset that quietly reshapes how you model optional fields.

  • OpenAI API
  • Structured Outputs
  • JSON Schema
  • TypeScript
  • Production AI
Machined parts fitted into a precision jig, representing a schema that constrains shape but not content

Key takeaways

  • Structured outputs guarantee conformance to a schema, not correctness of the values inside it. A response can validate perfectly and be entirely wrong, so schema validation is not a substitute for evaluating the content.
  • The guarantee lapses on two documented paths. A safety refusal returns a refusal field instead of your schema, and hitting the output token limit returns status incomplete with reason max_output_tokens. Neither is an exception; both are normal states your code must handle.
  • Every field must be marked required and every object must set additionalProperties false. Optionality therefore cannot be expressed by omitting a field — it has to become nullability, which changes your domain types rather than just your schema.
  • The supported schema language is a subset. Composition keywords including allOf, not, if, then and else are unavailable, so schemas that branch on a discriminator have to be restructured rather than translated.
  • Structural limits are generous but real: up to 5,000 object properties, 10 levels of nesting, 1,000 enum values total, and 120,000 characters across all names, enum values and constants.
Level
intermediate
Time to implement
An hour to audit an existing integration against the three paths
Written for
Engineers

Structured outputs are usually introduced as a guarantee: the model will return valid JSON matching the schema you supplied. That is accurate and it is narrower than it sounds. The guarantee covers shape, it covers a restricted dialect of JSON Schema, and there are two documented paths where it does not apply at all.

None of this is hidden — it is stated plainly in the provider documentation, and has been since the feature was introduced. It is just that most integrations are written against the happy path, and the two other paths are not errors. They are ordinary responses with a different shape.

It guarantees shape, never correctness

This is the misunderstanding with the largest blast radius. A schema constrains the structure of the output: which keys exist, what types their values take, which strings are drawn from an enum. It says nothing about whether the values are right.

A model asked to extract an invoice total can return a perfectly conforming object with the wrong number in it. Validation passes. Types check. The pipeline is green and the figure is fabricated. Teams routinely treat schema conformance as a quality gate, and it is a serialisation guarantee wearing the costume of one.

The practical consequence is that structured outputs remove one class of bug — malformed JSON, parse failures, hand-written extraction regexes — and remove none of the evaluation work. If anything they make evaluation more necessary, because the output now looks trustworthy.

The schema language is a subset, and the gaps have shape

The structured outputs guide is explicit that only a subset of JSON Schema is supported. Two of the omissions change how you model data rather than merely restricting what you can express.

ConstraintWhat it means in practice
All fields must be requiredOptionality cannot be expressed by omission. An optional field becomes a nullable field, which changes your domain type, not just your schema.
additionalProperties: false is mandatoryEvery object is closed. Passthrough or extensible shapes have to be modelled explicitly, usually as a string field holding serialised data.
No allOf, not, if, then, elseConditional and compositional schemas cannot be translated directly. Branching on a discriminator has to be restructured, typically as a union of complete variants.
5,000 properties, 10 nesting levelsGenerous, but a schema generated from a large ORM model can exceed it. Worth checking before generating schemas programmatically.
1,000 enum values, 120,000 characters totalThe character budget covers all property names, definition names, enum values and constants together. Large category lists consume it quickly.
The constraints that change your design, rather than merely limiting it.

Optionality becomes nullability

This is the constraint people meet first and misdiagnose most often. You cannot mark a field optional and let the model omit it. Every field is required, so the way to express "this may not be present" is to allow null explicitly.

typescript
// Wrong: the field is simply absent from `required`, which the API rejects.
const bad = {
  type: "object",
  properties: {
    invoiceNumber: { type: "string" },
    dueDate: { type: "string" },      // "optional"
  },
  required: ["invoiceNumber"],
  additionalProperties: false,
} as const;

// Right: every field required, absence expressed as an explicit null.
const good = {
  type: "object",
  properties: {
    invoiceNumber: { type: "string" },
    dueDate: { type: ["string", "null"] },
  },
  required: ["invoiceNumber", "dueDate"],
  additionalProperties: false,
} as const;

// The consequence is in the application type, not only the schema:
type Invoice = {
  invoiceNumber: string;
  dueDate: string | null;   // not `dueDate?: string`
};

The distinction matters downstream. An absent key and a null value are different things in most type systems and in most databases, and code written against an optional property will not compile against a nullable one. Decide early which your domain actually means, because retrofitting it across a codebase is tedious.

The two paths where the guarantee does not apply

Both are documented, both are ordinary, and both arrive as successful HTTP responses. Neither raises an exception in any SDK, which is why they tend to be discovered in production rather than in tests.

Refusal

If the model declines a request on safety grounds, the response carries a refusal field rather than content matching your schema. The documentation frames this as making refusals programmatically detectable, which is exactly right — it is a feature, and it means the check is a branch rather than a parse failure.

Retrying a refusal with the same input is the reflex and it is the wrong move. The model refused deterministically enough that a retry mostly reproduces it, and each attempt costs a full request. This is the specific pattern that turns a handled edge case into a runaway bill.

Truncation

If generation hits the output token limit, the response status becomes incomplete with incomplete_details.reason set to max_output_tokens, and what you have is a JSON fragment. It will not parse, and no amount of repair logic recovers the data that was never generated.

Unlike refusal, this one is worth retrying — but only with a change. Raising the limit, or reducing what you asked for, addresses the cause. Retrying identically produces an identical truncation at identical cost.

Handling all three explicitly

The shape that works is a discriminated union returned by the call site, so that callers are forced by the type system to consider each path. Collapsing this into an exception loses the distinction precisely where the recovery strategies differ.

typescript
type Extraction<T> =
  | { status: "ok"; data: T }
  | { status: "refused"; reason: string }
  | { status: "truncated"; partial: string };

async function extract<T>(
  request: ExtractRequest,
  validate: (value: unknown) => T,
): Promise<Extraction<T>> {
  const response = await client.responses.create(request);

  // Refusal: a normal response, not an error. Do not retry unchanged.
  const refusal = response.output?.[0]?.content?.[0]?.refusal;
  if (refusal) {
    return { status: "refused", reason: refusal };
  }

  // Truncation: the JSON is a fragment. Retry only with a larger budget
  // or a smaller ask — an identical retry truncates identically.
  if (response.status === "incomplete") {
    if (response.incomplete_details?.reason === "max_output_tokens") {
      return { status: "truncated", partial: response.output_text ?? "" };
    }
  }

  // Conforming: the shape is guaranteed, the values are not.
  // `validate` is where domain rules the schema cannot express are applied.
  return { status: "ok", data: validate(JSON.parse(response.output_text)) };
}

The `validate` callback is doing real work rather than restating the schema. It is where constraints the schema language cannot carry are enforced — that a date falls in a plausible range, that a total equals the sum of its line items, that an identifier matches a pattern. Those checks catch the conforming-but-wrong case that the guarantee explicitly does not cover.

Common mistakes

  1. Treating schema conformance as a correctness check. It constrains structure only; a validating response can contain entirely fabricated values.
  2. Retrying refusals unchanged. Refusal is largely deterministic for a given input, so the retry reproduces it and bills you again for the privilege.
  3. Retrying truncation unchanged. Same output budget, same truncation point. Raise the limit or reduce the request.
  4. Modelling optional fields by leaving them out of required. The API rejects it. Express absence as an explicit null and propagate that through your types.
  5. Assuming a JSON Schema generated by a library will be accepted. Most emit allOf or conditional constructs that are unsupported, and generators targeting large models can exceed the property and character limits.
  6. Wrapping the whole call in one try/catch. Refusal and truncation arrive as successful responses; a catch block never sees them.

Security and performance considerations

A closed schema is a genuine security control and an underused one. Because additionalProperties is false and the field set is fixed, a model cannot introduce unexpected keys into a structure your application will consume — which removes an injection path when the output feeds something that iterates over keys, builds a query, or populates a template.

That protection stops at the values. A string field will faithfully contain whatever the model produced, including content drawn from untrusted input the model was asked to summarise. Structured output is not sanitisation, and a conforming string is still attacker-influenced text if any part of the prompt was.

On cost, the all-fields-required rule means the model emits every field on every call, including the nulls. Wide schemas therefore cost output tokens proportional to their width rather than to the information actually present, and output tokens are the expensive half on every major provider. Combined with unchanged retries against refusals, this is exactly the compounding described in why agent bills explode — individually reasonable calls, an unreasonable total.

Troubleshooting

  1. 200 OK with nothing parsed — check that strict mode is actually enabled. Without it the request succeeds and the conformance guarantee simply does not apply.
  2. API rejects the schema — look for allOf, if/then/else, or an optional field missing from required. Library-generated schemas produce all three routinely.
  3. Intermittent parse failures under load — almost always truncation. Check status and incomplete_details.reason before blaming the model.
  4. Everything null — the model is conforming while finding nothing. That points at the prompt or the input, not the schema.
  5. Works on short documents, fails on long ones — output budget rather than input. The extracted structure grew past max output tokens.
  6. Schema accepted in testing, rejected after adding categories — the 1,000 enum value or 120,000 character budget, which is shared across the whole schema rather than per field.

The useful mental model is that structured outputs move a whole class of problem from parsing to semantics. You stop writing defensive extraction code and start needing an answer to a harder question: is this well-formed object actually right? That question does not have a library, which is why the evaluation work matters more once the parsing problem disappears.

If you are putting model output into a system where a wrong value has consequences — billing, records, downstream automation — that boundary is worth designing rather than discovering. Our AI engineering work covers it, and the integration side, where model output meets an existing system, sits with our API engineering practice.

Frequently asked questions

Do structured outputs guarantee the model's answer is correct?
No. They guarantee the response conforms to your schema — the right keys, the right types, values drawn from your enums. The values themselves can be entirely wrong, and a fabricated figure in a correctly typed field passes validation cleanly. Schema conformance removes parsing bugs and removes none of the evaluation work needed to establish that the content is right.
Why does the API reject my optional field?
Structured outputs require every field to be listed as required and every object to set additionalProperties to false, so optionality cannot be expressed by omitting a key. Express absence as an explicit null in the type union instead. This changes your application types as well as the schema, because a nullable field and an optional property are different things in most type systems.
What happens when the model refuses a structured output request?
The response carries a refusal field rather than content matching your schema, which the documentation describes as making refusals programmatically detectable. It arrives as a successful response, so a try/catch never sees it. Retrying the same input is usually wasted spend, because refusal is largely deterministic for a given request and the retry reproduces it at full cost.
Why does my JSON sometimes fail to parse despite strict mode?
Almost always truncation. When generation hits the output token limit the response status becomes incomplete with reason max_output_tokens, and what you receive is a fragment rather than a document. No repair logic recovers data that was never generated, so the fix is a larger output budget or a smaller extraction, not a more forgiving parser.
Which JSON Schema features can I not use?
The composition keywords are unavailable: allOf, not, if, then, else, dependentRequired and dependentSchemas. Structural limits also apply — up to 5,000 object properties, 10 levels of nesting, 1,000 enum values, and 120,000 characters across all property names, definition names, enum values and constants combined. Schemas emitted by ORM or validation libraries commonly violate at least one of these.

References

  1. [1]Structured model outputs — API guide — OpenAI, accessed 8 August 2026
  2. [2]Introducing Structured Outputs in the API — OpenAI

Revision history

  1. First published. Schema subset, structural limits, and failure states verified against the OpenAI structured outputs guide on the same date.

Share this article

Auravon AI

Auravon AI Editorial

Newsletter

Get Practical Engineering Insights

Articles like this one, delivered to your inbox. No filler, no news roundups — just engineering practice.