Prompt EngineeringTechniques that hold up

Output contracts

Stop asking for a format. Specify one, then make it impossible to miss.

WHY THIS, FOR YOU

You asked for JSON with a status field and you got it, four different ways — paid, Paid, payment received, and once a null where a value should have been. So there is a normalising function in your code now, and it grows a branch every time production surprises you. The problem is upstream: you described a shape and hoped. This lesson is about designing a contract instead — naming the fields, closing the sets, deciding what unknown looks like, and letting the decoder enforce the shape rather than the prompt request it.

Getting a guaranteed object back is machinery, and it is the easy half. This lesson is the design question that comes first: what shape should the contract be? Most of the reliability people chase with prompt wording is available for free in the schema. The mechanics, and what still gets past them, are in the next lesson.

1.The ladder from asking to guaranteeing

There are four rungs between "I would like JSON" and "this response cannot be the wrong shape".

Prose"Return JSON with the invoice number and total." Usually honoured, sometimes wrapped in a sentence or a code fence.
One exact exampleShow the literal object. Keys, types and the empty case stop being ambiguous. Still a request.
Schema in the promptNames and types are unmistakable, but generation is still free-running.
Schema the decoder enforcesSampling is restricted to schema-valid output. Guaranteed, not requested.
Four rungs, each closer to a guarantee than a request. Only the last one removes the parser: the output is restricted to the schema, not merely asked to follow it.

Most people stop at rung 1, get burned, and write a parser. Rung 2 costs a few tokens and often fixes the drift on a small task. Rung 4 is the one that deletes the parser, and as of October 2026 it comes in two forms: a JSON output format (output_config.format), or strict: true on a tool definition.

One dated warning: forcing a tool call with tool_choice used to be the standard route to a guaranteed shape. As of September 2026 that request returns a 400 on claude-sonnet-5-5; it still works on some older models, so check the one you pin. Where you want a real function call, keep tool_choice on auto, add strict: true, and tell the model in the prompt when the tool applies. The guarantee comes from strict or the output format, not from tool use itself.

The caveat that matters more than any of this: a guaranteed shape is not a guaranteed answer. The schema says total_minor_units is an integer. It does not say it is the integer on the invoice. Structure and truth are separate problems, and the schema only solves the first.

2.Closed sets are the cheapest accuracy you can buy

Every free-text field is an invitation to a synonym. Declare status as a string and you collect paid, Paid, settled, payment received — four spellings of one fact, and your if status == "paid" is wrong three times out of four.

An enum collapses most of that into one line. It also forces you to decide what the categories actually are, which is usually where the real ambiguity was hiding while the model absorbed it silently.

Weak fieldThe contractWhat changed
sentiment: stringsentiment: enum [positive, negative, neutral, mixed]Closed set, no synonyms
date: stringissue_date: string, format YYYY-MM-DD, or nullNamed, formatted, given a legal empty value
amount: stringtotal_minor_units: integer plus currency: enum [USD, EUR, GBP, unknown]No $1,204.00 to parse, money never in floats
notes: stringDropped, or notes: string, verbatim quote onlyA field with no rules has no meaning
confidence: numbersource_quote: string or nullA quote you can check beats a number you cannot
urgency: integerurgency: integer, range stated in the descriptionThe decoder will not enforce 1 to 5; your code does
Every row does the same move: an open string becomes something that cannot drift — an enum, a named unit, a defined null. The pattern carries to any field with a finite set of honest answers.

An enum closes the set but not the case; compare case-insensitively. Structured outputs in practice shows the failure.

The decoder cannot enforce numeric ranges or string lengths; Structured outputs in practice has the table of what it drops, and Validating what comes back has the check.

The confidence row is on almost every first draft. A model's self-reported score is generated like every other token and is not calibrated against anything. A quote you can search the source for is worth far more, because your code can verify it.

3.Name the fields, fix the order, define unknown

Name them precisely. issue_date and due_date, not date. The field name is an instruction, and it sits right where the model needs it. If you write a sentence explaining a field, rename the field and delete the sentence.

Fix the order, and put evidence first. The model generates left to right, so a field produced early is in context for the later ones. Put the quote before the label and the label is produced with the quote already on the page, the same mechanism as Thinking out loud. The size of the effect is task-dependent, so order the fields well, then measure. One detail from the vendor page: output comes back with required properties first, in schema order, then optional ones. Mark everything required to keep your order.

One response object, in declaration order
{
  "source_quote": "...",
1  "invoice_number": "...",
  "issue_date": "...",
  "total_minor_units": ...,
  "currency": "..."
}2
  1. 1Evidence, declared firstGenerated before the fields it supports, so it sits in context when they are produced
  2. 2The fields the evidence supportsCome after, with the quote already on the page
The field order from the invoice schema below. source_quote comes first, so it is already on the page when the model produces the fields it is meant to support.

Name that first field source_quote, not reasoning. A field called reasoning, trace or thinking can draw a reasoning_extraction refusal on Sonnet 5.5 and other current Claude models, and that category is billed even when no output arrives (as of September 2026). A quote or a short explanation is fine.

Define what unknown looks like. This stops invention. A model asked for a field the document does not contain will produce a plausible value, because plausible text is the only thing it makes. Give it a legal way to say nothing: null for absent, not an empty string and not "N/A" beside "unknown". Where unknown is a category, put it in the enum, because a four-value decision with no escape hatch is a three-value decision plus a guess.

A schema without a defined unknown is a schema that asks the model to make something up.

Here is the whole invoice contract as one call, the worked example for this lesson. The parameter spellings are copied from the vendor docs as of October 2026, not run here, so try them on your SDK version first.

import json
from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-sonnet-5-5"   # as of October 2026

def text_of(resp) -> str:
    return "".join(b.text for b in resp.content if b.type == "text")

INVOICE = {
    "type": "object",
    "properties": {
        # Evidence first: generated before the values it supports.
        "source_quote": {"type": ["string", "null"]},
        "invoice_number": {"type": ["string", "null"]},
        "issue_date": {"type": ["string", "null"], "description": "YYYY-MM-DD"},
        "total_minor_units": {"type": ["integer", "null"], "description": "Cents. 1204.00 USD is 120400."},
        "currency": {"type": "string", "enum": ["USD", "EUR", "GBP", "unknown"]},
    },
    # Every field required, so the order above is the order you get.
    "required": ["source_quote", "invoice_number", "issue_date", "total_minor_units", "currency"],
    "additionalProperties": False,
}

resp = client.messages.create(
    model=MODEL,
    max_tokens=2000,   # thinking is on by default and counts against this
    system="Read the invoice. Use null for anything not stated. Never infer a value.",
    output_config={"format": {"type": "json_schema", "schema": INVOICE}},
    messages=[{"role": "user", "content": "<invoice>...</invoice>"}],
)

# The schema holds only for a finished answer: a refusal or a cut-off may not match it.
if resp.stop_reason != "end_turn":
    raise RuntimeError(f"no usable answer: {resp.stop_reason}")
invoice = json.loads(text_of(resp))   # no fence to strip, no regex

Nothing in that prompt asks nicely for anything. Every rule that could have been a sentence is a field name, a type, an enum or a null, and the system prompt exists only for the one rule a schema cannot express: what to do about missing values.

When not to use a contract. Skip it for free-form text, such as a reply email, where forcing fields strips the writing. Skip it when you need a real function call, which is tool use with strict, not an output format. Be careful when the task needs working out. With JSON as the only text, the working can happen only in thinking, and Anthropic's Sonnet 5.5 guide reports, as of October 2026, that at low or medium effort the model often skips it and accuracy drops on multi-step tasks (vendor-reported, no numbers). A closing line telling it to think the problem through, or a higher effort, is the vendor's fix. Steering how much it thinks has the detail.

Next: the shape is now the decoder's job, and Structured outputs in practice covers the schema limits, the SDK helper, and what still gets past the contract.

WHAT YOU TAKE AWAY

  1. Climb the ladder — prose, one exact example, a schema in the prompt, then a schema the decoder enforces.
  2. Replace every free-text field whose answers are finite with an enum, and compare it case-insensitively.
  3. Name fields precisely; a good name replaces a paragraph of instructions.
  4. Put evidence fields before the fields they support, and mark every field required.
  5. Define what unknown looks like, or the model will invent a value instead.

RECALL NO SCROLLING BACK

  1. The difference between asking for a shape in the prompt and using a schema the decoder enforces is...

  2. The cheapest accuracy win in an extraction schema is usually...

  3. Putting an evidence_quote field before the classification field can help because...

  4. You never say what to return when a value is missing from the source. The likely result is...

  5. A schema-guaranteed response still needs value validation because...

GO DEEPER