---
title: "Structured Outputs on Amazon Bedrock: Make the Model's Response a Contract"
description: "Structured outputs constrain a model's response to a JSON schema you supply. What they are, why they matter, where they apply, what they cost, and how to call them on Claude Opus 5 through Amazon Bedrock, with the response shown with and without a schema."
canonical_url: "https://artificialcuriositylabs.ai/posts/2026-07-24-structured-outputs-claude-opus-5-bedrock/"
md_url: "https://artificialcuriositylabs.ai/posts/2026-07-24-structured-outputs-claude-opus-5-bedrock.md"
published_at: "2026-07-24T07:00:00.000Z"
tags:
  - "patterns"
  - "agents"
  - "structured-outputs"
---

Structured outputs let you send a JSON schema with a request and get back a response that matches it. The model's generation is constrained to the schema, so the response parses on the first try, contains the fields you defined, and uses only the values you allowed.

Here is one classification written two ways. The first is for a person:

```text
This is a billing problem, it is high priority, and someone should look at it.
```

The second is for a program:

```json
{"category": "billing", "priority": "high", "needs_human": true}
```

Structured outputs are how you ask for the second one and get it every time.

## How it works

You pass a JSON schema in the request. Bedrock compiles it into a grammar and applies [constrained sampling](https://aws.amazon.com/blogs/machine-learning/structured-outputs-on-amazon-bedrock-schema-compliant-ai-responses/) during generation, so each token the model emits has to keep the output valid against the schema. The first request with a new schema pays a compile cost, and compiled grammars are cached for 24 hours.

The [Bedrock documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html) describes two mechanisms, usable alone or together:

- A JSON schema output format controls the shape of the model's text response.
- Strict tool use (`strict: true`) applies a schema to the arguments the model passes to a tool.

## Why it matters

A model asked for JSON in the prompt can return output that breaks the program reading it. The [AWS launch post](https://aws.amazon.com/blogs/machine-learning/structured-outputs-on-amazon-bedrock-schema-compliant-ai-responses/) gives an example that parses fine and still fails: a booking function that needs `passengers: int` receives `"two"` or `"2"`. [Anthropic's documentation](https://platform.claude.com/docs/en/build-with-claude/structured-outputs) lists what goes wrong without a schema: parsing errors from invalid JSON syntax, missing required fields, inconsistent data types, and schema violations that need error handling and retries.

A schema removes these failure modes. The shape becomes a contract between the model and the code that reads its output, and the code can drop its fence-stripping, key-guessing and retry logic. That matters wherever a program consumes the response. When a person reads it, the constraint matters much less.

## Where it applies

| Use case | What the schema holds | Source |
|---|---|---|
| Extraction from unstructured text | Fields from invoices, contracts, clinical notes, product descriptions | [AWS lists](https://aws.amazon.com/blogs/machine-learning/structured-outputs-on-amazon-bedrock-schema-compliant-ai-responses/) finance, healthcare, ecommerce and legal examples |
| Classification and routing | Enums for category, priority and queue | The ticket example below; AWS lists ticket routing under customer service |
| Tool arguments for agents | The input schema of each tool, with `strict: true` | [Bedrock documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html) |
| Separating an answer from commentary | An `answer` field and a `reasoning` field | [OpenAI's announcement](https://openai.com/index/introducing-structured-outputs-in-the-api/) |
| Generated interfaces | A typed tree of components | [OpenAI's guide](https://developers.openai.com/api/docs/guides/structured-outputs/) |
| Pipelines that trust model output | The typed record the next stage reads | AWS describes "zero-validation data pipelines" built on schema-compliant output |

The tool row connects to how agents work. A [tool is a function with a JSON schema](https://artificialcuriositylabs.ai/posts/what-is-a-tool/), and strict mode applies the same constraint to the arguments the model fills in.

## How to use it with Claude Opus 5

Opus 5 on Bedrock accepts the Anthropic Messages API on the `bedrock-runtime` endpoint. Point the Anthropic SDK at `https://bedrock-runtime.<region>.amazonaws.com/anthropic`, authenticate with a short-lived Bedrock token, and put the schema in `output_config.format`. This example classifies a support ticket:

```python
import json
from anthropic import Anthropic
from aws_bedrock_token_generator import provide_token

REGION = "us-west-2"
client = Anthropic(
    base_url=f"https://bedrock-runtime.{REGION}.amazonaws.com/anthropic",
    api_key=provide_token(region=REGION),
)

schema = {
    "type": "object",
    "properties": {
        "category": {"type": "string",
                     "enum": ["billing", "bug", "account_access", "feature_request", "other"]},
        "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
        "needs_human": {"type": "boolean"},
        "order_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
        "summary": {"type": "string"},
    },
    "required": ["category", "priority", "needs_human", "order_id", "summary"],
    "additionalProperties": False,
}

response = client.messages.create(
    model="global.anthropic.claude-opus-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Classify this support ticket.\n\nTicket: {ticket}"}],
    output_config={"format": {"type": "json_schema", "schema": schema}},
)

if response.stop_reason != "end_turn":
    raise RuntimeError(f"incomplete response: {response.stop_reason}")

text = next(block.text for block in response.content if block.type == "text")
triage = json.loads(text)
```

Read the text block by type. With default settings and no schema, Opus 5 returned a `thinking` block ahead of the text in every run I made, so `response.content[0].text` fails there.

Every object in the schema needs `additionalProperties: False`, and Bedrock rejects the schema without it. Numeric ranges such as `minimum` and `maximum` are outside the supported subset, so check those in your own code.

For tool arguments, add `"strict": True` to the tool definition. The model's tool call then follows the input schema:

```python
tool = {
    "name": "classify_ticket",
    "description": "Record the classification of a support ticket",
    "strict": True,
    "input_schema": schema,
}

response = client.messages.create(
    model="global.anthropic.claude-opus-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Classify this support ticket using the tool.\n\nTicket: {ticket}"}],
    tools=[tool],
    tool_choice={"type": "tool", "name": "classify_ticket"},
)

args = next(block.input for block in response.content if block.type == "tool_use")
```

## What the response looks like with and without a schema

The input is one support ticket:

```text
Hi, I was charged twice for order #A-48213 this morning and now I can't log in
to see my invoices. This is blocking our month-end close, please fix ASAP!!
```

Opus 5 thinks by default, which changes the response and the bill. For this comparison I turned thinking off with `thinking={"type": "disabled"}`, so the schema is the only thing that differs between requests.

Without a schema, I asked Opus 5: "Classify this support ticket and return the result as JSON." Every response opened with a paragraph of prose, put the JSON in a markdown code fence, and usually closed with notes, so `json.loads` fails on the raw text. Here are two separate runs of that prompt, trimmed:

````text
Looking at this ticket, there are actually two distinct problems bundled together,
plus some urgency signals worth capturing. Here's my classification:

```json
{
  "ticket_id": "A-48213",
  "summary": "Customer reports a duplicate charge on order #A-48213 and is simultaneously unable to log in ...",
  "classification": {
    "primary_category": "Billing",
    "secondary_category": "Account Access",
    "is_multi_issue": true
  },
  "priority": {
    "level": "High",
    "rationale": "Financial impact (duplicate charge) combined with a blocker on a time-sensitive ..."
  },
  ...
}
```

A couple of notes on the judgment calls:

**Why Billing is primary:** The duplicate charge is the root financial harm ...
````

````text
Looking at this ticket, there are actually two distinct problems bundled together,
plus some urgency signals worth capturing. Here's my classification:

```json
{
  "ticket_id": "A-48213",
  "summary": "Customer reports a duplicate charge on order #A-48213 and is simultaneously unable to log in ...",
  "primary_category": "billing",
  "secondary_issues": [{ "category": "account_access", ... }],
  "sentiment": "frustrated",
  "urgency": "high",
  "priority": "P1",
  ...
}
```

A few notes on the judgment calls:

**Primary category is billing, not account access.** The duplicate charge is the root financial harm ...
````

Both answers are reasonable. They disagree on where the category lives, whether `priority` is an object or a string, and what the value says (`High` with a rationale, or `P1`). No two of the 20 runs used the same set of fields, three had no `priority` field at all, and the prompt never named any fields.

Describing the fields in the prompt ("category, one of billing, bug, ...; priority, one of low, medium, high, critical; ...") fixes this. Opus 5 followed it closely, and 19 of 20 responses were raw JSON that `json.loads` accepted. The twentieth came back in a code fence.

With the schema passed through `output_config.format`, 20 of 20 responses were the JSON and nothing else:

```json
{"category":"billing","priority":"high","needs_human":true,"order_id":"A-48213","summary":"Customer reports a duplicate charge on order #A-48213 and is also unable to log in to access invoices, blocking their month-end close. Requests urgent resolution (refund of duplicate charge and account access restoration)."}
```

The fields are the ones I defined, `priority` is one of the four values I allowed, and `order_id` is a string or null. Twenty runs is a small sample, but the one fenced response is the failure a parser has to handle, and the schema removes it. A one-in-twenty rate would be fifty parse failures per thousand tickets.

## What it costs

A schema moves the bill in two directions. It is added to the prompt, so input grows, and the response is only the JSON, so output shrinks. I priced each request at the [published Opus 5 rate](https://platform.claude.com/docs/en/about-claude/pricing) of $5 per million input tokens and $25 per million output tokens, using the token counts the API reported. With thinking disabled, 20 runs each:

| Request | Input tokens | Output tokens | Cost per 1,000 tickets |
|---|---|---|---|
| No schema, "return the result as JSON" | 84 | 1,081 | $27.43 |
| Fields described in the prompt | 148 | 117 | $3.67 |
| Schema in the request | 473 | 98 | $4.83 |

Against the bare prompt, the schema is 82% cheaper, because the bare prompt returns about a thousand tokens of prose and notes around the JSON. Against a prompt that describes the fields, the schema is 31% more expensive. It bills 325 more input tokens, about $1.63 per thousand tickets, and saves 19 output tokens, about $0.47. That difference buys the removal of the one fenced response in twenty.

These figures use the thinking-disabled runs from the previous section. Thinking tokens bill as output tokens, so a request that triggers thinking costs more than shown. I did not measure retries avoided.

## Where it stops

Truncation breaks the contract. With `max_tokens` set to 20, the call returned HTTP 200, `stop_reason: max_tokens` and an empty text block. AWS tells you to [check the stop reason](https://aws.amazon.com/blogs/machine-learning/structured-outputs-on-amazon-bedrock-schema-compliant-ai-responses/) for refusals and token limits, and the snippet above does.

A valid response can still lose information. The ticket describes two problems, a duplicate charge and a login failure, and my schema allows one `category`. Opus 5 returned `billing` in all 20 runs, and the login failure survived only in the free-text `summary`. Without a schema, every run recorded the second issue in its own field, such as `secondary_issues`. The schema made a product decision, one category per ticket, and the model followed it. If the next stage needs both issues, the schema needs a `categories` array. A schema guarantees the shape of each field and says nothing about whether those fields capture what the next stage needs.

## So what

Put a schema on any call where a program reads the answer. The cost is the schema's input tokens and a stop-reason check, and the return is the deletion of fence-stripping, key-guessing and retry code. For tool arguments, add `strict: true`.

Keep your own validation for ranges and for anything the schema subset cannot express. The schema covers shape. Meaning needs a separate check.
