Chapter 2 of 7 · 15 min read
Field descriptions versus system prompts
On this page
Every field in a JSON Schema can carry a sentence of prose under the key
description, and in a request to a model that sentence is part of the program
rather than a note about it. The schema is serialised into the request body — as
a tool definition, a response format, or whatever the provider calls it — and the
descriptions travel with it, so they are input the model conditions on in the
same sense the prompt is. That makes the placement of every field-level rule a
real decision: the same sentence about date formats can live in the system prompt
or on the field, and those two are not equivalent.
The claim here is that a rule governing one field belongs on that field, and the
evidence is a before-and-after on the same model, the same temperature and the
same documents. A contract-extraction service carried
All dates must be output in ISO 8601 format (YYYY-MM-DD). as bullet thirty-one
of a 1,900-token system prompt, and over 500 contracts contract_effective_date
came back in ISO form 91 percent of the time. The rule was deleted from the
prompt and the six date fields in the schema each grew a description:
{
"contract_effective_date": {
"type": "string",
"description": "The date the contract takes effect, as YYYY-MM-DD. If the contract says it takes effect on signature, use the signature date. Empty string if no date can be determined."
}
}Non-ISO output on the same 500 contracts fell to 0.4 percent — two responses, both of them contracts with no date at all where the empty-string clause had not been read as applying. The total instruction text got slightly longer rather than shorter. What changed is where the instruction was standing when the model needed it.
Both percentages are easier to hold as counts, and the counts are what make the comparison mean anything. Nine percent of 500 contracts is 45 responses that came back in some other date format. Nought point four percent of the same 500 is two. The denominator is identical, the documents are identical, and the same six fields were being asked for in both runs, so what separates the two figures is 43 responses that changed behaviour and nothing else about the setup. It is also worth noticing what the smaller number makes possible: at 45 you sample and generalise, and at two you read both of them, which is how the surviving pair were characterised precisely enough to name the clause they had missed.
A description is executable, which is not what the field is used for elsewhere
The description key is borrowed from JSON Schema and from OpenAPI, the
specification format in which HTTP services publish the shape of their endpoints,
and in both of those it has one job: it is documentation. A validator ignores it,
a code generator turns it into a docstring, and at runtime it is stripped out
entirely. Nothing you write in an OpenAPI description has ever changed the
behaviour of a running system, which is why engineers inherit the habit of
writing them last, or not at all, and of treating them as the place for notes to
future maintainers.
In a request to a model, the same key is part of what is executed. Here is what actually leaves the client:
{
"model": "vendor-mid-2026-02",
"messages": [
{ "role": "system", "content": "Extract the fields from the contract." },
{ "role": "user", "content": "<contract text>" }
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "contract_extract",
"strict": true,
"schema": {
"type": "object",
"properties": {
"contract_effective_date": {
"type": "string",
"description": "The date the contract takes effect, as YYYY-MM-DD. ..."
},
"governing_law_jurisdiction": {
"type": "string",
"description": "The jurisdiction named in the governing law clause, as written. Empty string if no such clause exists."
}
},
"required": ["contract_effective_date", "governing_law_jurisdiction"],
"additionalProperties": false
}
}
}
}Everything in that schema object is input the model conditions on, billed as
input tokens, and cached along with the rest of the prefix if you are using a
prompt cache, the provider-side mechanism that stores an unchanged leading
section of a request and bills it at a reduced rate on subsequent calls. There is
no separate channel where the shape goes and the prose does not. A description is
therefore an instruction with a delivery address attached, and the address is the
difference that matters.
Why bullet thirty-one loses to a sentence attached to the field
Generation is sequential, so at the moment the decoder is choosing the first
token after "contract_effective_date":, two things are true. The description for
that field is bound to the key that was just emitted, so no work is needed to
establish that it applies. Bullet thirty-one is 1,400 tokens back in a list of
forty rules, most of which govern other fields, so applying it requires the model
to scan that list and decide which of the forty are in scope for the field
currently being written.
That scan is a retrieval problem, and its error rate is not constant. With four bullets governing four fields it is nearly free. With forty bullets governing forty fields it has to be re-resolved forty times per response, once for each field, and each resolution is an opportunity to pull in a rule meant for a neighbour or to miss one that applied. The failure this produces is characteristic: rules near the top and bottom of a long list are followed more reliably than rules in the middle, and rules whose wording resembles several fields get applied to the wrong one. The nine percent non-ISO rate above was not the model being unable to write an ISO date; it was bullet thirty-one not being in scope at the instant the value was written.
That growth is worth being precise about, because it is linear in neither number. Four bullets governing four fields means each field is screened against four candidate rules, which is sixteen pairings across a response. Forty bullets governing forty fields is forty screenings repeated forty times, which is sixteen hundred pairings, and each one is a chance to bind a rule to the wrong field or to skip one that applied. Doubling the size of a schema therefore does not double the work of applying its prompt; it roughly quadruples it, because the number of values to be written and the number of rules each value must be screened against grow together. That is why a prompt behaving impeccably at six rules degrades at forty rather than degrading proportionally along the way, and why the degradation tends to arrive as a surprise.
The objection that usually arrives at this point is that the model reads the whole context anyway, so location cannot matter. It reads the whole context in the sense that every token is available to attend to, and that is the part of the claim that is true. What it does not follow from is that every token is equally likely to be attended to at a given step, or that the model has correctly worked out which of forty rules governs the key it is currently writing. Availability is not application. The 500-contract measurement is the cheap way to settle the argument in your own codebase: move the rules for one field group, hold everything else fixed, and count.
Attachment matters independently of distance. Even a short system prompt with six
rules requires the model to bind each rule to a field by name, and names in prose
are approximate — "all dates" has to be matched against contract_effective_date,
termination_date, renewal_notice_deadline and last_amended, one of which is
not obviously a date at all. A description on the field skips the binding step
entirely, because the schema has already done it.
The rule, and where it stops
The rule a new hire can apply without judgement is that a rule governing exactly one field lives on that field, and everything else lives in the prompt. It is worth stating the second half properly, because "put everything in descriptions" is the overcorrection and it is nearly as bad.
| Kind of rule | Example | Where it goes | Why |
|---|---|---|---|
| Field format or unit | dates as YYYY-MM-DD | description on the field | in scope exactly when the value is written |
| Field absence policy | empty string when no clause exists | description on the field | the boundary is field-specific |
| Field boundary or tie-break | header figure wins over line items | description on the field | the disambiguation is local |
| Cross-field consistency | end_date must not precede start_date | system prompt, and validated in code | no single field owns it |
| Task framing | "these are UK commercial leases" | system prompt | applies to every field equally |
| Output-wide policy | "never infer facts not in the document" | system prompt | applies to every field equally |
| Anything a validator can check | IBAN checksums, enum membership | schema constraint, not prose | prose has a failure rate; constraints do not |
The last row is the one that gets argued about, and it is the boundary between
this chapter and the material of topic 5.2, on constrained decoding. A
description is applied by the model. A pattern, an enum, an integer bound, or
a required-key list is applied by the decoder, which cannot emit a token that
would violate it. Writing must be a valid ISO 4217 currency code in a
description gets you the right answer most of the time; declaring an enum of the
eleven currencies you actually handle gets it every time. When a description says
something the schema could have said, the description is a weaker version of an
available constraint, and chapter 4 works through what that difference costs in
practice, comparing an enum against a description that lists the same values.
There is a version of this that goes wrong in the opposite direction, where the
prose asks for something the constraint forbids. A severity enum of
low | medium | high with the description
use urgent for anything that has taken the site down describes a value the
decoder can never emit. The model does not error and does not tell you; it picks
whichever legal member the instruction leans towards, usually high, and the
distinction the description was reaching for is gone. Ambiguous or over-reaching
prose does not soften a constraint, it just bends the choice within it, and the
result is a field whose behaviour cannot be predicted from either the schema or
the description alone.
The reason it bends rather than breaks is that nothing is checking. A pattern, an
enum or a required-key list is evaluated by the decoder, which can refuse a token.
A description is evaluated by nothing at all: no component in the request path reads
the prose, compares it against the constraint declared on the same field, and reports
that the two disagree. So an instruction naming an impossible value does not fail. It
is applied, as a pull, to a distribution over the values that remain possible, and the
pull lands on whichever legal member the wording most resembles. The absence of an
error is not evidence that a description was consistent with its schema. It is
evidence that no part of the system was ever positioned to notice.
What actually happens when a bullet and a description disagree
This is the question worth being precise about, because the intuitive answers — "the more specific one wins", "the later one wins", "the system prompt wins because it is a system prompt" — are all wrong in the same way. They assume the conflict is resolved once, globally, and that the response will be consistent with whichever side won.
The third of the three deserves taking seriously rather than dismissing, because a real observation sits behind it. Providers do treat the system role differently: it is documented as the place for standing instructions, it is trained to be harder to override from user text, and an engineer who has read that infers, reasonably, that there is a precedence order somewhere in the machinery. There is not. What exists is a weighting learned during training, which makes a system message a strong prior rather than a rule with authority over other text. Nothing in the request path compares two instructions, decides which governs, and discards the loser — there is no evaluator, no error, and no record that a conflict occurred. There is a model producing one token at a time with both texts in its context, and at each field it produces whichever value the two of them together make likeliest. Per-field resolution is not a quirk of this example; it is the only thing the mechanism is capable of.
What happens is that the conflict is resolved per field, at generation time, and
the result is a response that is internally inconsistent. A team that inherited a
40-field schema, with half the rules in a system-prompt list and half in
descriptions, had a missing-value contradiction between the two. Bullet nine said
Use an empty string for any value not present in the document. Eighteen of the
forty fields had descriptions ending in null if not stated. Across 300
documents, the eighteen fields with descriptions returned null for absence 96
percent of the time. The twenty-two without returned "". Nobody had shipped a
bug, exactly, and the response validated every time, but the consumer had to
handle both sentinels on a per-field basis, and the mapping of which field used
which was written nowhere except in the schema's description keys.
Those two counts add to the forty fields in the schema, and that is the finding rather
than a coincidence. No rule anywhere decided which sentinel a field would use. There
was a bullet applying to all forty, a description applying to eighteen, and the
eighteen were whichever fields somebody had happened to write a sentence for at some
point. So the effective contract — what a consumer has to test for in order to detect
absence — was settled by the edit history of a schema file rather than by any decision
about missing values, and the only place that settlement is recorded is which fields
have a description key and which do not.
That is the real cost of splitting the rules across two places. The first-order problem is that one of the two rules loses. The second-order problem, and the expensive one, is that neither loses cleanly, so the schema's effective behaviour is a per-field function of where its rules happen to live. You cannot diff it, you cannot review it as a unit, and a change to the system prompt silently changes the behaviour of exactly those fields that have no description of their own.
The defence of consolidating into descriptions is therefore not that descriptions
are magic. It is that a field-scoped rule stored on the field can be reviewed
with the field, changed with the field, and tested with the field, and that a
forty-bullet list has to be re-resolved against every field it might govern each
time the model writes one. Schema Design • Prabal makes the same case from a
Pydantic angle — Pydantic being the Python library that turns an annotated class
into the JSON Schema the request carries — and is worth reading if your schemas
are defined in Python, where descriptions live in Field(description=...) and
are easy to leave empty.
Two practical limits keep the rule from becoming "write as much as you like on every field". The first is length. A description of one or two sentences is applied as a unit; a description of 120 words with six clauses and three exceptions reproduces the bullet-list problem inside a single field, because the model now has to work out which of its clauses is in scope for the value it is writing. If a field genuinely needs six rules, that is usually evidence the field is doing two jobs and should be two fields, which is chapter 5's territory, on enums that answer two questions at once. A useful ceiling in practice is about 40 words: the format, one example, and the absence case.
The second is cost, and it is smaller than people fear. Forty fields with 25-token descriptions is 1,000 input tokens per request, which at $3 per million is $0.003 a call — three dollars per thousand calls, irrelevant against the price of a retry, let alone the price of a wrong extraction reaching a customer.
That is two multiplications and a division, and the division is where the estimate usually goes wrong by a factor of a thousand in one direction or the other. Forty fields at twenty-five tokens each is a thousand tokens of description on every request. Provider prices are quoted per million tokens, so a thousand tokens is a thousandth of the unit being priced, and a thousandth of three dollars is three tenths of a cent. A thousand calls therefore cost three dollars in descriptions. Descriptions also sit in the schema, which is a stable prefix that does not vary between requests, so on any provider with prompt caching they are read at the cached rate after the first call. Deleting a description to save tokens is close to always the wrong trade; the reason to keep them short is that short instructions are followed more reliably, not that long ones are expensive.
A description is an instruction with an address. Where the rule governs one field, attaching it there removes a retrieval step the model would otherwise perform on every value it writes, and removes it from the review problem as well, because the rule and the thing it governs become one artifact. Where the rule spans fields or frames the task, the prompt is still its home.
None of this makes descriptions safe. A description is prose the model chooses to follow, so it can be vague, it can contradict the type it sits on, and it can quietly take over work that the type system should have been doing. The next chapter is about the last of those, which is the most damaging because it produces responses that are valid, plausible, and impossible for a consumer to interpret correctly.
Schema design for LLMs — read the current version at https://daviddominguez.dev/learn/en/schema-design-for-llms/field-descriptions-versus-system-prompts