LLM Structured Output: JSON Schema That Actually Validates

Photo by deepakiqlect on flickr
Prompting for JSON asks the model to follow instructions, but nothing stops it from adding stray text, wrapping the object in a markdown fence, or dropping a required field. Structured outputs compile your JSON Schema into a grammar that restricts which tokens the model can generate at each step, so the response is mechanically constrained to match the schema rather than merely encouraged to.
The most common cause is skipping the stop reason or finish reason check before parsing. If the model refused the request or the response was cut off by a max tokens limit, the text is not a complete JSON object no matter how tight the schema was, and calling JSON parse on it will throw. Always branch on the stop reason first, and only attempt to parse a response that completed normally.
Yes, and it is one of the most useful patterns available. A single Zod schema can be converted into the JSON Schema you send to the model and used again with safeParse to validate the response that comes back. This keeps both ends of the round trip defined in one place and gives you a typed object plus a structured error list if validation fails.
Use JSON output mode when the entire response is a single structured document, such as an extracted invoice or a generated report section. Use strict tool or function calling when the model is choosing between multiple possible actions in an agent loop, since each action can have its own argument schema. Both provide the same schema guarantee; the choice depends on what the response represents.
No. Schema constraints guarantee the shape and types of the response, not the business correctness of the values inside it. A model can return a syntactically perfect object with a negative quantity or a swapped field. You still need a second validation pass, such as a Zod refine function or a database lookup, for rules that span multiple fields or depend on external state.

Photo by deepakiqlect on flickr
The first time a production job crashed on a stray markdown fence wrapped around an otherwise perfect JSON payload, I learned the hard way that asking a language model nicely for JSON is not the same as receiving JSON you can parse. The model followed the instructions. It just also added a friendly sentence before the object, because nothing forced it not to.
This post walks through the parts that actually make structured output reliable: constrained decoding through JSON Schema, choosing between direct JSON output and tool-based function calling, detecting refusals and truncation before they corrupt your pipeline, and validating every response with Zod so a schema violation never reaches your database.
Prompt engineering can get you close, but close is not a guarantee. Even a well-crafted system prompt that says respond only with valid JSON matching this shape will occasionally produce output that technically satisfies the instruction while breaking every downstream parser you have. The failure modes are predictable once you have seen them a few times.
None of these are bugs in the model. They are the natural result of treating a probabilistic text generator as if it were a deterministic serializer. If your system depends on the shape of the response, you need a mechanism that constrains the generation itself, not just a politely worded request.
Both the Claude API and the OpenAI API now offer real structured output modes that restrict token generation to only the tokens a valid schema allows. This is fundamentally different from prompting for JSON and hoping. Reach for the native feature before you reach for regex cleanup on the response text.
Constrained decoding means the API compiles your JSON Schema into a grammar and restricts which tokens the model is allowed to emit at each step of generation. If your schema says a field must be one of three enum values, the model is mechanically prevented from producing a fourth. This is enforced at the sampling layer, not by post-hoc validation, so there is no window where the model could have said something else and slipped past a lenient check.
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const InvoiceLine = z.object({
sku: z.string(),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative(),
});
const InvoiceExtraction = z.object({
vendorName: z.string(),
invoiceNumber: z.string(),
lines: z.array(InvoiceLine),
totalCents: z.number().int().nonnegative(),
});
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: rawInvoiceText }],
output_config: { format: zodOutputFormat(InvoiceExtraction) },
});Claude exposes this through two complementary mechanisms: a JSON output format you attach to a message request, and strict tool use where a tool's input schema is enforced the same way. OpenAI's equivalent is the strict flag on response_format with a json_schema type, which comes with a firm rule: every property must be listed as required, and additionalProperties must be explicitly set to false. You cannot simply omit an optional field from the required list the way you might in a hand-rolled JSON Schema; the idiomatic workaround is to make the field's type nullable instead.
Both providers give you two shapes for the same guarantee, and the right one depends on what the response represents in your application, not on which one feels more familiar.
| Approach | Best for | Watch out for |
|---|---|---|
| JSON output mode | A single structured document as the entire response, like an extracted invoice or a generated report section | First-request latency while the schema compiles into a grammar; both providers cache the compiled schema for reuse |
| Strict tool or function calling | Agent loops where the model chooses between several possible actions, each with its own argument shape | You need a distinct tool definition per action type, and the model must still decide which tool to call correctly |
| Prompt-only JSON, no schema constraint | Throwaway prototypes and manual experimentation only | No structural guarantee whatsoever; never ship this path to a production system that parses the output |
A schema constraint guarantees the shape of a completed response. It says nothing about whether the response actually completed, or whether the model declined to answer at all. Both providers give you signals for this, and skipping them is the most common way constrained output still breaks in production.
A schema-constrained response can still fail to parse if you skip the stop reason check. A refusal or a max-tokens cutoff produces text that is not a complete JSON object, and calling JSON parse on it will throw regardless of how tight your schema was. Always branch on the stop reason first.
Schema-constrained generation makes malformed JSON rare, not impossible, and it says nothing about whether the values are the ones you actually wanted. A model can return a syntactically perfect object with a negative quantity or an invoice number that is really a customer name. Treat the constrained response the same way you would treat any external input: parse it, then validate it with a real schema library before it touches persistence.
const parsed = InvoiceExtraction.safeParse(
JSON.parse(response.content[0].text)
);
if (!parsed.success) {
logger.warn("schema_validation_failed", {
issues: parsed.error.issues,
});
throw new ExtractionValidationError(parsed.error);
}
// parsed.data is now fully typed and safe to persist
await saveInvoice(parsed.data);Running the same shape through Zod's safeParse gives you two things a raw JSON parse cannot: a typed object your TypeScript code can trust downstream, and a structured issues array you can log or return to the caller instead of a generic parsing exception. If you are already using a Zod schema to build the JSON Schema sent to the model, as shown in the earlier example, you get this validation step almost for free since the same schema definition does double duty.
JSON Schema and Zod both validate the shape and type of a single response. Neither one can express relationships between fields that only make sense together, or facts that live outside the response entirely. Plan for a second validation pass for cases like these.
Because Zod schemas can generate the JSON Schema sent to the model and validate the response that comes back, a single TypeScript definition covers both ends of the round trip. That symmetry is worth designing around from the start of a project rather than bolting on after the fact.
Before trusting a schema in production, test it against adversarial prompts: ambiguous input, requests that should trigger a refusal, and inputs deliberately close to your token limit. A schema that only ever saw clean happy-path input during development will surprise you the first week it meets real users.