Get valid JSON from LLMs every time with structured outputs
Stop parsing broken JSON from LLMs. How structured outputs work on the Claude and OpenAI APIs, the schema rules that cause 400s, and how to handle refusals.
Updated 8 min read

In this article
If your LLM integration breaks because the model returned a stray sentence, a missing field or a trailing comma, stop fixing it with regular expressions and retries. Both the Claude API and the OpenAI API now offer structured outputs: you send a JSON Schema and the model's output is constrained to match it. Turn it on, keep your schema inside the supported subset, and handle the two cases where it can still fail: refusals and truncated output.
This guide explains the usual causes of broken JSON, shows the current request shapes for both providers (checked against their documentation in October 2026), and lists the schema rules that trip people up.
Why "please reply in JSON" keeps failing
Asking for JSON in the prompt works most of the time, which is exactly why it is dangerous: the failures show up in production, not in testing. The common symptoms are:
- Prose around the JSON. "Here is the result:" followed by a code fence, which
JSON.parserejects. - Valid JSON, wrong shape. A field renamed (
order_idinstead oforderId), a value outside your enum, or an extra key your code does not expect. - Truncated output. The response hit the token limit halfway through an array.
- A refusal. The model declined the request and wrote an explanation instead of data.
Structured outputs remove the first two classes of failure. The last two still need code, and they are covered below.
How structured outputs work
With structured outputs, the provider compiles your JSON Schema into a grammar and uses it to constrain generation, so the model can only produce tokens that keep the output valid against the schema. This is different from older JSON mode, which guarantees syntactically valid JSON but not your schema. OpenAI's guide says it directly: both produce valid JSON, but only Structured Outputs adheres to your schema, and it recommends Structured Outputs whenever possible.
Two practical consequences follow from the grammar step. The first request with a new schema is slower while it compiles, and later requests reuse it. Anthropic documents that compiled grammars are cached for 24 hours from last use, and that changing the schema structure invalidates the cache while changing only a name or description does not.
Claude API: output_config.format
On the Claude API, structured outputs are generally available and need no beta header. You pass a schema in output_config.format with "type": "json_schema", and the JSON arrives in the text block of the response. The older output_format parameter is deprecated and only works with the structured-outputs-2025-11-13 beta header, so new code should use output_config.
{
"model": "claude-haiku-5-5",
"max_tokens": 1024,
"messages": [{ "role": "user", "content": "Order 4471 arrived broken, please refund." }],
"output_config": {
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["billing", "bug", "feature_request", "other"] },
"orderId": { "type": ["string", "null"] },
"summary": { "type": "string" }
},
"required": ["category", "orderId", "summary"],
"additionalProperties": false
}
}
}
}In TypeScript the SDK can build that schema from Zod and validate the response for you. messages.parse() returns a typed parsed_output:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const Ticket = z.object({
category: z.enum(["billing", "bug", "feature_request", "other"]),
urgency: z.enum(["low", "normal", "high"]),
orderId: z.string().nullable(),
summary: z.string(),
});
const client = new Anthropic();
export async function triage(email: string) {
const response = await client.messages.parse({
model: "claude-haiku-5-5",
max_tokens: 1024,
system: "Classify the support email. Use null when no order ID is present.",
messages: [{ role: "user", content: email }],
output_config: { format: zodOutputFormat(Ticket) },
});
if (response.stop_reason === "refusal") throw new Error("Model refused");
if (response.stop_reason === "max_tokens") throw new Error("Output truncated; raise max_tokens");
return response.parsed_output; // typed as z.infer<typeof Ticket>
}In Python the equivalent is client.messages.parse(..., output_format=YourPydanticModel), which returns response.parsed_output. Structured outputs on Claude also work with streaming and the Message Batches API, and they can be combined with strict tool use ("strict": true on a tool definition) in the same request.

OpenAI Responses API: text.format
On OpenAI's Responses API the schema goes in text.format with type: "json_schema", a name and strict: true. The JavaScript SDK has a Zod helper, and responses.parse() exposes the result as output_parsed:
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";
const Ticket = z.object({
category: z.enum(["billing", "bug", "feature_request", "other"]),
urgency: z.enum(["low", "normal", "high"]),
orderId: z.string().nullable(),
summary: z.string(),
});
const openai = new OpenAI();
export async function triage(email: string) {
const response = await openai.responses.parse({
model: "gpt-6-astra",
input: [
{ role: "system", content: "Classify the support email." },
{ role: "user", content: email },
],
text: { format: zodTextFormat(Ticket, "ticket") },
});
if (response.status !== "completed") {
throw new Error(`Incomplete: ${response.incomplete_details?.reason}`);
}
return response.output_parsed;
}In Python, pass text_format=YourPydanticModel to client.responses.parse(). A refusal appears as a separate output item of type refusal rather than as data, so check for it before you use the parsed value.
Schema rules that cause most 400 errors
Neither provider accepts the whole JSON Schema specification. The rules below come from each provider's documentation; an unsupported schema is rejected with an error rather than silently ignored.
| Rule | Claude API | OpenAI (strict) |
|---|---|---|
additionalProperties | Must be false on objects | Must be false on every object |
| Optional fields | Supported via required | Every field must be in required; use a union with null for "absent" |
Numeric limits (minimum, maximum) | Not supported | Supported, except on fine-tuned models |
String length (minLength, maxLength) | Not supported | Supported, except on fine-tuned models |
| Composition | anyOf; allOf without $ref | anyOf (not at the root); no allOf, not, if/then/else |
| Size limits | 20 strict tools, 24 optional parameters, 16 union-type parameters per request | 5,000 properties, 10 levels of nesting, 1,000 enum values |
Two habits avoid most surprises. First, model "unknown" as null rather than as an omitted key: it works on both providers and forces the model to make an explicit choice. Second, keep business rules such as "summary under 200 characters" out of the grammar and in your own validation. The Anthropic TypeScript SDK's schema transform does something similar for you: constraints the API cannot enforce are moved into the field's description as hints, and parse() then validates the response against your original Zod schema.
The two failures structured outputs cannot prevent
Refusals
If the model declines a request for safety reasons, Claude returns stop_reason: "refusal" with HTTP 200, you are billed for the tokens, and the output may not match your schema. Anthropic also notes that a schema property asking for step-by-step reasoning can itself trigger a refusal, so ask for a short explanation field instead. On OpenAI, look for the refusal output item. In both cases, log the event and route the request to a fallback path rather than parsing it.
Truncation
If generation stops at the token limit, the JSON may be incomplete. Claude reports stop_reason: "max_tokens"; OpenAI sets status to "incomplete" with the reason max_output_tokens. OpenAI's guidance is to parse only when the status is "completed". The fix is usually to raise the limit or reduce the output: return IDs rather than full records, or paginate large extractions.

Keep a validation layer anyway
Structured outputs make the shape reliable, but your code should still validate at the boundary. You may run the same parser against a provider or local model that only offers JSON mode, a fallback path might bypass the SDK helper, and the schema sent to the model may be looser than your real business rules. A small wrapper costs nothing:
import { z } from "zod";
const Ticket = z.object({
category: z.enum(["billing", "bug", "feature_request", "other"]),
urgency: z.enum(["low", "normal", "high"]),
orderId: z.string().nullable(),
summary: z.string().max(200),
});
export function parseTicket(text) {
let data;
try {
data = JSON.parse(text);
} catch {
return { ok: false, error: "not JSON" };
}
const result = Ticket.safeParse(data);
return result.success
? { ok: true, value: result.data }
: { ok: false, error: z.prettifyError(result.error) };
}Given {"category":"refund",...}, this returns ok: false with the message "Invalid option: expected one of ... at category", which is precise enough to log, alert on, or feed back to the model in a single retry.
Limitations and gotchas
- Feature conflicts on Claude. JSON outputs cannot be combined with citations (the API returns a 400) or with assistant message prefilling.
- Changing the format breaks prompt caching. Anthropic notes that changing
output_config.formatinvalidates the prompt cache for that conversation thread, so keep one schema per conversation where you can. - No sensitive data in schemas. Anthropic's documentation warns that compiled schemas are cached separately from message content, so do not put personal or health data in property names, enum values or patterns.
- Model support varies. Check each provider's supported-model list before switching models; older OpenAI models fall back to JSON mode only.
- Correct shape is not correct content. A perfectly typed
categorycan still be the wrong category. Measure accuracy with an evaluation set, not just parse rates.
What to do next
- Find every place your code calls
JSON.parseon model output and list the schema it expects. - Move each one to structured outputs, starting with the call that fails most often in your logs.
- Add explicit handling for refusals and truncation, with metrics for both.
- Keep the Zod or Pydantic validation at the boundary and delete the regex clean-up code.
Frequently asked questions
Do I still need a beta header for Claude structured outputs?
No. Anthropic lists structured outputs as generally available on the Claude API. Use
output_config.format; the olderoutput_formatparameter is deprecated and only works with thestructured-outputs-2025-11-13beta header.What is the difference between JSON mode and structured outputs?
JSON mode guarantees syntactically valid JSON. Structured outputs also guarantee that the JSON matches the schema you supply, which is why OpenAI recommends it whenever possible.
How do I make a field optional with OpenAI structured outputs?
In strict mode every field must be listed in
required. Give the field a union type with null, such as["string", "null"], and treat null as "absent".Can structured outputs still return invalid JSON?
Yes, in two cases: a refusal (Claude
stop_reason: "refusal", or an OpenAI refusal item) and truncation at the token limit (max_tokenson Claude, statusincompleteon OpenAI). Check for both before parsing.Why is my first structured-output request slow?
The provider compiles your schema into a grammar on first use. Anthropic caches compiled grammars for 24 hours from last use; OpenAI says later requests with the same schema avoid the delay.
Can I use minLength or maximum in my schema?
Not on the Claude API, and not on OpenAI fine-tuned models. Enforce those rules in your own validation after parsing; the Anthropic TypeScript SDK moves such constraints into field descriptions as hints.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Structured outputs — Anthropic
- Structured model outputs — OpenAI
- Models overview — Anthropic
- Models — OpenAI
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone

TutorialJavaScript
JavaScript using declarations: automatic cleanup without finally
Learn JavaScript's using and await using declarations, Symbol.dispose and DisposableStack, with tested browser recipes and October 2026 support data.
By Cloudsoft Zone

Architecture guideJavaScript
Ship JavaScript without a bundler: import maps and JSON modules
How to structure a front end as native ES modules: import maps for bare imports, JSON modules, modulepreload against waterfalls, and cache-busting.
By Cloudsoft Zone
