Bedrock structured outputs now work on Claude Sonnet 5 — dropping forced tool use turned thinking on
Amazon Bedrock now accepts outputConfig.textFormat on Claude Sonnet 5. Replacing forced tool use with it fixed the JSON shape, but needed thinking disabled.

On this page
Introduction
Earlier this month, Amazon Bedrock refused structured outputs on Claude Sonnet 5, so a translation pipeline I work on kept its JSON in shape with a prompt rule and an error-feedback retry. On 2026-10-20, AWS announced structured outputs for all Claude models on Bedrock, and I wanted to know whether that workaround could go.
It could. With the prompt rule removed, forced tool use broke 5 of 20 calls on a real batch, and outputConfig.textFormat broke none. The switch only kept the old speed with thinking turned off, because forced tool use had been keeping Sonnet 5 from thinking at all, and without it one 143-segment batch went from 47 seconds to 731.
Bedrock now enforces the schema while the model generates, and the request no longer depends on forced tool use, which the 5.5 models refuse.
What Bedrock accepts on Claude Sonnet 5 now
Every Claude model I could call on Bedrock now takes a JSON Schema for its answer. Claude Code sent one small request per model to the global.anthropic.* inference profiles in ap-northeast-1. It went through both runtime APIs: Converse, Bedrock’s model-agnostic chat API, and InvokeModel, which carries Anthropic’s own request body.
| Model | Converse outputConfig.textFormat | InvokeModel output_config.format |
|---|---|---|
| Sonnet 4.5, 4.6, 5, 5.5 | Accepted | Accepted |
| Opus 4.5, 4.6, 4.7, 4.8, 5, 5.5 | Accepted | Accepted |
| Haiku 4.5, 5.5 | Accepted | Accepted |
| Fable 5, 5.1 | Not testable | Not testable |
The prompt asked for prose, an enum value outside the schema, and an extra key, and every accepted response still matched the schema. Fable could not be used because of an account setting, so I did not check whether it supports structured outputs. The Sonnet 5 model card now lists Structured outputs under Supported.
A deliberately broken schema, {"type": "bogus"}, was rejected, so Bedrock reads the field instead of dropping it:
ValidationException: The json schema definition at outputConfig.textFormat.structure.jsonSchema is invalid.Converse takes the schema serialised to a JSON string with json.dumps(), while InvokeModel takes the Python dict itself, so the two request shapes are not interchangeable:
client.converse( modelId="global.anthropic.claude-sonnet-5", messages=messages, outputConfig={"textFormat": { "type": "json_schema", "structure": {"jsonSchema": { "schema": json.dumps(schema), # a string; InvokeModel takes the dict "name": "translations", }}, }},)client.invoke_model( modelId="global.anthropic.claude-sonnet-5", body=json.dumps({ "anthropic_version": "bedrock-2023-05-31", "max_tokens": max_tokens, "messages": anthropic_messages, "output_config": {"format": { "type": "json_schema", "schema": schema, # a dict; Converse takes a JSON string }}, }),)toolChoice, strict, and textFormat each guarantee a different thing
toolChoice controls how the model picks from the tools list: let it decide (auto), force some tool (any), or force a named one (tool). strict: true on a tool definition makes that tool’s arguments match its schema when it is called. outputConfig.textFormat makes the reply itself match a schema, with no tool involved.
strict and textFormat are the two halves of structured outputs. Both use constrained decoding: Bedrock compiles the schema into a grammar and the model can only sample tokens the grammar allows. toolChoice is older and is not part of structured outputs.
Are forced tool use and structured outputs mutually exclusive?
No. I had assumed structured outputs was implemented as a tool, so it only worked with forced tool use.
Structured outputs is not a tool. It restricts which tokens the model can pick, in two ways. strict is an attribute on a tool definition and constrains that tool’s arguments when the model calls it. textFormat sits on the request and constrains the whole reply.
That assumption describes the workaround the earlier article was built on. Define a tool whose input schema is the answer, force it with toolChoice: {"tool": {"name": ...}}, and read the arguments. Forcing guarantees the call and never checks its contents, which is how the keys came back as text.
strict fills the gap forced tool use leaves. Forcing guarantees the model calls the fake tool, and strict guarantees the input it fills in matches the tool’s schema. In other words, the result does not come from running the fake tool. It is the input the model put into it, already JSON that matches the schema.
Sonnet 5 accepted a request carrying both strict and forced tool use, and every result matched the schema. textFormat is separate from tools altogether, so it needs no tool at all and nothing to force.
What auto, any, and tool mean
toolChoice decides how the model uses the tools list:
-
{"auto": {}}: the model decides which tool to call, or answers in text with no tool at all. It is also what the API does whentoolChoiceis left out.tool_config = {"tools": [get_weather, get_time],"toolChoice": {"auto": {}}, # may call either tool, or answer in text only} -
{"any": {}}: the model must call at least one tool from the list, and generates no text.tool_config = {"tools": [get_weather, get_time],"toolChoice": {"any": {}}, # must call a tool, but the model picks which} -
{"tool": {"name": ...}}: the model must call the named tool.tool_config = {"tools": [get_weather, get_time],"toolChoice": {"tool": {"name": "get_weather"}}, # must call get_weather}
The last two are forced tool use: the model has to use a tool and cannot answer in text.
In the small probe, Opus 4.7, Opus 4.8, and Haiku 5.5 answered in text with stopReason: end_turn and made no tool call. With strict and auto, the schema holds only if a call happens.
Is strict the same thing as textFormat?
Same engine, different target. AWS describes them as two mechanisms that “can be used independently or together in the same request”.
strict: true | outputConfig.textFormat | |
|---|---|---|
| Where it goes | Inside a tool definition | Top level of the request |
| What it constrains | The JSON the model puts in the fake tool’s input | The model’s output when it answers in text |
| When it applies | When the model chooses to call that tool | When the model chooses not to call a tool and answers in text (every response, if no tools are offered) |
Sending both in one request works, and each keeps to its own output. I sent 30 requests to Sonnet 5 with a strict tool, toolChoice: auto, and textFormat all set. Every reply came back as either a tool call or text, never both.
With auto, though, the model mostly called the tool, including 4 of the 10 runs told not to use any tool, so textFormat applies only when the model decides not to call the tool.
strict and textFormat take two separate paths. When the model calls the tool, strict applies; when it answers in text instead, textFormat applies. Either way, the result matches a schema. All 24 tool inputs and all 6 texts matched their own schema.
Three ways to ask for the same JSON on a real batch
I tried three combinations: forced tool use + strict, strict + auto, and textFormat. textFormat was the only request that held on both Sonnet 5 and Sonnet 5.5 without leaving a decision to the model.
The test batch had 63 segments, and the prompt rule naming text as a forbidden key was removed so that a shape error had a chance to appear. Each request ran 20 times on Sonnet 5 and 10 times on Sonnet 5.5.
| Request | Model | Valid | Median time |
|---|---|---|---|
| Forced tool use, prompt rule kept | Sonnet 5 | 20 of 20 | 25 s |
| Forced tool use, prompt rule removed | Sonnet 5 | 15 of 20 | 25 s |
S1: forced tool use + strict | Sonnet 5 | 20 of 20 | 24 s |
A1: strict + auto | Sonnet 5 | 19 of 20 | 24 s |
T1: textFormat | Sonnet 5 | 20 of 20 | 24 s |
S1: forced tool use + strict | Sonnet 5.5 | Rejected with a 400 | — |
A1: strict + auto | Sonnet 5.5 | 10 of 10 | 36 s |
T1: textFormat | Sonnet 5.5 | 10 of 10 | 36 s |
The five failures without the rule all named every key text, the same error the earlier article chased. S1 fixes that with the smallest change, but Sonnet 5.5 and Opus 5.5 refuse forced tool use outright:
tool_choice: type "tool" and "any" are not supported for this model.A1’s one failure called the tool with an empty translations array after 75 output tokens and two seconds. An empty array satisfies the schema, so strict let it through. I chose T1 in the end. Under auto, A1 can also skip the call entirely, and T1 has no tool to skip.
What structured outputs still leaves to the code
The schema guarantee has edges, and the adapter still checks each one:
- A response cut off at
max_tokens, or a refusal, may not match the schema. Anthropic’s structured-outputs page says so for both. Converse has norefusalstop reason, so the adapter reads the reply only whenstopReasonisend_turn. - The item count and the ids are not constrained.
minItems(how many items an array must have) accepts only 0 or 1, so it cannot require 63, which is how A1’s empty array passed. The comparison against the input ids stays. - Every object needs
"additionalProperties": false. Bedrock returns a 400 for any other value.
Leaving forced tool use turned adaptive thinking on
Forced tool use had been keeping Sonnet 5 from thinking, and textFormat let it think again. Adaptive thinking is Claude’s default mode on Sonnet 5: the model decides per request whether to reason before answering and for how long, and the reasoning tokens count against maxTokens.
Translating a whole document with textFormat, most batches took as long as before, but three slowed down sharply. The slowest was attempted six times, because five of those ran past the 120-second read_timeout. botocore, the AWS SDK under boto3, retried each one without raising, so every timed-out attempt was billed and none was logged as a failure.
Running the slow batches three more times each, with a 300-second timeout and no retries, separated the cause:
| Request | Time | Stop reason | Output tokens | Runs with reasoning |
|---|---|---|---|---|
| Forced tool use | 42–47 s | 6 of 6 tool_use | 6,767–7,304 | 0 of 9 |
textFormat, thinking on | 45–138 s | 2 of 6 max_tokens | 6,854–16,384 | 5 of 9 |
textFormat, thinking off | 41–46 s | 6 of 6 end_turn | 6,598–6,871 | 0 of 9 |
Time, stop reason, and output tokens cover the two slowest batches (six runs). The last column covers all nine.
The JSON itself was the same length in every mode, about 13,000 characters for one batch, so the extra tokens were reasoning. The 143-segment batch was just as slow on a second full run, long after the grammar had been compiled and cached, so compilation was not the delay.
Anthropic’s thinking troubleshooting page documents the cause: “A forced tool_choice ({"type": "any"} or a named tool) also returns no thinking block: the response starts with the tool call.” Under forced tool use, the pipeline had been translating without thinking all along. So after switching to textFormat, thinking has to be turned off to avoid the timeouts and the extra tokens.
Turning thinking off, and the models that refuse it
Sending thinking: {"type": "disabled"} brought textFormat back to the forced-tool-use numbers. Converse passes Anthropic-specific fields through additionalModelRequestFields:
client.converse( # … additionalModelRequestFields={"thinking": {"type": "disabled"}}, outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": { "schema": json.dumps(schema), "name": "translations", }}}},)On 23 batches, every response matched the schema and every id came back. The longest call took 45 seconds, and none was retried. Translation quality on the pipeline’s evaluation suite stayed within the run-to-run spread of the forced-tool-use version.
Not every model accepts that setting. Anthropic’s per-model table:
| Model | thinking: {"type": "disabled"} |
|---|---|
| Sonnet 5 | Accepted |
| Opus 5, Haiku 5.5 | Accepted at effort high or below |
| Sonnet 5.5 | 400; send {"type": "between_tools"} instead |
| Opus 5.5, Fable 5, Fable 5.1 | 400; thinking is always on |
disabled turns thinking off entirely. between_tools, accepted only by Sonnet 5.5, turns off the thinking before the reply, but the short updates the model writes between tool calls still come back as thinking blocks. By the documentation, a single call with no tools, like this pipeline’s, should behave the same under either.
The pipeline reads its model ID from SSM Parameter Store at runtime, so it can be switched to Sonnet 5.5 without a redeploy, and every batch after that would fail with a 400. I made the adapter refuse a model ID that has not been verified with thinking off, before it calls Bedrock:
THINKING_DISABLED_MODELS = frozenset({"claude-sonnet-5"})
def __init__(self, *, model_id: str) -> None: # other arguments elided if model_id.rsplit("anthropic.", 1)[-1] not in THINKING_DISABLED_MODELS: raise ConfigurationError( f"model not verified to run with thinking off: {model_id}" )The error surfaces with the model ID in it, instead of as a stream of Bedrock 400s, and adding a model means measuring it first.
Summary
| Problem | What handles it |
|---|---|
| Forced tool use guarantees a call, not its contents | outputConfig.textFormat, with no tool: 20 of 20 without the prompt rule |
| Sonnet 5.5 and Opus 5.5 refuse forced tool use | The same textFormat request: 10 of 10 on Sonnet 5.5 |
| Leaving forced tool use turns adaptive thinking back on | thinking: {"type": "disabled"}, restoring the forced-tool-use latency and token count |
max_tokens cuts, refusals, item count, and ids | Read only end_turn, and keep the id check |
A model switch to one that rejects disabled | An allow-list that fails with the model ID before calling Bedrock |
On Bedrock, Claude Sonnet 5 now takes a JSON Schema for its reply. Moving off forced tool use also changes whether the model thinks, so measure latency and output tokens on your heaviest batch before switching, not only the shape of the JSON.
References
- AWS What’s New: Amazon Bedrock supports structured outputs for all Anthropic Claude models
- Amazon Bedrock: Get validated JSON results from models (structured outputs)
- Amazon Bedrock model card: Claude Sonnet 5
- Amazon Bedrock API: ToolChoice, defining auto, any, and tool
- Claude API: Structured outputs, including what is not guaranteed
- Claude API: Troubleshooting thinking, with the per-model disabled-thinking table
- Claude API: Define tools, including forced tool use restrictions


