Quick answer: Structured outputs are a way to force an LLM to return data in a fixed shape, usually JSON that matches a schema you define. Instead of parsing free-form prose and hoping the model wrapped its answer in the format you asked for, you constrain the output at the API level so json.loads() never surprises you. This is the difference between a demo and a production data pipeline.
This post is about getting reliable structured data out of an LLM. That is a different problem from pulling structured data out of a webpage, which we cover in extract structured JSON from any webpage. Here the concern is the contract between your code and the model: when you ask for a JSON object with four specific fields, you get exactly that, every time.
Why "just ask for JSON" is not enough
The naive approach is to write "respond with JSON" in your prompt and parse whatever comes back. It works most of the time, which is precisely what makes it dangerous. The failure modes are:
- The model wraps the JSON in a markdown code fence, or adds a "Here is the JSON:" preamble.
- A field is missing, or has a slightly different name than you expected.
- A value that should be a number comes back as a string, or a boolean comes back as the word "yes."
- On a long or complex input, the model occasionally emits invalid JSON that will not parse at all.
At 98% reliability, a batch job over 10,000 records fails roughly 200 times. Each failure is a retry, a log line, or a silent data corruption. Structured outputs move you toward guaranteed-valid parsing so those 200 failures do not happen.
Three mechanisms, from loosest to strictest
There are three common ways to get structured data out of an LLM. They differ in how strongly the format is enforced.
| Mechanism | What it does | Enforcement |
|---|---|---|
| Prompt instruction | You ask for JSON in plain language | None; best effort |
| JSON mode | The API guarantees syntactically valid JSON | Valid JSON, but not your schema |
| Schema-constrained output | The API guarantees JSON matching your exact schema | Full; field names, types, required fields |
Prompt instruction alone is the loosest. JSON mode guarantees the output parses, but does not guarantee it has the fields you wanted. Schema-constrained output is what you want in production: the response validates against the schema you supplied, so both the shape and the types are guaranteed.
Defining a schema
The cleanest way to work with schema-constrained output is to define your target shape as a typed model and let the SDK convert it to a JSON schema. With the Anthropic Python SDK and Claude, messages.parse() validates the response against a Pydantic model for you.
from pydantic import BaseModel
import anthropic
class SupportTicket(BaseModel):
category: str
priority: str
customer_name: str
refund_requested: bool
client = anthropic.Anthropic()
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{
"role": "user",
"content": (
"Extract ticket fields from this message: "
"'Hi, this is Dana Reyes. My order never arrived and I want "
"my money back. This is urgent.'"
),
}],
output_format=SupportTicket,
)
ticket = response.parsed_output # a validated SupportTicket instance
print(ticket.category) # e.g. "shipping"
print(ticket.priority) # e.g. "high"
print(ticket.refund_requested) # True
response.parsed_output is a real SupportTicket object, already validated. There is no string parsing, no code fence to strip, no missing-field guard to write.
If you prefer raw JSON Schema instead of a typed model, pass it through output_config.format:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "..."}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "medium", "high"]},
"customer_name": {"type": "string"},
"refund_requested": {"type": "boolean"},
},
"required": ["category", "priority", "customer_name", "refund_requested"],
"additionalProperties": False,
},
}
},
)
Note the enum on priority. That constrains the value to one of three strings, so you never have to normalize "High" versus "high" versus "urgent" downstream. Constraining the vocabulary in the schema is one of the most useful things you can do.
Validation and retries
Schema-constrained output removes most failure modes, but not all of them. Two remain worth handling:
- The model hits its token limit and the output is truncated. Check the stop reason; if it is
max_tokens, raise the limit and retry rather than trying to parse a half-finished object. - The model declines the request for safety reasons. A refusal will not match your schema, so branch on the stop reason before you read the content.
A small wrapper covers both:
def extract(client, messages, schema_model, retries=2):
for attempt in range(retries + 1):
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=2048,
messages=messages,
output_format=schema_model,
)
if response.stop_reason == "max_tokens":
continue # truncated; retry with a fresh attempt
if response.stop_reason == "refusal":
raise ValueError("Model declined the request")
return response.parsed_output
raise RuntimeError("Output kept truncating; raise max_tokens")
For anything worth guarding this carefully, add domain validation on top: check that a date is in the past, that an amount is non-negative, that an email looks like an email. The schema guarantees the shape; your code guarantees the meaning.
Common use cases
Structured outputs turn an LLM into a reliable component you can wire into a larger system. The recurring patterns are:
- Extraction. Pull fields out of unstructured text: invoices, resumes, support messages, contracts.
- Classification. Assign a label from a fixed set. The
enumconstraint makes this bulletproof. - Routing. Have the model decide which of several downstream handlers a request should go to, and return that decision as a typed field.
- Enrichment. Take a messy record and return a cleaned, normalized version with consistent field names and types.
Structured input meets structured output
A frequent pattern is to fetch content from the web, then extract structured data from it with an LLM. The cleaner your input, the more reliable your output. If you scrape a page as raw HTML, the model spends effort ignoring navigation and ads before it even reaches the content you care about.
Fetching the page as clean markdown first gives the model a focused input:
curl https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/product/42", "format": "markdown"}'
Then pass that markdown to your schema-constrained extraction call. Clean input plus a strict schema is the combination that makes web-to-data pipelines dependable. You can see how link.sc fits into that flow in the docs.
The bottom line
Structured outputs are the contract that lets you treat an LLM like any other function that returns typed data. Define the schema, enforce it at the API level, validate the meaning in your code, and handle the two remaining failure modes (truncation and refusal). Do that and the model becomes a component you can build on, rather than a source of parsing surprises.
Feeding web content into your extraction pipeline? Try link.sc free and turn any URL into clean markdown before your LLM ever sees it.