Structured Output & Tool Calling
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Examples on this page target Python 3.12 with the current anthropic and openai SDKs, Ollama 0.34,
Spring Boot 4.1 with the Spring AI 2.0.x BOM, and llama.cpp’s current grammar tooling, checked against the vendors' own API
references as of 2026-09-28. Every example below extracts the same target shape from the shared DocsAssistant
scenario (introduced in the vector-database and RAG section): given the raw
text of one DocsAssistant page, return its metadata as
\{title: string, component: string, topics: string[]}. Holding the shape constant across every vendor and
technique lets this page be read as a comparison table as much as a tutorial — the only thing that changes
between sections is how each stack constrains and validates the same JSON.
From parsing prose to native structured output
Before any vendor offered a schema-aware generation mode, the only way to get JSON out of a model was to ask for it in the prompt — "respond with a JSON object containing these fields, and nothing else" — and then regex- or substring-extract whatever the model wrapped it in ("Here is the JSON you requested: \`{…}\`"), before finally calling a JSON parser on the extracted span. This still works, and it is still what a bare local model with no schema-aware API falls back to, but it carries three practical problems: the model can wrap the JSON in prose despite instructions not to, it can produce almost-valid JSON (a trailing comma, an unescaped quote inside a string) that a strict parser rejects, and nothing stops it from inventing a field name or omitting a required one. Native structured-output and JSON-schema modes — covered for each vendor below — replace that parse-the-text approach with the vendor constraining which tokens the model is allowed to sample so that the output is JSON, and in the strict variants so that it also matches a specific schema, before your code ever sees it. The regex-extraction approach is worth recognizing when reading older code or a model/runtime that has no schema-aware mode, but for anything built new against a current API, the sections below are the preferred starting point.
JSON mode vs. JSON-schema / strict structured outputs
Both major hosted vendors expose two tiers of constraint, and the distinction matters for what you can assume downstream:
-
JSON mode (OpenAI’s older
\{"type": "json_object"}response format, and Ollama’sformat: "json") guarantees the output parses as JSON. It does not guarantee any particular field is present, correctly typed, or even the field you asked for by name — the model is still free to choose the object’s shape. Anthropic has no schema-less JSON mode: as of 2026-09-28 its structured outputs always take a JSON Schema. -
JSON-schema / strict structured outputs (Anthropic’s JSON outputs through
output_config: \{"format": \{"type": "json_schema", "schema": …}}on the ordinary/v1/messagesendpoint, plusstrict: trueon a tool definition for tool parameters; OpenAI’sjson_schematext format withstrict: true) additionally constrains the model’s token sampling so that the output matches a JSON Schema you supply: every required field present, every type correct, no extra fields whenadditionalProperties: falseis set. Both vendors implement this with grammar-constrained sampling — compiling the JSON Schema into a grammar that restricts which tokens are even eligible at each decoding step, the same family of technique covered generically in Sampling & Decoding as a constraint layered on top of ordinary temperature/top-p sampling, except here the constraint comes from a schema instead of a fixed vocabulary subset.
Locally run models get the equivalent guarantee through a different mechanism, covered next.
Constrained (grammar) decoding: Ollama and llama.cpp
Ollama and llama.cpp — Ollama’s own inference engine is in fact built on llama.cpp — expose the same
grammar-constrained idea for models you run yourself, without depending on a hosted vendor’s schema-aware
endpoint. Apple’s on-device Foundation Models framework applies the same idea in Swift: its @Generable guided
generation derives a schema from a structure and constrains decoding to it, as shown in
Apple Intelligence
and Machine Learning.
Ollama: the format field
Ollama’s /api/chat and /api/generate endpoints accept a format field that can be either the literal string
"json" (loose JSON-mode behavior) or a full JSON Schema object, which Ollama compiles into a grammar and uses
to constrain decoding exactly as the hosted vendors do for their strict modes:
curl http://localhost:11434/api/chat -d '{
"model": "llama3.1:8b",
"messages": [
{"role": "user", "content": "Extract the title, component and topics from this DocsAssistant page:\n\n== Configuring HNSW parameters\nThis page (component: pgvector-guide) covers ef_search, ef_construction and m for pgvector HNSW indexes."}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"title": {"type": "string"},
"component": {"type": "string"},
"topics": {"type": "array", "items": {"type": "string"}}
},
"required": ["title", "component", "topics"],
"additionalProperties": false
}
}'
See the Ollama structured outputs documentation for
the format field on both endpoints.
llama.cpp: GBNF grammars
Ollama’s format field is itself implemented on top of llama.cpp’s grammar system, but llama.cpp also exposes
that system directly through GBNF (a GGML-flavored Backus-Naur form): a grammar file of production rules that
define which sequences of characters are legal at each position in the output, passed to llama-cli or
llama-server with a --grammar (inline) or --grammar-file flag. A minimal GBNF rule for the same
\{title, component, topics} shape looks like this:
root ::= "{" ws "\"title\":" ws string "," ws "\"component\":" ws string "," ws
"\"topics\":" ws "[" ws (string ("," ws string)*)? ws "]" ws "}"
string ::= "\"" [^"]* "\""
ws ::= [ \t\n]*
llama.cpp also ships a converter that turns a JSON Schema straight into an equivalent GBNF grammar, so hand
writing rules like the ones above is rarely necessary for a schema you already have; it exists mainly for
grammars that constrain something other than JSON — a fixed set of command tokens, a restricted subset of a
programming language — where no JSON Schema equivalent applies. Either way, the mechanism is the same as
Ollama’s format and the hosted vendors' strict modes: legal next-tokens are filtered against the grammar’s
current state before sampling ever runs, so an invalid token is never eligible to be chosen in the first place,
rather than being generated and then rejected afterward.
Anthropic: JSON outputs with output_config.format
Anthropic’s recommended way to get a schema-conformant JSON answer is JSON outputs: pass
output_config: \{"format": \{"type": "json_schema", "schema": …}} on an ordinary messages.create call to
/v1/messages. There is no separate endpoint and, as of 2026-09-28, no beta header. The API compiles the schema
into grammar-constrained sampling, so the text block Claude returns always parses as JSON that matches the schema — correct types, all required fields present, no extra fields when additionalProperties: false is set:
import json
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
PAGE_METADATA_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string"},
"component": {"type": "string"},
"topics": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "component", "topics"],
"additionalProperties": False,
}
page_text = (
"== Configuring HNSW parameters\n"
"This page (component: pgvector-guide) covers ef_search, ef_construction and m "
"for pgvector HNSW indexes."
)
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract metadata from this page:\n\n{page_text}"}],
output_config={"format": {"type": "json_schema", "schema": PAGE_METADATA_SCHEMA}},
)
page_metadata = json.loads(response.content[0].text)
print(page_metadata) # {'title': 'Configuring HNSW parameters', 'component': 'pgvector-guide', 'topics': [...]}
The Python SDK also offers client.messages.parse(), which takes a Pydantic model as output_format, translates
it to output_config.format for you, and returns the validated object as response.parsed_output:
from pydantic import BaseModel
class PageMetadata(BaseModel):
title: str
component: str
topics: list[str]
response = client.messages.parse(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract metadata from this page:\n\n{page_text}"}],
output_format=PageMetadata,
)
print(response.parsed_output) # PageMetadata(title='Configuring HNSW parameters', ...)
Strict tool use is a separate, complementary guarantee: strict: true on a tool definition constrains the
tool parameters Claude sends (tool_use.input) to the tool’s input_schema, rather than the shape of Claude’s
answer. Use JSON outputs when the JSON is the answer, and strict tool use when the JSON is a call your code
executes — the search_docs example in Tool / function calling as structured output below shows the latter.
See the Anthropic structured outputs
documentation for output_config.format, messages.parse() and the supported JSON Schema subset, and the
Anthropic strict tool use
documentation for strict: true on tools.
OpenAI: a JSON-schema text format
OpenAI’s Responses API takes the JSON Schema directly on the request, through text.format with
\{"type": "json_schema", …} and strict: true, rather than routing the extraction through a tool call:
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
page_metadata_format = {
"type": "json_schema",
"name": "page_metadata",
"strict": True,
"schema": {
"type": "object",
"properties": {
"title": {"type": "string"},
"component": {"type": "string"},
"topics": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "component", "topics"],
"additionalProperties": False,
},
}
page_text = (
"== Configuring HNSW parameters\n"
"This page (component: pgvector-guide) covers ef_search, ef_construction and m "
"for pgvector HNSW indexes."
)
response = client.responses.create(
model="gpt-6-sol",
input=[
{"role": "user", "content": f"Extract title, component and topics from this page:\n\n{page_text}"}
],
text={"format": page_metadata_format},
)
import json
page_metadata = json.loads(response.output_text)
print(page_metadata)
The json_schema format is, per OpenAI’s own documentation, the direct successor to the older
\{"type": "json_object"} JSON mode: both guarantee syntactically valid JSON, but only json_schema with
strict: true guarantees the result matches the schema’s required fields, types and (when
additionalProperties: false is set) exact field set — OpenAI’s guidance is to prefer it over plain JSON mode
whenever the target shape is known ahead of time, which it is for every example on this page.
See the OpenAI structured outputs guide for
the text.format/json_schema shape (and its Chat Completions equivalent, response_format) and its per-model
support.
Pydantic: parse, validate and retry
A JSON-schema mode still only guarantees the wire format is correct; it says nothing about domain rules a
plain JSON Schema cannot express (a component value that must be one of this section’s actual component
names, a non-empty topics list). Pydantic closes that gap: keep output_config.format on the request so the
shape is guaranteed, define the domain rules on a BaseModel, parse each response into it, and treat a
ValidationError as a retry signal — feeding the validation error’s own
message back to the model so the next attempt has a concrete reason to correct, rather than repeating the same
mistake:
from pydantic import BaseModel, ValidationError, field_validator
KNOWN_COMPONENTS = {"pgvector-guide", "hermes", "irurueta-algebra"}
class PageMetadata(BaseModel):
title: str
component: str
topics: list[str]
@field_validator("component")
@classmethod
def component_must_be_known(cls, value: str) -> str:
if value not in KNOWN_COMPONENTS:
raise ValueError(f"'{value}' is not a known DocsAssistant component")
return value
@field_validator("topics")
@classmethod
def topics_must_not_be_empty(cls, value: list[str]) -> list[str]:
if not value:
raise ValueError("topics must contain at least one entry")
return value
def extract_page_metadata(client, page_text: str, max_attempts: int = 3) -> PageMetadata:
correction = ""
for _ in range(max_attempts):
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": (
f"Extract title, component and topics from this page:\n\n{page_text}"
f"{correction}"
),
}],
# Same schema as the Anthropic section above: the shape is guaranteed by the API.
output_config={"format": {"type": "json_schema", "schema": PAGE_METADATA_SCHEMA}},
)
try:
# Only the domain-rule validators can still fail here.
return PageMetadata.model_validate_json(response.content[0].text)
except ValidationError as error:
correction = f"\n\nYour previous answer broke a rule: {error}. Correct it."
raise RuntimeError(f"could not extract valid page metadata after {max_attempts} attempts")
The loop reuses PAGE_METADATA_SCHEMA from the Anthropic section rather than PageMetadata.model_json_schema(),
because Pydantic’s generated schema omits additionalProperties: false unless the model sets
extra="forbid". It combines with the schema-conformance guarantee rather than replacing it: JSON outputs already
rule out a missing field or wrong type, so the retries this loop spends are used
entirely on the domain rules a JSON Schema cannot express (component_must_be_known, topics_must_not_be_empty) — a narrower, cheaper problem than validating the whole shape from scratch on every attempt. See the
Anthropic structured outputs
documentation for output_config.format on messages.create.
Jackson: the JVM equivalent
On the JVM, Jackson plays the role Pydantic’s parsing plays in Python: deserializing a JSON string into a typed
object. Spring Boot 4.1 uses Jackson 3, whose packages moved to tools.jackson, whose mapper is the immutable
JsonMapper built with JsonMapper.builder(), and whose base exception JacksonException is now unchecked. Domain
rules go on the record components as Bean Validation annotations, which a record supports without any compact
constructor:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
public record PageMetadata(
@NotBlank String title,
@NotBlank String component,
@NotNull @Size(min = 1) List<String> topics) {}
Jackson does not fail closed on a missing field by default: a missing title simply becomes null. Enable
DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES (and FAIL_ON_NULL_CREATOR_PROPERTIES for an explicit
null) to make it raise. Jackson also never looks at the Bean Validation annotations, so the code calls a
Validator explicitly after deserializing:
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Set;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
JsonMapper mapper = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)
.build();
Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); // or inject Spring's Validator bean
try {
PageMetadata metadata = mapper.readValue(rawJsonFromModel, PageMetadata.class);
Set<ConstraintViolation<PageMetadata>> violations = validator.validate(metadata);
if (!violations.isEmpty()) {
// retry with the violation messages fed back to the model, same as the Pydantic loop above
}
} catch (JacksonException e) {
// retry with the exception's message fed back to the model
}
With both features on, a wrong shape raises JacksonException and a broken domain rule shows up as a
ConstraintViolation, so the same retry-on-failure pattern from the Pydantic section applies. See the
Jackson 3 migration guide for
the tools.jackson packages, JsonMapper.builder() and the unchecked JacksonException, and the
Hibernate Validator reference for
Validator.validate().
Spring AI: ChatClient.entity()
Spring AI wraps exactly this parse-and-validate step behind ChatClient’s `entity() call, so application code
never touches raw JSON or ObjectMapper directly. Internally it appends the target type’s JSON Schema as format
instructions to the prompt (a BeanOutputConverter, covered in more structural depth for the vector-database
and RAG scenario in
Integrating with Spring AI) and deserializes the
response with Jackson, raising the same way ObjectMapper.readValue does on a mismatch:
public record PageMetadata(String title, String component, List<String> topics) {}
PageMetadata metadata = chatClient.prompt()
.user(u -> u.text("""
Extract title, component and topics from this DocsAssistant page:
{pageText}
""")
.param("pageText", pageText))
.call()
.entity(PageMetadata.class);
entity() accepts either a Class<T> (as above) or a ParameterizedTypeReference<T> for a generic target such
as List<PageMetadata>. Spring AI’s own documentation describes this conversion as best-effort rather than a
schema-conformance guarantee equivalent to Anthropic’s JSON outputs or OpenAI’s strict structured outputs — it depends on the underlying model actually following the appended format instructions — so pairing it with
validation (Bean Validation annotations, or a manual check before the value is used) remains worthwhile even
though entity() already removes the manual JSON parsing step.
Tool / function calling as structured output
Every example so far asked the model to return structured data as the final answer. Tool calling is the same
underlying mechanism — the model still emits a schema-shaped JSON object rather than free text — pointed at a
different purpose: instead of the JSON being the answer, it is a request your application executes, whose
result is fed back so the model can produce a final, free-text answer. A search_docs tool for DocsAssistant
illustrates the full round trip:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
search_docs_tool = {
"name": "search_docs",
"description": "Search DocsAssistant's indexed documentation pages for a query.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"component": {"type": "string", "description": "Restrict the search to one component"},
},
"required": ["query"],
"additionalProperties": False,
},
}
messages = [{"role": "user", "content": "What does ef_search control in pgvector's HNSW index?"}]
# 1. The model requests a tool call instead of answering directly.
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=[search_docs_tool],
messages=messages,
)
tool_use = next(block for block in response.content if block.type == "tool_use")
print(tool_use.name, tool_use.input) # search_docs {'query': 'ef_search HNSW pgvector', 'component': 'pgvector-guide'}
# 2. The application executes the real search and returns a tool_result.
search_result = "ef_search controls how many candidate nodes HNSW visits per query; higher values raise recall and latency."
messages += [
{"role": "assistant", "content": response.content},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": tool_use.id, "content": search_result}
]},
]
# 3. The model turns the tool result into the final, free-text answer.
followup = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=[search_docs_tool],
messages=messages,
)
final_text = next(block for block in followup.content if block.type == "text")
print(final_text.text)
The strict: true flag on search_docs_tool is the tool-parameter counterpart of the JSON outputs used
for extraction above: it guarantees tool_use.input always has a query string and never a stray extra field, so the
application’s tool-executing code can trust the call’s shape without its own defensive parsing. The round trip
itself — request, execute, return, final answer — is the same shape regardless of which vendor’s SDK is used;
Context Windows & Memory covers what happens to this
exchange once several tool calls accumulate across a longer conversation, and the "Best practices for tool
definitions" section of
Anthropic’s Define tools
documentation covers writing the tool description field well enough that the model reaches for the right tool
without being forced into it via tool_choice.
Validation and retry as a general pattern
Across every technique on this page, "validate then retry with the error fed back" is the same three-step loop regardless of which layer produced the invalid output:
-
Schema-level failures (wrong type, missing required field) are what strict structured-output modes exist to eliminate up front — prefer catching these before the call returns rather than after, since a schema violation is exactly the class of error grammar-constrained sampling is built to prevent.
-
Domain-level failures (a
componentthat parses fine as a string but is not one this section actually has, atopicslist that is technically non-empty but nonsensical) cannot be expressed in a JSON Schema and are what Pydantic’s@field_validator, Bean Validation annotations, or a hand-written check afterentity()exist to catch. -
The retry itself should always feed the concrete validation error back into the next prompt (as the Pydantic loop above does), rather than merely re-asking the same question — a model given no information about why its previous answer failed has no more reason to succeed on attempt two than on attempt one.
A production system typically bounds the retry loop (three attempts is a common default) and falls back to a human-reviewed queue or a rejection rather than looping indefinitely against a model that keeps failing the same validation.
References
-
Anthropic: Strict tool use (verified 2026-09-28)
-
Anthropic: Structured outputs (verified 2026-09-28)
-
Anthropic: Tool use overview (verified 2026-09-28)
-
Anthropic: Define tools (verified 2026-09-28)
-
OpenAI: Structured model outputs (verified 2026-09-28)
-
Ollama: Structured outputs (verified 2026-09-28)
-
llama.cpp: Grammars (GBNF) (verified 2026-09-28)
-
Spring AI: Structured output converter (verified 2026-09-28)
-
Jackson: Migrating to Jackson 3 (verified 2026-09-28)
-
Hibernate Validator: Reference guide (verified 2026-09-28)