Structured Output and Schema Enforcement
How to make a model emit machine-readable JSON your downstream systems can rely on, using forced tool calls, validation and repair loops.
Tool use solves the model's outbound problem: reaching the world. Structured output solves the mirror problem: your systems need to consume what the model produced. A queue consumer, a Step Functions state machine or a DynamoDB write needs typed fields, not a paragraph. Prose is a terrible interface, and asking politely for JSON in the prompt is a terrible contract — you will get fenced code blocks, trailing commentary, a stray apology, or valid JSON with the fields renamed.
The portable technique: a tool you never execute
The most reliable approach on Bedrock reuses the machinery you already have. Define a single tool whose inputSchema is your output schema — say record_extraction with the fields you want — and set toolChoice to {"tool": {"name": "record_extraction"}} so the model must call it. Then simply read toolUse.input as your result and never execute anything. You get the model's constrained-generation path, already tuned to produce schema-shaped JSON, instead of free-form text you have to scrape. Note that forced toolChoice is not supported by every model, so check the model's documentation; with {"any": {}} and a single declared tool you get much the same effect.
Some models additionally expose a native structured-output or response-format option, reachable through the Converse API's additionalModelRequestFields. That is model-specific, so consult the model's request parameters page rather than assuming it is available everywhere.
Validate anyway
As with tool arguments, the schema shapes generation but is not enforced by the service, so parse and validate every response before it leaves your boundary. Pydantic or jsonschema against the same schema you published gives you one definition and a typed object on the other side. Set additionalProperties: false, mark required fields, and use enum for closed vocabularies so a category is one of yours rather than one the model invented.
Repair, then fail loudly
When validation fails, feed the validator's error message back as another turn and ask for a corrected object. One or two repair attempts fix the overwhelming majority of failures. After that, stop: route the record to a dead-letter queue or a human review queue rather than shipping a half-parsed object downstream. Log the raw output alongside the validation error — that pairing is what tells you whether to change the schema, the description, or the model.
Design the schema for the consumer
Flat beats deeply nested. Provide an explicit way to say "not present" — a nullable field or an "unknown" enum member — because a model with no escape hatch will invent a value to satisfy required. Include a confidence field or a verbatim source-quote field when a human will review the output; it makes triage far cheaper. And version the schema, because the day you add a required field is the day every queued message from the old version fails validation.
Where it fits on AWS
Structured output is the join between the probabilistic part of your system and the deterministic part. Extract typed JSON, validate it, then hand it to ordinary infrastructure: an EventBridge event, a Step Functions execution, an SQS message, a DynamoDB item. Amazon Bedrock Guardrails still applies independently through guardrailConfig, and it is a content control — it will not make malformed JSON well-formed.
Example
import json
import boto3
from pydantic import BaseModel, Field, ValidationError
from typing import Literal, Optional
bedrock = boto3.client("bedrock-runtime", region_name="eu-west-1")
class Complaint(BaseModel):
category: Literal["billing", "delivery", "product_fault", "other"]
severity: Literal["low", "medium", "high"]
order_id: Optional[str] = Field(None, description="null if not mentioned")
summary: str = Field(max_length=280)
# The tool is never executed - its schema IS the output contract.
EXTRACT_TOOL = {
"tools": [{"toolSpec": {
"name": "record_complaint",
"description": "Record the structured details of a customer complaint.",
"inputSchema": {"json": Complaint.model_json_schema()},
}}],
"toolChoice": {"tool": {"name": "record_complaint"}}, # force the call
}
def extract(email_text: str, max_repairs: int = 2) -> Complaint:
messages = [{"role": "user", "content": [{"text": email_text}]}]
for _ in range(max_repairs + 1):
response = bedrock.converse(
modelId="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
toolConfig=EXTRACT_TOOL,
)
reply = response["output"]["message"]
call = next(b["toolUse"] for b in reply["content"] if "toolUse" in b)
try:
return Complaint.model_validate(call["input"]) # trust nothing
except ValidationError as exc:
# Repair turn: hand the validator's own message back.
messages.append(reply)
messages.append({"role": "user", "content": [{"toolResult": {
"toolUseId": call["toolUseId"],
"content": [{"text": f"Invalid: {exc}. Call the tool again, corrected."}],
"status": "error",
}}]})
raise ValueError("Could not obtain a valid Complaint - send to DLQ for review.")When to use it
- An insurer extracts claim type, date of loss and estimated value from free-text emails as validated JSON, then starts a Step Functions execution with it.
- A support pipeline classifies incoming tickets into a fixed enum of queues, rejecting to human triage any response that fails schema validation twice.
- A product team converts supplier PDFs into typed catalogue records written straight to DynamoDB, with a confidence field driving which records a human reviews.
Discussion