Getting Claude to reliably return structured data used to require careful prompt engineering, aggressive retry logic, and still the occasional format failure at 2am. Structured outputs, now GA in Claude 4.6, changes the equation: pass a JSON schema and Claude guarantees schema-valid JSON back. No retries, no parse errors, no json.JSONDecodeError in production.
Here are three patterns that work in production, plus copy-paste schemas for India-specific use cases.
The API parameter — output_config.format
If you migrated from Claude 4.5 and you're getting 400 errors, this is probably why: the parameter was renamed from output_format to output_config.format in 4.6. Check your client version and update accordingly.
The basic pattern:
import json
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"json_schema": {
"name": "product_info",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"price": {"type": "number"},
"in_stock": {"type": "boolean"}
},
"required": ["name", "price", "in_stock"]
}
}
}
},
messages=[{
"role": "user",
"content": "Extract: Apple MacBook Pro 14-inch, ₹1,85,000, available"
}]
)
product = json.loads(response.content[0].text)
# {"name": "Apple MacBook Pro 14-inch", "price": 185000.0, "in_stock": true}
Claude doesn't just attempt to return JSON — it's constrained to produce output that validates against your schema. If a field is in required, it will be in the response. If you specify "type": "number", you'll get a number.
Pattern 1 — Inline JSON schema
Best for simple, ad-hoc extraction where you need a quick schema without the overhead of defining Pydantic models. Here's a complete GST invoice extraction schema:
gst_invoice_schema = {
"name": "gst_invoice",
"schema": {
"type": "object",
"properties": {
"gstin_seller": {
"type": "string",
"description": "15-character GSTIN of the seller"
},
"gstin_buyer": {
"type": ["string", "null"],
"description": "GSTIN of buyer, null for B2C"
},
"invoice_number": {"type": "string"},
"invoice_date": {
"type": "string",
"description": "Date in YYYY-MM-DD format"
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"hsn_code": {"type": "string"},
"quantity": {"type": "number"},
"rate": {"type": "number"},
"gst_rate": {
"type": "number",
"description": "GST rate as percentage (5, 12, 18, or 28)"
},
"amount": {"type": "number"}
},
"required": ["description", "hsn_code", "quantity", "rate", "gst_rate", "amount"]
}
},
"total_taxable_value": {"type": "number"},
"total_gst": {"type": "number"},
"total_amount": {"type": "number"}
},
"required": [
"gstin_seller", "invoice_number", "invoice_date",
"line_items", "total_taxable_value", "total_gst", "total_amount"
]
}
}
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2000,
output_config={"format": {"type": "json_schema", "json_schema": gst_invoice_schema}},
messages=[{"role": "user", "content": f"Extract invoice data:\n\n{invoice_text}"}]
)
invoice_data = json.loads(response.content[0].text)
The inline schema approach works well when your schema is relatively flat and you're prototyping. Once it goes to production or the schema gets complex, move to Pattern 2.
Pattern 2 — Pydantic model (recommended for production)
The instructor library wraps Claude (and other LLMs) with Pydantic, giving you full type validation, IDE autocomplete, and automatic schema generation from your models. This is what I'd use in any serious production system.
from pydantic import BaseModel, Field
from typing import List, Optional
import instructor
import anthropic
class LineItem(BaseModel):
description: str
hsn_code: str
quantity: float
rate: float
gst_rate: float = Field(description="GST rate as percentage (5, 12, 18, or 28)")
amount: float
class GSTInvoice(BaseModel):
gstin_seller: str = Field(description="15-character GSTIN of the seller")
gstin_buyer: Optional[str] = Field(default=None, description="GSTIN of buyer, null for B2C")
invoice_number: str
invoice_date: str = Field(description="Date in YYYY-MM-DD format")
line_items: List[LineItem]
total_taxable_value: float
total_gst: float
total_amount: float
client = instructor.from_anthropic(anthropic.Anthropic())
invoice = client.chat.completions.create(
model="claude-sonnet-4-6",
max_tokens=2000,
response_model=GSTInvoice,
messages=[{"role": "user", "content": f"Extract invoice data:\n\n{invoice_text}"}]
)
# invoice is now a GSTInvoice instance, fully typed
print(invoice.gstin_seller) # str, guaranteed
print(invoice.line_items[0].gst_rate) # float, guaranteed
print(invoice.model_dump()) # Convert to dict for serialization
What instructor gives you that raw output_config doesn't:
- Automatic retry with error feedback: if Claude returns something that fails Pydantic validation, instructor retries with the validation error as context
- IDE autocomplete: your editor knows the shape of
invoice Optionalhandling: Pydantic'sOptional[str]maps cleanly to nullable fields- Nested validation: you can add
@validatordecorators for business logic (e.g., validate GSTIN format with regex)
Install: pip install instructor anthropic
Pattern 3 — Tool use for complex/ambiguous extraction
When documents are noisy (OCR output, scanned PDFs with table misalignment) or when you need Claude to reason before committing to a value, force tool use. The model will think through the extraction before outputting the structured result.
tools = [{
"name": "extract_invoice",
"description": "Extract structured invoice data from the provided text. Use this after analyzing the document.",
"input_schema": {
"type": "object",
"properties": {
"gstin_seller": {"type": "string"},
"gstin_buyer": {"type": ["string", "null"]},
"invoice_number": {"type": "string"},
"invoice_date": {"type": "string"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"hsn_code": {"type": "string"},
"amount": {"type": "number"}
},
"required": ["description", "hsn_code", "amount"]
}
},
"total_amount": {"type": "number"},
"extraction_confidence": {
"type": "string",
"enum": ["high", "medium", "low"],
"description": "Confidence in extraction quality given document condition"
},
"extraction_notes": {
"type": ["string", "null"],
"description": "Note any ambiguous fields or OCR issues"
}
},
"required": ["gstin_seller", "invoice_number", "invoice_date", "line_items", "total_amount", "extraction_confidence"]
}
}]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2000,
tools=tools,
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=[{
"role": "user",
"content": f"Analyze this invoice document carefully, then extract the structured data:\n\n{ocr_text}"
}]
)
# Extract the tool call result
tool_use = next(b for b in response.content if b.type == "tool_use")
invoice_data = tool_use.input
The extraction_confidence and extraction_notes fields are the key addition here. For OCR-heavy documents, you want to know when Claude is uncertain so you can route low-confidence extractions to human review.
Performance at different effort levels
The effort parameter matters differently for extraction than for reasoning tasks.
Pure extraction (clean, well-structured documents): effort=low is usually enough. The document either has a GSTIN or it doesn't. Low effort is significantly cheaper and faster — about 40-60% less cost depending on the model tier.
Ambiguous or OCR-noisy data: effort=medium. Claude needs to reconcile inconsistencies, infer missing values, and decide between plausible interpretations. Medium effort handles this well.
Complex financial documents with regulatory nuance: effort=high. SEBI filings, RBI returns, DPIIT documents with complex nested structures and regulatory-specific field interpretations. High effort is worth it when a wrong extraction has real consequences.
My rule: start with effort=low, run your eval suite (see our evaluation framework post), and only move up when accuracy metrics justify the cost increase.
India-specific schemas (copy-paste ready)
Three schemas I've actually used in production:
PAN card extraction
class PANCardData(BaseModel):
pan_number: str = Field(
description="10-character PAN in format ABCDE1234F",
pattern=r"^[A-Z]{5}[0-9]{4}[A-Z]$"
)
name: str = Field(description="Name as printed on PAN card")
dob: str = Field(description="Date of birth in DD/MM/YYYY format as on card")
father_name: str = Field(description="Father's name as printed on PAN card")
card_type: str = Field(
description="Individual, Company, HUF, etc.",
default="Individual"
)
Indian bank statement transaction
class BankTransaction(BaseModel):
transaction_date: str = Field(description="Date in YYYY-MM-DD format")
value_date: Optional[str] = Field(default=None)
description: str = Field(description="Transaction narration/description")
debit: Optional[float] = Field(default=None, description="Debit amount, null if credit")
credit: Optional[float] = Field(default=None, description="Credit amount, null if debit")
balance: float = Field(description="Running balance after transaction")
transaction_type: str = Field(
description="Transaction method",
# Use enum in JSON schema version
)
reference_number: Optional[str] = Field(
default=None,
description="UTR/NEFT/RTGS reference number if available"
)
class TransactionType(str, Enum):
UPI = "UPI"
NEFT = "NEFT"
RTGS = "RTGS"
IMPS = "IMPS"
CHEQUE = "cheque"
ATM = "ATM"
NACH = "NACH"
OTHER = "other"
DPIIT startup registration extraction
class DPIITStartup(BaseModel):
company_name: str
cin: str = Field(description="Corporate Identity Number")
dpiit_number: str = Field(description="DPIIT recognition number")
registration_date: str = Field(description="DPIIT recognition date in YYYY-MM-DD")
sector: str = Field(description="Primary sector from DPIIT taxonomy")
stage: str = Field(
description="Funding stage: bootstrapped, angel, seed, series_a, series_b_plus"
)
incorporation_date: str = Field(description="Company incorporation date in YYYY-MM-DD")
state: str = Field(description="State of incorporation")
entity_type: str = Field(description="Pvt Ltd, LLP, OPC, etc.")
💡 Want to go deeper? The Advanced track covers context engineering and structured prompting patterns that pair well with these extraction schemas.
Next steps
These patterns work for extraction, but building reliable production systems requires evals to know when they're failing:
- LLM evaluation framework for Indian developers — measure extraction accuracy systematically
- Claude Opus 4.6 prompting guide — when to use Opus vs Sonnet for complex documents
- RAG over Indian government data — combine extraction with retrieval for regulatory documents
- Migrating from Claude 3.5/4.5 to 4.6 — full migration guide including the
output_configrename



