The story so far
Pattern 01: Mike messaged support asking why his payment was still pending. The assistant answered confidently — and made the whole thing up, because nothing grounded it in Mike’s actual transaction. We fixed that: every answer now traces back to a real record, not a guess.
Pattern 02: a second team — the dispute team — plugged their own data into that same assistant. Their payload was missing fields the original prompt needed, and it crashed with a raw KeyError. We fixed that too, with a versioned contract that checks every field is present and the right Python type, before anything renders. A second team’s integration now fails loud and named, not silently, two stack frames deep.
Today: a dispute’s classification passes that exact contract, clean. Every field present. Every type correct. It still doesn’t route anywhere. Why does a passing contract still let this through?
Present. Correctly typed. Still wrong.
The dispute-routing queue, the second team’s integration from Pattern 02, passes the contract cleanly. Every field is there. Every field is a string or a number.
The dispute still doesn’t route anywhere.
The routing queue needs status to be exactly one of three values: open, under_review, resolved. Pattern 02’s contract has no concept of “this string must be one of these exact values” — it only knows “is this a string.” So when a classification comes back with status: "still being looked at", the contract says yes, and the routing queue’s enum match finds nothing. No crash. No error. The dispute just sits in no queue at all.
This is Pattern 03: Structured Output And Validation.
The pattern in one sentence
Constrain a model’s output to an exact schema — fixed enum values, real types — during generation, not after, so a value that violates the schema becomes impossible to produce instead of merely easy to catch.
What I built
Same payment-support assistant, same dispute-routing story as Pattern 02. The new piece is a schema check sitting inside the generation step, not just around the prompt:
flowchart TB
DISPUTE[Dispute Service] --> API[AI Assistant API]
API --> SCHEMA[Get Routing Schema]
REGISTRY[(Bedrock Structured Outputs)] --> SCHEMA
SCHEMA --> GENERATE[Generate With outputConfig Schema Attached]
GENERATE --> VALIDATE[Validate Against Schema]
VALIDATE -->|Fails| VIOLATION[Return Schema Violation]
VALIDATE -->|Passes| ROUTE[Route To Matching Queue]
VIOLATION --> API
ROUTE --> API
The schema itself (OutputSchema) is a small dataclass: a schema ID, a description, and a list of typed fields — each one optionally carrying an enum of allowed values. It’s grounded in Amazon Bedrock Structured Outputs, checked directly against AWS’s official docs before writing this. Real Bedrock enforces a JSON Schema (Draft 2020-12 subset) via outputConfig.textFormat on the Converse API, using constrained decoding: the schema applies while the model is generating tokens, so a token that would produce an invalid enum value or wrong type literally cannot be selected. This is not validate-then-reject — by the time a real response comes back, it is already guaranteed to satisfy the schema. This pattern’s code ships a local mock (BedrockStructuredOutputClient) that simulates the same guarantee for scripted scenarios, so you can run the whole thing with zero AWS credentials.
What broke, and why — a service correction, caught before any code was written
The original plan for this pattern (recorded in patterns/state.md from a prior session) tentatively pointed at Amazon Bedrock Guardrails. Reading Guardrails’ actual documentation showed it’s content-safety policy enforcement — denied topics, PII redaction, hallucination/grounding checks — with no JSON-schema capability at all. The right service for “the output must validate against a schema” is a completely different, newly-GA (February 2026) capability: Bedrock Structured Outputs. Caught in the research step, before any code or prose assumed the wrong service — the same kind of correction Pattern 02 needed for its prompt template syntax, just one step earlier in the process this time.
What broke downstream, and why
Without a schema, the routing queue’s own code looked like this:
known_statuses = {"open", "under_review", "resolved"}
if candidate_output.get("status") not in known_statuses:
raise ValueError(f"... dispute does not route anywhere.")
Pattern 02’s contract has already confirmed status is present and is a string by the time this code runs. The classification —
{ "dispute_reference": "DSP-2026-0043", "status": "still being looked at", "confidence": 0.74 }
— is exactly the kind of value-level problem a presence/type contract cannot see. With a schema in front of generation, the same payload is checked against the real enum:
{
"type": "schema_violation",
"answer": "This classification cannot be routed — it does not satisfy the dispute queue's schema.",
"reason": "status: Value 'still being looked at' is not one of the allowed values: open, under_review, resolved.",
"next_step": "On real Bedrock, this exact failure is what Structured Outputs' constrained decoding prevents from ever being generated.",
"schema_id": "dispute-classification-v1"
}
A clear, named violation — not a silent non-route.
Fixing it for real: enforced during generation, not after
The fix isn’t a smarter regex or a fuzzy match against the enum. It’s attaching the schema to the generation request itself, the real Converse API shape:
{
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"name": "dispute_classification",
"description": "Structured classification of a payment dispute",
"schema": "{\"type\": \"object\", \"properties\": {\"status\": {\"type\": \"string\", \"enum\": [\"open\", \"under_review\", \"resolved\"]}, ...}, \"additionalProperties\": false}"
}
}
}
}
}
Two details easy to miss, confirmed against AWS’s current Structured Outputs docs and its own ML blog’s code sample: textFormat.type must be the literal string "json_schema", and the schema field itself is a JSON string (json.dumps(...)), not a raw object — plus name is required alongside it. Skip either and the real API returns a 400 before it ever reaches the model. On real Bedrock, this means an invalid status value is never generated in the first place — the model literally cannot select a token outside the allowed set once the grammar is compiled. That’s a stronger guarantee than this pattern’s earlier contract layer, which can only catch a problem after the fact.
More than one way to build this
The wider tooling field here is genuinely narrow — most constrained-decoding tools don’t reach Bedrock at all, worth saying plainly rather than forcing a bigger comparison than the field supports. If you’re already running a LiteLLM-fronted multi-provider setup, good news: LiteLLM merged real native support for this exact Bedrock feature in February 2026 — confirmed by checking the actual merged pull request, not just a docs page.
Two honest corrections worth knowing. Instructor, the popular Pydantic-based structured-output library most people reach for first, does not yet support Bedrock’s native constrained decoding — its shipped Bedrock modes are exactly the generate-then-retry pattern this whole pattern argues against. A PR exists; it’s not merged. And Outlines, another well-known constrained-decoding library, has zero Bedrock integration at all — confirmed straight from its own README. AWS’s own blog about Outlines says so directly: it routes through SageMaker, not Bedrock. If you assumed either of those just worked with Bedrock, now you know they don’t.
What it actually costs
| Local mock (this repo, today) | Real AWS, demo-scale | |
|---|---|---|
| Monthly cost | $0 | Effectively $0–$1/month at a few dozen demo requests |
| Model — Nova Micro (default) | — | ~$0.000035/1K input + $0.00014/1K output tokens — structured output adds no separate per-call fee beyond the model’s own token cost |
| Model — Claude 3.5 Sonnet (optional) | — | ~$0.006/1K input + $0.03/1K output tokens — ~170-200x Nova Micro’s rate |
| Lambda | — | Always Free — 1M requests/month |
| DynamoDB | — | Always Free — 25GB/month |
| API Gateway | — | Free for 12 months, then ~$1/million calls |
Bedrock compiles the schema to a grammar and caches it for 24 hours, confirmed from AWS’s own docs — repeat calls with the same schema cost no extra latency after the first. Same cost profile and same Nova Micro default as Patterns 01-02.
When to use this pattern
- A downstream system needs an exact enum, not free text that merely describes the right answer.
- Small formatting errors in model output would break a parser, a router, or a database constraint.
- You’re tempted to write a regex or fuzzy-match layer to coerce free text into your real schema.
- Retry-on-validation-failure is too slow or expensive — you need the model unable to produce an invalid value, not just caught after it does.
When not to use this pattern alone
A schema check catches “the value isn’t one of the allowed options” and “the type is wrong.” It does not judge whether a schema-valid classification is actually correct — a well-formed, in-enum, completely wrong answer still passes every check in this pattern. That’s Pattern 04: Evaluator Optimizer, the next step in this series. It also doesn’t take action based on the classification, only emit it — that’s Pattern 05: Tool Calling.
Beyond payment support
This series builds one continuous payment-support story, but the failure mode isn’t specific to payments — it shows up anywhere a model’s output has to match another system’s fixed, finite vocabulary:
- Support-ticket categorization — a model returns
category: "billing issue"when the router only recognizesbilling/technical/account; the ticket sits in an unmapped bucket, nobody triages it. - E-commerce order-status webhooks —
status: "on its way"instead of the exactshipped/out_for_delivery/deliveredthe webhook consumer expects; silently ignored, not an error. - Medical intake triage —
"pretty urgent"instead of the clinic’s fixedlow/medium/high/emergencyscale; the queueing system has no rule for that string. - Financial transaction categorization —
"eating out"when the chart of accounts only hasdining/groceries/entertainment; reconciliation breaks silently, exactly the shape of the Ramp example below.
Same fix in every case: constrain the values the model can even produce, instead of hoping its phrasing happens to line up with what the downstream system understands.
Who’s using this in the real world
Constraining what a model can generate — instead of checking it after the fact — is a proven technique at production scale, not a niche trick.
OpenAI’s own launch numbers for Structured Outputs: under 0.1% schema-match failures, versus 2-5% with plain JSON mode (parse-then-check, no generation-time constraint). That’s the same constrain-versus-check gap this pattern builds, measured by the company that shipped it. (openai.com/index/introducing-structured-outputs-in-the-api)
Ramp, a real fintech, published their own engineering blog on an AI agent that reclassifies merchant transactions — the exact shape of this pattern’s story, a model producing a classification that has to match a fixed system vocabulary. The agent handles nearly 100% of requests (up from 1.5-3% manual handling) at 99% accuracy. (engineering.ramp.com/post/fixing-merchant-classifications-with-ai)
Try it yourself
Full code, tests, and diagrams: patterns/03-structured-output-and-validation in the ai-series-demo repo.
git clone https://github.com/narenmak17/ai-series-demo.git
cd ai-series-demo/patterns/03-structured-output-and-validation
python -m unittest discover -s tests
python -m app.demo
No AWS account, no API keys, no cost. Seven tests, all green.
What’s next
Pattern 04 keeps the same schema-valid output but asks a harder question: is it actually good? A classification can be present, correctly typed, and within the right enum — and still be the wrong classification. Pattern 04 introduces the first second LLM call in this series: a judge that evaluates the first call’s output, not just its shape.
[!TIP] Star the GitHub repo and follow along with the series.
🎯 Interview Prep — Pattern 03
Scenario-based questions an interviewer would ask about this pattern. Try answering out loud before expanding each one. Full guide, all patterns: Interview Guide →
Q1 Pattern 02's contract validation passed. The dispute still didn't route. What happened?
The routing queue does a literal enum match against three labels: open, under_review, resolved. The classification returned status: "still being looked at" — present, a string, exactly what Pattern 02’s contract required. It doesn’t match any label. The dispute sat unrouted with no error raised.
Root cause: Pattern 02’s contract has no mechanism to express “this string must be one of these exact values.” It validates presence and Python type only.
Fix: Pattern 03 adds an enum constraint on the status field via Bedrock Structured Outputs (outputConfig.textFormat.structure.jsonSchema). The schema prevents the model from generating any value outside the allowed set — constrained at generation time, not caught after.
Q2 What is the difference between input validation and output schema validation?
Input validation (Pattern 02): checks data coming into the prompt — are required fields present? Are they the right Python types? Runs before the model call.
Output schema validation (Pattern 03): checks the model’s response — is it the expected structure? Are enum-constrained fields within the allowed set? Runs after the model call, or with Bedrock Structured Outputs, is enforced during generation.
Both are necessary: input validation protects the model from bad data (cheap, fast rejection). Output schema validation protects downstream systems from a model that produced a valid-looking but unusable value.
Q3 What is constrained decoding, and how is it different from post-hoc validation?
Post-hoc validation: model generates freely, a validation function checks the result. If wrong, reject and optionally retry. Problems: the bad value exists long enough to be logged, each retry costs another model call.
Constrained decoding (Bedrock Structured Outputs): the schema is compiled into a grammar. Each generation step is restricted to tokens that remain valid given the grammar. An enum-violating value is physically impossible to generate — no retry needed.
Use constrained decoding when the schema is within Bedrock’s supported subset (basic types, enum, required). Use post-hoc Pydantic for schema features outside that subset (numeric ranges, recursive schemas).
Q4 What JSON Schema features does Bedrock Structured Outputs support, and what doesn't it support?
Supported: basic types (string, number, integer, boolean, array, object), enum on string fields, required fields list, some format values.
Not supported: numeric minimum/maximum, string minLength/maxLength, recursive schemas, full JSON Schema Draft 2020-12.
The hybrid pattern: Bedrock Structured Outputs for the enum constraint (strongest guarantee, zero retry cost) + Pydantic post-hoc for range checks that Bedrock’s subset can’t express (e.g. confidence in [0.0, 1.0]).
Q5 The model starts returning confidence: 'high' instead of 0.91. How do you catch this before it reaches the routing queue?
With Bedrock Structured Outputs (real AWS): the schema declares confidence as type: "number". Constrained decoding prevents the model from generating the string "high" in that position — only tokens that parse as a JSON number are valid.
Post-hoc Pydantic (belt-and-suspenders or fallback):
class DisputeClassification(BaseModel):
status: Literal["open", "under_review", "resolved"]
confidence: float = Field(ge=0.0, le=1.0)
isinstance("high", float) is False — caught before touching the routing queue.
Best practice: both. Bedrock Structured Outputs at generation; Pydantic on the Lambda side for range constraints Bedrock can’t express.
Q6 Show me the Converse API call shape for Bedrock Structured Outputs.
response = client.converse(
modelId="amazon.nova-micro-v1:0",
messages=[{"role": "user", "content": [{"text": prompt}]}],
outputConfig={
"textFormat": {
"structure": {
"jsonSchema": {
"schema": {
"type": "object",
"properties": {
"dispute_reference": {"type": "string"},
"status": {
"type": "string",
"enum": ["open", "under_review", "resolved"]
},
"confidence": {"type": "number"}
},
"required": ["dispute_reference", "status", "confidence"]
}
}
}
}
}
)
Bedrock compiles the schema into a grammar on the first call (~few hundred ms) and caches it for 24 hours. The response is a JSON string in response["output"]["message"]["content"][0]["text"]. Parse with json.loads().
Q7 You're reviewing a PR where the developer validates model output with a bare try/except json.loads(). What's wrong?
Three problems:
JSON validity ≠ schema validity.
json.loads()confirms the response is parseable JSON. It does not check field presence, types, or enum values.{"status": "still being looked at"}is valid JSON.No field-level check. The routing queue does a literal enum match. An invalid status goes straight to the queue — no error raised, dispute unrouted.
Unstructured error response.
{"error": "model did not return valid JSON"}gives no field name, no allowed values, no way to fix it fast.
Fix: add a Pydantic model with Literal on status — or use Bedrock Structured Outputs so the invalid value can’t be generated at all.
Q8 When would you choose Pydantic post-hoc validation instead of Bedrock Structured Outputs?
Choose Pydantic when:
- Numeric range constraints — e.g.
confidencein[0.0, 1.0]. Bedrock has nominimum/maximumsupport. - Recursive schemas — Bedrock doesn’t support self-referencing schemas.
- Not on Bedrock — Structured Outputs is a Bedrock feature; Pydantic works with any model output.
- Richer error messages — Pydantic
ValidationErrorgives field-level detail including which value violated which constraint.
In practice, use both: Bedrock Structured Outputs for enum and type constraints (strongest guarantee); Pydantic as a belt-and-suspenders check for constraints outside Bedrock’s supported subset.
Q9 How do you test that an output schema catches all the ways a model could return the wrong value?
Build a test matrix for each field:
- Happy path — each valid enum value
- Enum violations — free-text values, wrong case (
"OPEN"), empty string, injection attempt - Type violations — string where number expected (
confidence: "high"), bool where float expected - Missing required fields — each required field absent individually; all absent
For each failure case, verify:
- Response type is
schema_violation(not an unhandled exception) reasonfield names the exact field and the violated constraint
For Bedrock Structured Outputs specifically: unit-test the local mock’s schema logic. Trust the real Bedrock service for constrained decoding guarantees — test the Lambda’s error-handling path separately.
Q10 You need to validate 10,000 model responses per minute against this schema. What actually changes at that volume?
Less than people expect, because the enforcement moved from your code to Bedrock’s generation step. The schema compiles into a grammar once and Bedrock caches it for 24 hours — you’re not paying a re-compilation cost per request, just normal Converse call volume.
What genuinely needs attention at 10k rpm:
- Provisioned Throughput or a quota increase — same constraint as any high-volume Bedrock workload, not specific to structured outputs.
- The Pydantic fallback path, if you’re running the hybrid pattern — post-hoc validation for range checks (confidence in
[0.0, 1.0]) runs in your own compute, so it scales with your Lambda/ECS concurrency, not with Bedrock. - Schema-violation logging volume — even a low violation rate (say 0.5%) is 50 structured error events per minute at this scale; make sure the CloudWatch metric filter grouping by violated field doesn’t get lost in noise.
The one-line answer: constrained decoding pushes the expensive part (guaranteeing valid output) into a cached, one-time grammar compile — it’s the retry elimination, not raw throughput, that’s the real cost story at scale. A system doing post-hoc-only validation at 10k rpm would be paying for retries on every rejected generation; this pattern mostly avoids that cost by construction.
Q11 How do you explain Bedrock Structured Outputs to a product manager who asks why disputes keep misrouting?
“Think of a dropdown menu on a form. You can’t type ‘still under investigation’ in a dropdown — you can only pick from the options the form gives you.
Bedrock Structured Outputs does the same thing for the AI: instead of a free-text field, we give it a dropdown with exactly three options — open, under_review, or resolved. It has to pick one. It can’t write anything else.
We’re switching the status field from a text box to a dropdown. Once that’s deployed, the misrouting stops.”
Technical one-liner: Bedrock Structured Outputs compiles the JSON Schema into a generation grammar — the invalid token can’t be sampled. The routing queue never sees it.
Have questions or feedback? Drop a comment below or connect on LinkedIn.
💬 Comments