The Schema Is the Contract: Making an LLM Return Valid Data

August 19, 2026

5 min read

One schema, both ends: it feeds the model and checks the output, catching malformed data at the boundary

You ask a model for a structured record and it hands you back something that looks right. An object, the fields you asked for, plausible values. Then you feed it to the next step and it falls over, because total came back as the string "1,299.00" instead of a number, or issued is the phrase "last Tuesday" instead of a date, or the whole thing is wrapped in a markdown fence with a sentence of preamble the model decided you needed.

The answer was fine. The shape was wrong. And a wrong shape downstream is indistinguishable from a wrong answer: both break the step that consumed it.

So the rule I work by is that the schema is the contract. Not a suggestion in the prompt, not something I eyeball after the fact. The same schema goes into the prompt to tell the model what it owes me, and validates the response on the way out to confirm it paid. A malformed or off-type answer fails a real check at the boundary instead of quietly poisoning everything after it.

Schema In, Schema Out

I define the shape once, as a Zod schema, and use it at both ends. In my own LLM client the prompt is a template with a {schema_description} slot, and I render the schema into it so the model sees the exact structure it has to return. The same schema object then parses the response.

const Invoice = z.object({
  number: z.string().describe("invoice number exactly as printed"),
  issued: z.string().describe("issue date, as written"),
  currency: z.string().describe("ISO 4217 code, e.g. USD"),
  lineItems: z.array(z.object({
    description: z.string().describe("the line item text as written"),
    quantity: z.number().describe("units, as an integer"),
    unitPrice: z.number().describe("price per unit, numeric only, no symbol"),
  })).describe("one entry per line on the invoice"),
  total: z.number().describe("grand total, numeric only"),
});

const prompt = template.replace("{schema_description}", describeSchema(Invoice));
const raw = await model.complete(prompt);
const result = Invoice.safeParse(extractJson(raw));

describeSchema is mine, and it deliberately doesn't emit JSON Schema. It walks the Zod object, prints one line per field with the .describe() text as an inline comment, and expands nested objects and arrays in place:

- number: string [REQUIRED] // invoice number exactly as printed
- issued: string [REQUIRED] // issue date, as written
- currency: string [REQUIRED] // ISO 4217 code, e.g. USD
- lineItems: array<object {
- description: string [REQUIRED] // the line item text as written
- quantity: number [REQUIRED] // units, as an integer
- unitPrice: number [REQUIRED] // price per unit, numeric only, no symbol
}> [REQUIRED] // one entry per line on the invoice
- total: number [REQUIRED] // grand total, numeric only

That is the entire contract the model sees, guidance included. The usual move is to hand it JSON Schema instead. Here is what zod-to-json-schema actually emits for the same object, untouched:

{
  "type": "object",
  "properties": {
    "number": {
      "type": "string",
      "description": "invoice number exactly as printed"
    },
    "issued": {
      "type": "string",
      "description": "issue date, as written"
    },
    "currency": {
      "type": "string",
      "description": "ISO 4217 code, e.g. USD"
    },
    "lineItems": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "description": {
            "type": "string",
            "description": "the line item text as written"
          },
          "quantity": {
            "type": "number",
            "description": "units, as an integer"
          },
          "unitPrice": {
            "type": "number",
            "description": "price per unit, numeric only, no symbol"
          }
        },
        "required": [
          "description",
          "quantity",
          "unitPrice"
        ],
        "additionalProperties": false
      },
      "description": "one entry per line on the invoice"
    },
    "total": {
      "type": "number",
      "description": "grand total, numeric only"
    }
  },
  "required": [
    "number",
    "issued",
    "currency",
    "lineItems",
    "total"
  ],
  "additionalProperties": false,
  "$schema": "http://json-schema.org/draft-07/schema#"
}

Same information. Fifty-seven lines and 1,330 characters, against nine lines and 504 for the version above it. Most of the extra is braces, quotes, and a required array the model has to cross-reference against the properties. The version I ship is the short one.

One definition drives the instruction and the check. They can't drift apart, because they're the same object. When safeParse fails, I have the answer at the boundary, before the bad record reaches anything that would trust it.

Fix It in Code First, Reprompt Last

Validation catching a bad object is only half the value. The other half is what you do with the failure, and reaching straight for a reroll is usually the wrong first move.

The first repair happens before Zod ever sees the object. extractJson strips the markdown fences and pulls the object out of whatever prose the model wrapped around it, because a fenced block with a sentence of preamble is the most common malformed shape there is, and it is pure text handling. Only what survives that reaches safeParse.

Weaker models get the shape wrong more often. A small open model will confidently return "1,299.00" where the schema wants a number, or an ISO string where you asked for a bare year. Mistral Small does this to me regularly. The model understood the task and simply typed the field wrong.

Most of those never needed the model. A comma in a number is a replace and a parse. An ISO timestamp you wanted as a year is a slice. Zod does it inline: a z.coerce, a .transform(), or a preprocess step normalizes the value as it passes through, so the malformed shape becomes the right shape without spending a token. The schema that caught the error is the same place you repair it.

The model reprompt is the fallback, for when the deterministic fixes run out. When a value is genuinely wrong or ambiguous rather than just mistyped, I hand the model back its own output plus the exact validation error and ask for a corrected object: here is what you gave me, here is the field that failed and why, fix it. Reroll last, once code has nothing left to try.

Let the Decoder Enforce It Where It Can

Reprompting is a recovery move. Some providers let you prevent the error instead. Constrained or structured decoding restricts what the model is even allowed to emit, token by token, so the output conforms to your schema by construction. Where a provider supports it, I use it, because a class of malformed output stops being possible rather than being caught after the fact.

It isn't available everywhere, and it isn't free of tradeoffs, which is why the validate-and-repair path stays the backbone. I keep both because the check is the thing that's true across every model I run.

Index Labels Instead of Invented Keys

When a prompt returns a set of items, a batch extraction, or a merge that reconciles several into one, I don't let the model make up an identifier for each. I hand it a labeled list, and the schema asks for those labels back, keys: string[], not a free id field.

[l1: "2 boxes letter-size, rush"]
[l2: "invoice, net 30, no rush"]

The model returns l1, l2, and the fields for each. The point is a less faulty response. Ask for an id and a model will cheerfully fabricate a UUID for a row that never had one; constrain the identifier to labels you assigned and it references yours instead of inventing. The response gets smaller too, because it echoes the label, not a copy of the input text you already have. Fewer hallucinated keys and a smaller bill, from making the identifier part of the contract.

What the Contract Buys You

Two things follow directly from treating the schema as the contract.

The first is portability. When the contract lives in the schema, not in prose you tuned for one model's quirks, most of the prompt travels. It's most of what let me move an extraction task from 4o-mini to Mistral Small to Qwen3 4B. A weaker model sometimes needs the prompt tightened, or a different strategy, to hold accuracy, but the shape I get back never moves, because the schema is the part that doesn't change. The swap is a tuning job, not a rewrite, and that is what makes comparing models cheap enough to actually do.

The second is already on screen, up in that first output. Those // comments are the .describe() text, and they ride into the prompt right beside each field. I'm not keeping a schema in one place and a wall of prose about how to fill it in another. The note on unitPrice, "numeric only, no symbol," is the reason it comes back a number and not "$4.00". The schema is the output contract and the field-by-field instructions at once.

This is the fourth of four posts that sit together: whether the field needs a model at all, which model to reach for, how to feed it, and this one, what to demand back.

None of this makes a model deterministic. It stays the part of the pipeline that can surprise you. What the schema does is put a hard edge around the surprise: the output is either the shape you specified or it's a caught error you can repair, and either way nothing downstream ever has to trust that the model behaved.