← All Articles
July 16, 2026
6 min read
AI Engineering · Claude · Data Pipelines
Structured Outputs Changed How I Build AI Systems
For years I built AI systems the same way everyone did: ask Claude a question, get back text, parse the text with regex. It worked. Sort of. Until it didn’t.
A slight change in Claude’s phrasing and your regex broke. You added more patterns. Your code got more fragile. Every model update felt like a gamble.
Then structured outputs arrived — JSON schemas, tool use, typed responses. It changed how I build everything downstream of a model call. This is the difference between systems that work and systems you can actually trust.
The Old Way
Before structured outputs, you’d do this:
import anthropic
import re
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[
{
"role": "user",
"content": """Analyze this invoice and extract:
- Vendor name
- Invoice total
- Due date
Format your response exactly as:
VENDOR: [name]
TOTAL: [amount]
DUE: [date]"""
}
]
)
response_text = message.content[0].text
# Now parse with regex
vendor_match = re.search(r"VENDOR:\s*(.+?)(?:\n|$)", response_text)
total_match = re.search(r"TOTAL:\s*(\$?[\d,]+\.?\d*)", response_text)
due_match = re.search(r"DUE:\s*(\d{1,2}/\d{1,2}/\d{4})", response_text)
if vendor_match and total_match and due_match:
vendor = vendor_match.group(1).strip()
total = total_match.group(1)
due_date = due_match.group(1)
print(f"Success: {vendor}, {total}, {due_date}")
else:
print(f"Parse error. Raw response: {response_text}")
This works. I’ve built dozens of systems like this. It’s also fragile:
- Claude might say “$1,234.56” or “1234.56” or “USD 1234.56.” Your regex matches one, breaks on the other.
- The due date might come back as “2024-05-15” or “May 15, 2024” or “15/05/2024.” Same problem.
- Claude might wrap the vendor name in quotes or add extra spaces. Parser breaks.
- Upgrade the model or nudge the prompt, and output format drifts. Back to debugging regex.
The deeper problem: you’re outsourcing structure to Claude’s text generation. Claude’s job is to be helpful, not to generate machine-readable formats. If the task is reasoning about an invoice, Claude focuses on reasoning. Formatting comes second.
So you build defensive parsing. More regex. Try/catch blocks. Fallback patterns. The code becomes 30% business logic, 70% parsing duct tape — until one day Claude shifts its phrasing slightly and everything breaks. Now you’re debugging production at 11pm because a comma moved.
The New Way: Structured Outputs
Now you can ask Claude to return structured data against a JSON schema. Guaranteed format.
import anthropic
import json
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[
{
"role": "user",
"content": "Extract structured data from this invoice..."
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "InvoiceData",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {
"type": "string",
"description": "Name of the vendor/company issuing the invoice"
},
"invoice_total": {
"type": "number",
"description": "Total amount due in USD"
},
"due_date": {
"type": "string",
"description": "Due date in YYYY-MM-DD format"
}
},
"required": ["vendor_name", "invoice_total", "due_date"]
}
}
}
)
# Parse the JSON response
data = json.loads(message.content[0].text)
vendor = data["vendor_name"]
total = data["invoice_total"]
due_date = data["due_date"]
print(f"Success: {vendor}, {total}, {due_date}")
This is different. You’re not parsing Claude’s text anymore. Claude is returning structured data that conforms to a schema you define. The format is guaranteed, so there’s no regex and no fragility.
If the model version changes, the response format doesn’t. Your code keeps working.
Why This Matters
Reliability. Your parser won’t break on model updates or phrasing changes — the schema is the contract.
Validation. Invalid data doesn’t leave Claude’s response in the first place. If a required field is missing, you know immediately, with no edge case where parsing "succeeds" but the data is malformed.
# With regex: you might get vendor_name = None if the pattern didn't match
# With structured outputs: vendor_name is always a string, or the API fails
# No ambiguity
Type safety. Your data has types. invoice_total is a number, not a string, so there are no conversion errors downstream.
Cost and speed. A tighter schema means a more specific prompt — Claude spends tokens on the data you need instead of formatting, which means shorter responses and lower cost. And parsing JSON is a lot faster than regex matching.
Real Example: Invoice Processing
Here’s the before-and-after for a document extraction pipeline.
Before (regex-based):
def parse_invoice_response(text: str) -> dict:
result = {}
# Vendor name - try multiple patterns
vendor_pattern = r"(?:Vendor|Company|From):\s*(.+?)(?:\n|$)"
vendor_match = re.search(vendor_pattern, text, re.IGNORECASE)
if vendor_match:
result["vendor_name"] = vendor_match.group(1).strip().strip('"')
else:
result["vendor_name"] = None
# Invoice total - handle multiple formats
total_patterns = [
r"[Tt]otal:?\s*\$?([\d,]+\.?\d*)",
r"[Aa]mount [Dd]ue:?\s*\$?([\d,]+\.?\d*)",
r"\$\s*([\d,]+\.?\d*)",
]
for pattern in total_patterns:
total_match = re.search(pattern, text)
if total_match:
total_str = total_match.group(1).replace(",", "")
try:
result["invoice_total"] = float(total_str)
break
except ValueError:
continue
if "invoice_total" not in result:
result["invoice_total"] = None
# Due date - handle multiple date formats
date_patterns = [
r"[Dd]ue:?\s*(\d{1,2}/\d{1,2}/\d{4})",
r"[Dd]ue:?\s*(\d{4}-\d{2}-\d{2})",
r"[Dd]ue:?\s*([A-Z][a-z]+\s+\d{1,2},?\s+\d{4})",
]
for pattern in date_patterns:
due_match = re.search(pattern, text)
if due_match:
result["due_date"] = due_match.group(1)
break
if "due_date" not in result:
result["due_date"] = None
return result
# In production:
response_text = claude_api_call(invoice_text)
invoice_data = parse_invoice_response(response_text)
if not invoice_data.get("vendor_name"):
# Fallback logic
log_error("Could not parse vendor name")
# Maybe try again? Maybe ask for human review?
After (structured outputs):
def extract_invoice_data(invoice_text: str) -> dict:
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=512,
messages=[{"role": "user", "content": invoice_text}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "InvoiceData",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_total": {"type": "number"},
"due_date": {"type": "string"}
},
"required": ["vendor_name", "invoice_total", "due_date"]
}
}
}
)
return json.loads(message.content[0].text)
# In production:
invoice_data = extract_invoice_data(invoice_text)
# invoice_data is guaranteed to have vendor_name (str), invoice_total (number), due_date (str)
# No parsing errors. No null checks for parsing failures.
The second version is shorter, faster, more reliable, and cheaper to run.
Tool Use: Another Form of Structured Output
Claude’s tool use is another version of this same pattern:
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=[
{
"name": "save_invoice_data",
"description": "Save extracted invoice data",
"input_schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_total": {"type": "number"},
"due_date": {"type": "string"}
},
"required": ["vendor_name", "invoice_total", "due_date"]
}
}
],
messages=[{"role": "user", "content": invoice_text}]
)
# Check if Claude called the tool
if message.content[0].type == "tool_use":
tool_input = message.content[0].input
# Same guarantee: tool_input has the right schema
Same benefit. Claude can’t return malformed data, because the API enforces the schema.
The Migration Path
If you’re maintaining old regex-based systems, don’t rip them out overnight:
- Keep the regex parser for backward compatibility.
- Add structured output as a parallel path.
- When both succeed, compare results and log the differences.
- Gradually shift more traffic to the structured-output path.
- Once it’s stable, retire the regex parser.
No big-bang migration. Gradual convergence.
The Gotchas
Strict mode. With "strict": True, Claude must follow the schema exactly. Any ambiguity — like a missing additionalProperties: false — and requests can fail. Test it in staging first.
Complex schemas. Deeply nested schemas or heavy conditional logic can trip Claude up. Keep schemas flat and simple where you can.
Version compatibility. Older Claude versions don’t support structured outputs. Make sure you’re on claude-3-5-sonnet or newer before you build around this.
The Bottom Line
Structured outputs solved the parsing fragility problem for me. My systems got more reliable, cheaper to run, and simpler to maintain, all at once.
If you’re still building with regex parsing against a text response, switch to structured outputs. You’ll thank yourself the next time a model update ships.
Get the free AI Readiness Checklist
15 questions to diagnose your team’s AI readiness, where you’ll see ROI fastest, and what to tackle first.
✓ Takes 5 minutes
✓ Actionable next steps
✓ No sales pitch
No spam. Unsubscribe anytime.
or
Ready to build AI that actually works?
Let’s talk about how SRE discipline transforms AI from a risky experiment into a reliable business system.
Book Your Free Discovery Call