Designing a JSON Schema an LLM Can Fill Correctly
5 min read · updated August 3, 2026
Two schemas can accept exactly the same set of documents and get different answers out of the same model. The type system is for your validator. The names, the order and the descriptions are for the model, and that is the half most people leave to whatever their ORM generated.
The schema is part of the prompt
Whatever the transport looks like, the schema reaches the model as text. Property names, description strings, enum members and $defs titles are all tokens in context, and they are read the same way your instructions are. A field called amt with no description is an instruction that says amt. A field called total_including_tax with the description “the grand total printed on the invoice, in the currency shown; not the subtotal” is a much better instruction, and it costs you thirty tokens.
It follows that the schema is where disambiguation belongs. Rules about a specific field placed in the system prompt are separated from that field by everything else in the request; in the description they are adjacent to the decision. Move them.
Field order is causal
JSON objects are unordered as a data model. Generation is not. Under constrained decoding the fields come out in the order the schema declares them, each conditioned on everything already emitted, so field order is a claim about what depends on what.
Three consequences you can use directly:
- Reasoning first. A
reasoningorevidencefield before the answer gives the model tokens to work in. The same field after the answer is a post-hoc rationalisation of a decision already made — it reads as justification and it cannot change the answer. - Evidence before value. Emit
source_quotebeforevalue, and the value is conditioned on a span the model has just committed to. Emit it after and it is conditioned on nothing it has to be consistent with. - Counts last. Ask for
item_countbefore the array and the model must commit to a number before it has produced the items, then live with it. Ask for it after and it is a free checksum you can assert on. This is the array-length bug in one line.
One caveat worth checking against current docs: Google’s Gemini schema support has historically exposed a propertyOrdering field precisely because ordering was not otherwise guaranteed. If ordering is load-bearing for you — and after the paragraph above it is — confirm how your provider treats it rather than assuming declaration order wins.
One schema, rewritten
Before — generated from a database table, which is where these usually come from:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"amt": { "type": "number" },
"dt": { "type": "string", "format": "date" },
"status": { "type": "string" },
"cust": { "type": "string" },
"meta": { "type": "object", "additionalProperties": true }
},
"required": ["id", "amt"]
}After:
{
"type": "object",
"additionalProperties": false,
"properties": {
"invoice_number_quote": {
"type": "string",
"description": "Copy the invoice number exactly as printed, including any prefix."
},
"invoice_number": {
"type": "string",
"description": "The invoice number with surrounding whitespace removed."
},
"total_including_tax": {
"type": "number",
"description": "Grand total printed on the invoice. Not the subtotal, not a line item."
},
"currency": {
"type": "string",
"enum": ["EUR", "GBP", "USD", "OTHER"],
"description": "The currency of total_including_tax. OTHER if it is none of these."
},
"issue_date": {
"type": "string",
"description": "Issue date as YYYY-MM-DD. If the document prints an ambiguous format such as 03/04/2026, use the order indicated elsewhere on the page; if that is not determinable, return \"unknown\"."
},
"payment_status": {
"type": "string",
"enum": ["paid", "unpaid", "partially_paid", "not_stated"],
"description": "What the document itself states. not_stated if it does not say."
},
"customer_name": {
"type": ["string", "null"],
"description": "Name of the party being billed. null if the document does not name one."
}
},
"required": ["invoice_number_quote", "invoice_number", "total_including_tax",
"currency", "issue_date", "payment_status", "customer_name"]
}The rules, and why each works
- Every field required; optionality via null. Not merely because strict modes demand it. An omitted field and a field the model could not find are indistinguishable to your code; an explicit
nullis a positive statement that the model looked. - Close every set you can.
statusas a free string invites"Paid","PAID","paid in full"and"settled". An enum makes four of those unreachable. - Give the escape hatch a name.
not_statedandOTHERexist so the model has somewhere honest to go. Without one, a closed enum forces a wrong pick — the constraint guarantees a member of the set is chosen, and if none is right it still chooses. - No free-form maps.
additionalProperties: trueis a hole in the type at run time and a hole in the instruction at generation time. Strict modes reject it anyway. - Put the format in the description, not the validator.
format: "date"is outside several providers’ supported keyword sets, so it may be dropped without complaint. “as YYYY-MM-DD” in the description reaches the model either way, and your validator enforces it afterwards. - Name the ambiguity you know about. The date description above tells the model what to do with
03/04/2026. Every extraction domain has three or four of these, they are the bulk of your real error rate, and each costs one sentence.
Do not extract into your database schema
The strongest single rule here. Your table has an id column, foreign keys, a created_at, normalised currency codes and a nullable column that means three different things depending on status. None of that is in the document, and asking the model to produce it makes it invent. Extract a document-shaped record — one field per thing that is literally on the page — and transform to your storage shape in code you can unit-test.
The two-field invoice_number_quote / invoice_number pair above is the same idea in miniature: the first is transcription, which models do well, and the second is a normalisation you can check against the first. When they disagree you have found a bad record without having any ground truth at all.
The same rule explains a mistake that looks like good design: computing in the schema. Asking for a days_overdue integer, a total_in_eur converted from another currency, or an is_high_value boolean makes the model do arithmetic it is mediocre at, and buries the inputs so you cannot check the result. Ask for the due date, the amount and the currency as printed; compute the rest in code that has tests. The schema’s job is to describe the document, and every field in it that is not on the page is a field somebody will have to explain later.