What is Structured Output?
Structured Output is an AI design pattern for LLM apps and agents, in the Composition group. Require the model to answer in a schema and validate the response.
Bianca works in finance, and she automated reading the invoices her suppliers send. Every company in Brazil is identified by a tax ID, fourteen digits with two check digits at the end.
Her prompt asked: return the issuer's tax ID, the issue date and the total amount, as structured data. In the first week, it worked on almost everything.
Then the odd cases started. One reply opened with a friendly sentence: sure, here's the data. Another returned the total as text, with the currency symbol, when the system expected a number. A third gave the field a different name.
Each case became one more patch in the code. The parser grew larger than the business logic, and it still failed a few times a day. In this lesson, you'll see how to get the model to deliver data your code can use without guessing. And why that's only half of the job.
The fix starts with a change of mindset. Stop asking for a format in the prompt text, and start declaring the format as part of the call.
Current APIs accept a schema, a description of the fields, their types and their allowed values. And they constrain generation so the output follows it. It's the difference between handing someone a blank sheet of paper, and handing them a form with labeled fields.
But here's the part people miss. The schema guarantees shape, not truth.
A tax ID can have fourteen digits and still be invalid. So the second half of the pattern is checking business rules in code. Check digits, a date in the past, a positive amount. Shape guaranteed by the API, content checked by you.
Let's follow one invoice. The input is the text that needs to become data.
The call carries the schema. The model can only generate outputs in that shape.
The SDK turns the response into a typed object. No regular expressions, no hunting for braces.
Then your code checks the rules the schema can't express.
Valid data goes straight into the accounting system.
And data with the right shape, but suspicious content, goes to a person for review.
Here's a real invoice from a bakery. A tax ID, an issue date in August, a total of one thousand two hundred thirty-four dollars, and two items, bread rolls and coffee.
With return the data in the prompt, a realistic reply had three shape failures at once. Text around the data, the wrong field name, and the total as text with a currency symbol. Bianca's parser needed three fixes. The next week, a fourth showed up.
With a declared schema, the API can only emit four fields. The issuer's tax ID, the issue date, the total as a number, and the list of items. The SDK hands back an object.
And then Bianca's code still has work to do. The tax ID has to pass the check digit rule.
The items have to add up to the total.
And the issue date can't be in the future. If any of these fail, the invoice goes to review, with the list of what failed.
Here's one for you. The model returned an object exactly in the schema. The tax ID has fourteen digits. But the check digits don't match. What does the pattern say to do?
Validate the check digits in code, and send it to human review when they fail. Shape isn't truth. An invalid fourteen digit value passes a text schema and breaks the issuer's record.
And asking again, over and over, until it passes? A retry on the same document usually returns the same misread. The review queue exists exactly for the case where the source is unreadable, or the model misread it.
Structured output isn't for everything. If the output is text for a person to read, like a chat reply, forcing a schema only stiffens the writing. Nobody's going to parse it.
If it's a short free text the code only displays, same thing.
And if your schema would be one field called answer, of type text, it guarantees nothing. It just adds ceremony.
Now the most common mistake. Every invoice has an issuer. But not every service contract has a penalty. If the penalty field is required, the model invents something like ten percent standard, just so it doesn't break the schema.
So mark as optional whatever can really be missing. And measure it. In your eval, include documents where the field doesn't exist, and count how often the model returns empty, versus how often it invents. And prefer a closed list of values over open text. An open text field is how super urgent ends up in your priority column.
With a native schema, the output is almost always valid. So valid stops being a metric. It hides content errors.
Evaluate field by field, against labeled values. And normalize first. The invoice says one thousand two hundred thirty-four dollars with a comma, and the label holds the number. Compare text to text, and the eval punishes formatting while a wrong tax ID with the right punctuation slips through.
Measure the invention rate on documents where the field truly doesn't exist.
And track what goes to human review, and how much of it was a real error. In production, a validation rule that suddenly fires a lot usually means a supplier changed their layout.
Version the schema the way you version the prompt, and record both on every trace. A new required field pushes invention on old documents. A removed field breaks code that still reads the old name.
And one security point. Structured output removes one class of risk. Your code stops interpreting free text that could contain anything. But the field values still come from a model that read an outside document. Treat them as untrusted input. A description field copied into a web page can carry a script. A field pasted into a database query is an injection. Escape before displaying, use parameters in queries, and never execute a field as code.
On the page, the same extraction comes in four versions. They all start the same way: one call, with the schema attached, that returns a typed object.
Then three checks in code. The check digits, the date, and the sum. Anything that fails goes to review. Everything else is saved.
What changes between versions is the provider and the type system. In Python, one class defines both the schema and the type. In TypeScript, a Zod object does the same, and the compiler knows every field. There's a version on OpenAI's SDK with the very same schema. And LangChain wraps both providers, so the business checks don't change when the model does.
So here's the idea to take with you. Don't ask the model for a format. Declare it, and let the API hold the shape. Then check the content in code, because a perfect form can still be filled in wrong.
In the next lesson, we'll use this same idea in the other direction: the model filling in a form that asks your code to do something. Tools and function calling. See you there.
Saída Estruturada
Structured Output is an AI design pattern for LLM apps and agents, in the Composition group. Require the model to answer in a schema and validate the response.
Require the model to answer in a defined schema and validate the answer in code before using it. It applies to any output another program will read: extraction, classification, routing, API calls, data headed for a database.
A form with labeled fields instead of a blank sheet of paper.
Bianca automated reading supplier invoices. The examples on this page use Brazilian invoices (NF-e), where every company is identified by a CNPJ, a 14-digit tax ID with two check digits. The prompt asked: “return JSON with the issuer’s CNPJ, the issue date, and the total amount”. In the first week it worked on almost everything.
Then the odd cases started. One invoice came back with the JSON wrapped in triple backticks and the word json. Another returned the total as "R$ 1.234,56", a string, when the system expected a number. A third named the field issuer_tax_id instead of issuer_cnpj. And in one of them the model replied “Sure! Here is the JSON:” before the braces.
Each case became a try, a replace, and a regular expression in the code. The parser grew larger than the business logic, and it still failed a few times a day.
The question this pattern answers: how do you get the model to deliver data your code can use without guessing?
Stop asking for a format in the prompt text and start declaring the format as part of the call. Current APIs accept a JSON schema (or a Pydantic class, or a Zod object) and constrain generation so the output follows that schema: field names, types, and allowed values.
The schema guarantees shape, not truth. A CNPJ can have 14 digits and still be wrong. That is why the second half of the pattern is validating business rules in code: check digits, a date in the past, a positive amount. Shape guaranteed by the API, content checked by you.
The refactoring that lands here is Replace Free Text with Structured Output. Use it when the JSON parser has become the product.
flowchart LR
IN(["1. Invoice text"]):::io --> M(["2. Model with schema"]):::llm
M --> P["3. Typed parse"]:::tool
P --> V{"4. Business rules OK?"}
V -->|"yes"| DB[("5. Accounting system")]:::store
V -->|"no"| Q((("6. Review queue"))):::human
Bianca receives this invoice (excerpt). Brazilian invoices (NF-e) identify every company by a CNPJ, a 14-digit tax ID with two check digits:
ISSUER: Padaria São Jorge Ltda
CNPJ: 11.222.333/0001-81
Issue date: 14/08/2026
Total: R$ 1.234,56
Items: French rolls 800un R$ 800,00 | Coffee 20kg R$ 434,56
With “return JSON” in the prompt, a realistic reply was a short paragraph (“Sure! Here is the JSON:”), triple backticks, the field cnpj_emissor instead of issuer_cnpj, and the total as the string "R$ 1.234,56".
Three shape failures in one reply: surrounding text, the wrong field name, a number as a string with a currency symbol. Bianca’s parser needed all three fixes, and the next week a fourth showed up.
With a native schema, the API can only emit the fields issuer_cnpj, issue_date, total, items. The SDK returns an object. Then Bianca’s code still has work: the CNPJ 11222333000181 has to pass the check digits, the item sum has to match the total, the date cannot be in the future.
The Padaria São Jorge CNPJ in this example is illustrative. In production, use the check-digit validator; fourteen identical digits are already invalid under the rule.
schema Invoice {
issuer_cnpj: string # 14 digits
issue_date: date
total: float
items: list of { description: string, amount: float }
}
function read_invoice(text):
# 2 and 3. shape guaranteed by the API
invoice = llm(EXTRACT_PROMPT, text) -> Invoice
# 4. content checked in code
errors = []
if not valid_cnpj(invoice.issuer_cnpj): errors.add("cnpj")
if invoice.issue_date > today(): errors.add("future date")
if abs(sum(invoice.items.amount) - invoice.total) > 0.01: errors.add("sum")
if errors: return human.review(invoice, errors) # 6
return accounting.save(invoice) # 5
answer: string field) that it guarantees nothing. Then it only adds ceremony.Every NF-e has an issuer. Not every service contract has a penalty. If penalty is required, the model invents “10% standard” so it does not break the schema. In the eval, that shows up as the invention rate: documents without the field should return null.
Mark as optional whatever can be missing. Use an enum for priority, product, invoice type. An open string is the loose schema that lets "super urgent" through.
Structured output is also not for the chat reply a person reads. Forcing JSON there stiffens the writing and does not help the code, because nobody is going to parse it.
messages.parse with Pydantic or Zod).Do not evaluate “valid JSON”. With a native schema that almost always passes and hides content errors. Evaluate issuer_cnpj, total, issue_date against the label, after normalization (digits only in the CNPJ, ISO date).
strict: true and the model “calls” it with the data. Useful when the same call also uses other tools.Version the schema the way you version the prompt. A new required field pushes invention on old documents in the eval. A removed field breaks code that still reads the old name. Store schema_version on the span next to the prompt version, so a spike in “invalid tax ID” points at the right deploy.
When the provider still does not constrain generation, do not pretend that asking for JSON in the text is enough. Validate, and on failure, enter repair with the parser’s error message. That is a different pattern, and its retry cap is the same cap as any loop.
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | None extra; the schema rides on the same call | same span as before, with the schema attached |
| Latency | The first call with a new schema can take longer | higher duration only the first time |
| Cost | The schema counts as input; format instructions shrink | input tokens similar to before |
| Failure: invented field | A required field that is not in the source comes back filled in | plausible value with no matching passage in the input |
| Failure: right shape, wrong content | An invalid 14-digit CNPJ, swapped date | business validation stopping it in the next span |
| Failure: schema too loose | string accepts anything |
unexpected values reaching the database |
Evaluate field by field. “The JSON was valid” is no longer a metric: with native structured output it almost always is.
null or invent something?An eval case:
The label needs the canonical value, not the invoice text. R$ 1.234,56 on the PDF becomes 1234.56 in expected. If you compare string to string, the eval punishes formatting and lets a wrong CNPJ with the right punctuation through.
Include bad documents on purpose: crooked scans, missing fields, two similar amounts on the same page. The easy case does not teach the schema. The case with two totals (the cover and the last page) teaches whether the model guesses the larger one.
{ "input": "invoices/supplier-118.txt", "expected": { "issuer_cnpj": "11222333000181", "issue_date": "2026-08-14", "total": 1234.56 } }
Structured output removes one class of risk: your code stops interpreting free text that could contain anything. But field contents still come from a model that read an external document. Treat the values as untrusted input: escape before displaying, use parameters in SQL queries, and never execute a field as code.
The OWASP Top 10 for LLM Applications covers this as insecure output handling: the model is not a trusted source, even when the shape is right. A description field copied into HTML is XSS. A query field interpolated into SQL is injection. A schema does not sanitize.
| Pros | Cons |
|---|---|
| Ends fragile parsers and regular expressions | The schema becomes one more contract to version |
| The output type is known to the code and the compiler | The right shape can create a false sense of right data |
| Enums keep values inside the list | Poorly designed required fields induce invention |
| Makes per-field evals easy | Not suited to free text meant for people |
The tabs extract the same invoice with a schema and validate the CNPJ and the sum in code. Python and TypeScript use the Anthropic SDK. The OpenAI tab is the same call on another provider. LangChain hides the envelope for both.
Python. Use this when the backend is Python. Pydantic defines the schema and the type at once.
from datetime import date
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
class Item(BaseModel):
description: str
amount: float
class Invoice(BaseModel):
issuer_cnpj: str
issue_date: date
total: float
items: list[Item]
def valid_cnpj(cnpj: str) -> bool:
digits = [int(c) for c in cnpj if c.isdigit()]
if len(digits) != 14 or len(set(digits)) == 1:
return False
for size in (12, 13):
weights = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2][-size:]
check = sum(d * w for d, w in zip(digits, weights)) % 11
if digits[size] != (0 if check < 2 else 11 - check):
return False
return True
def read_invoice(text: str) -> Invoice | list[str]:
# 2 and 3. shape guaranteed by the API
invoice = client.messages.parse(
model="claude-opus-5-5",
max_tokens=4096,
system="Extract the invoice data. CNPJ as digits only. Amounts in BRL, as numbers.",
messages=[{"role": "user", "content": text}],
output_format=Invoice,
).parsed_output
# 4. content checked in code
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("future date")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("sum")
return errors or invoiceWhat you gained: a typed object, no parser. What you lost: nothing you had before; business validation is still yours.
TypeScript. Use this when the backend is Node. Zod defines the schema and the type.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const Invoice = z.object({
issuer_cnpj: z.string(),
issue_date: z.string(), // YYYY-MM-DD
total: z.number(),
items: z.array(z.object({ description: z.string(), amount: z.number() })),
});
type Invoice = z.infer<typeof Invoice>;
function validCnpj(cnpj: string): boolean {
const d = cnpj.replace(/\D/g, "").split("").map(Number);
if (d.length !== 14 || new Set(d).size === 1) return false;
for (const size of [12, 13]) {
const weights = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2].slice(-size);
const check = weights.reduce((acc, w, i) => acc + w * d[i], 0) % 11;
if (d[size] !== (check < 2 ? 0 : 11 - check)) return false;
}
return true;
}
export async function readInvoice(text: string): Promise<Invoice | string[]> {
// 2 and 3. shape guaranteed by the API
const { parsed_output: invoice } = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 4096,
system: "Extract the invoice data. CNPJ as digits only. Amounts in BRL, as numbers. Date as YYYY-MM-DD.",
messages: [{ role: "user", content: text }],
output_config: { format: zodOutputFormat(Invoice) },
});
if (!invoice) return ["schema"];
// 4. content checked in code
const errors: string[] = [];
if (!validCnpj(invoice.issuer_cnpj)) errors.push("cnpj");
if (new Date(invoice.issue_date) > new Date()) errors.push("future date");
const sum = invoice.items.reduce((acc, i) => acc + i.amount, 0);
if (Math.abs(sum - invoice.total) > 0.01) errors.push("sum");
return errors.length ? errors : invoice;
}What you gained: the compiler knows every field. What you lost: dates arrive as text and need converting.
Python + OpenAI. Use this when the backend is Python and the provider contract is with OpenAI.
from datetime import date
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
def read_invoice(text: str) -> Invoice | list[str]:
# 2 and 3. shape guaranteed by the API. Invoice is the class from the Python tab.
invoice = client.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extract the invoice data. CNPJ as digits only. Amounts in BRL, as numbers."},
{"role": "user", "content": text},
],
response_format=Invoice,
).choices[0].message.parsed
if invoice is None:
return ["schema"]
# 4. content checked in code
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("future date")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("sum")
return errors or invoiceWhat you gained: the same Pydantic schema, on another SDK. What you lost: the name choices[0].message belongs to OpenAI; do not let it leak into the billing module.
Python + LangChain. Use this when extraction needs the same function on more than one provider.
from datetime import date
from langchain.chat_models import init_chat_model
MODEL = "openai:gpt-4o-mini" # or "anthropic:claude-opus-5-5"
reader = init_chat_model(MODEL, temperature=0).with_structured_output(Invoice)
def read_invoice(text: str) -> Invoice | list[str]:
# 2 and 3. the schema is the same Pydantic object as the Python tab
invoice = reader.invoke([
("system", "Extract the invoice data. CNPJ as digits only. Amounts in BRL, as numbers."),
("human", text),
])
# 4. content checked in code
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("future date")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("sum")
return errors or invoiceWhat you gained: the business checks do not change when the model prefix changes. What you lost: one more dependency for a call the SDK already made.
Checked against the OpenAI and LangChain 1.x docs in October 2026.
| Library | In this pattern | Still yours |
|---|---|---|
| Anthropic SDK | messages.parse with Pydantic or Zod |
CNPJ, date, and the sum |
| OpenAI SDK | chat.completions.parse with the same Pydantic model |
The same rules |
| LangChain | with_structured_output |
The same rules, without the provider envelope |
| Instructor or Pydantic AI | The schema on the call, if the team already uses one of these | The checks the schema cannot express |
Answer the 6 questions to complete this lesson. Watching alone does not complete it: the check is what counts toward the trail and the certificate.
Structured Output is an AI design pattern for LLM apps and agents, in the Composition group. Require the model to answer in a schema and validate the response.
In the real-world scenarios Invoice and contract extraction, Automatic ticket triage, and Resume screening with bias control.