Module 3: Structured Outputs and System Prompts
3. Pydantic Schemas and Function Calling
Overview
Pydantic lets you define data schemas that validate, transform and document the LLM's output. Function calling (OpenAI) and Tool Use (Anthropic) use JSON schemas to get guaranteed structured output — the model can't answer with free text, only with the fields you specified. In this capsule you'll learn to combine Pydantic with function calling, work with nested structures, optional fields, enums, and custom validators.
Why it matters: Function calling is the most robust form of structured output available. It's not "the model might return JSON" — it's "the model MUST call the function with these exact arguments". For production systems processing critical data, that difference matters.
Pydantic: More Than Validation
Pydantic v2 does three fundamental things for this flow:
- Define the contract: What the data has to look like
- Validate the output: The LLM returned what you expected
- Generate the schema: Turn the Pydantic model into JSON Schema for function calling
from pydantic import BaseModel, Field, field_validator
from typing import Literal, Optional
from datetime import date
class SentimentAnalysis(BaseModel):
"""Sentiment analysis of a text."""
sentiment: Literal["POSITIVE", "NEGATIVE", "NEUTRAL"]
confidence: float = Field(ge=0.0, le=1.0, description="Confidence level between 0 and 1")
keywords: list[str] = Field(max_length=5, description="At most 5 words that justify the sentiment")
needs_attention: bool = Field(description="True if the negative sentiment is intense and needs follow-up")
# Generate JSON Schema (to use in function calling)
schema = SentimentAnalysis.model_json_schema()
print(schema)
# {
# "type": "object",
# "properties": {
# "sentiment": {"enum": ["POSITIVE", "NEGATIVE", "NEUTRAL"]},
# "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
# "keywords": {"type": "array", "maxItems": 5},
# "needs_attention": {"type": "boolean"}
# },
# "required": ["sentiment", "confidence", "keywords", "needs_attention"]
# }
Function Calling as Structured Output (OpenAI)
The concept
Function calling isn't only for calling real functions. It's OpenAI's most robust structured output mechanism: the model MUST answer with the function's arguments in the JSON format you specified.
from openai import OpenAI
import json
client = OpenAI()
# Define the "tool" with the function's schema
tools = [
{
"type": "function",
"function": {
"name": "analyze_sentiment",
"description": "Analyzes the sentiment of a product review text",
"parameters": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["POSITIVE", "NEGATIVE", "NEUTRAL"],
"description": "Sentiment classification"
},
"confidence": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"description": "Confidence level between 0 and 1"
},
"keywords": {
"type": "array",
"items": {"type": "string"},
"maxItems": 5,
"description": "Words that justify the sentiment"
},
"needs_attention": {
"type": "boolean",
"description": "True if the sentiment is very negative and needs follow-up"
}
},
"required": ["sentiment", "confidence", "keywords", "needs_attention"]
}
}
}
]
def analyze(text: str) -> SentimentAnalysis:
"""Analyzes sentiment using function calling."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Analyze the sentiment of product reviews accurately."
},
{
"role": "user",
"content": f"Analyze: '{text}'"
}
],
tools=tools,
tool_choice={
"type": "function",
"function": {"name": "analyze_sentiment"}
}, # Force it to use this specific function
temperature=0
)
# Extract the arguments from the tool call
tool_call = response.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
# Validate with Pydantic
return SentimentAnalysis(**args)
# Test
texts = [
"It arrived earlier than expected and works perfectly. Highly recommended.",
"AWFUL. It broke on the third day. They ripped me off.",
"It does its job. Neither the best nor the worst."
]
for text in texts:
result = analyze(text)
print(f"Input: {text[:50]}...")
print(f" Sentiment: {result.sentiment} ({result.confidence:.0%})")
print(f" Keywords: {result.keywords}")
print(f" Needs attention: {result.needs_attention}\n")
From Pydantic Schema → JSON Schema for Function Calling
You don't have to write the JSON Schema by hand. Pydantic generates it:
from pydantic import BaseModel, Field
from typing import Optional, Literal, List
from openai import OpenAI
import json
client = OpenAI()
# Define the Pydantic model
class InvoiceItem(BaseModel):
description: str = Field(description="Item description")
quantity: int = Field(ge=1, description="Number of units")
unit_price: float = Field(ge=0, description="Price per unit")
subtotal: Optional[float] = Field(None, description="Subtotal (quantity * price)")
class Invoice(BaseModel):
vendor: str = Field(description="Name of the vendor or company")
invoice_number: str = Field(description="Invoice number or code")
issue_date: str = Field(description="Date in YYYY-MM-DD format")
due_date: Optional[str] = Field(None, description="Due date if applicable")
subtotal: float = Field(ge=0, description="Subtotal before taxes")
tax: float = Field(ge=0, description="Tax amount")
total: float = Field(ge=0, description="Total including taxes")
currency: Literal["USD", "EUR", "MXN", "GBP"] = Field(description="Currency code")
items: List[InvoiceItem] = Field(description="List of invoice items")
# Generate the JSON Schema automatically
schema = Invoice.model_json_schema()
# Use it in function calling
def extract_invoice(invoice_text: str) -> Invoice:
"""Extracts structured data from an invoice."""
tools = [
{
"type": "function",
"function": {
"name": "extract_invoice_data",
"description": "Extracts all the structured data from an invoice",
"parameters": schema # Schema generated by Pydantic!
}
}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are an invoice data extractor. Extract every available field accurately."
},
{
"role": "user",
"content": f"Extract the data from this invoice:\n\n{invoice_text}"
}
],
tools=tools,
tool_choice={"type": "function", "function": {"name": "extract_invoice_data"}},
temperature=0
)
args = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
return Invoice(**args)
# Test with invoice text
invoice_text = """
INVOICE #INV-2025-001
TechSupplies Inc.
Date: January 15, 2025
Due: February 15, 2025
IT consulting services: 40 hours x $150/hr = $6,000.00
Software licenses: 5 units x $200/ea = $1,000.00
Subtotal: $7,000.00
Tax (16%): $1,120.00
TOTAL: $8,120.00 MXN
"""
result = extract_invoice(invoice_text)
print(f"Vendor: {result.vendor}")
print(f"Number: {result.invoice_number}")
print(f"Total: {result.total} {result.currency}")
print(f"Items: {len(result.items)}")
for item in result.items:
print(f" - {item.description}: {item.quantity} x ${item.unit_price}")
Tool Use in Anthropic
Anthropic has its equivalent of function calling with tools:
import anthropic
import json
from pydantic import BaseModel
from typing import Literal, List
client = anthropic.Anthropic()
class EmailSummary(BaseModel):
subject: str
sender: str
urgency: Literal["HIGH", "MEDIUM", "LOW"]
required_actions: List[str]
deadline: str # YYYY-MM-DD or "NO_DATE"
def summarize_email_anthropic(email_text: str) -> EmailSummary:
"""Extracts structured information from an email using Claude."""
response = client.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=500,
tools=[
{
"name": "process_email",
"description": "Extracts structured information from an email",
"input_schema": EmailSummary.model_json_schema() # Pydantic schema
}
],
tool_choice={"type": "tool", "name": "process_email"},
messages=[
{
"role": "user",
"content": f"Process this email and extract the information:\n\n{email_text}"
}
]
)
# Find the tool use in the response
for block in response.content:
if block.type == "tool_use":
return EmailSummary(**block.input)
raise ValueError("No tool use response was returned by Anthropic")
# Test
email = """
From: cto@company.com
To: team@company.com
Subject: URGENT: Production system down
The payment system has been completely down since 2:00 PM.
We need to:
1. Identify the root cause within the next 30 minutes
2. Notify the affected customers before 4:00 PM
3. Have a restoration plan ready by 5:00 PM
This is critical - we're losing $5,000/hour.
"""
result = summarize_email_anthropic(email)
print(f"Subject: {result.subject}")
print(f"Urgency: {result.urgency}")
print(f"Actions ({len(result.required_actions)}):")
for a in result.required_actions:
print(f" - {a}")
Nested Structures and Complex Types
from pydantic import BaseModel, Field
from typing import Literal, Optional, List
from enum import Enum
class Category(str, Enum):
TECHNICAL = "TECHNICAL"
BILLING = "BILLING"
ACCOUNT = "ACCOUNT"
FEATURE = "FEATURE_REQUEST"
OTHER = "OTHER"
class Priority(str, Enum):
CRITICAL = "CRITICAL" # System down
HIGH = "HIGH" # Functionality affected
MEDIUM = "MEDIUM" # Workaround available
LOW = "LOW" # Question or improvement
class MentionedEntity(BaseModel):
type: Literal["USER", "PRODUCT", "SYSTEM", "COMPANY"]
value: str
class TicketAnalysis(BaseModel):
"""Complete analysis of a support ticket."""
category: Category
priority: Priority
summary: str = Field(max_length=100, description="At most 100 characters")
entities: List[MentionedEntity] = Field(default=[], description="Mentioned entities")
recommended_actions: List[str] = Field(max_length=5)
needs_escalation: bool
frustration_level: Literal[1, 2, 3, 4, 5] = Field(description="1=calm, 5=very frustrated")
tags: List[str] = Field(default=[], max_length=10)
# Generate the full schema (including nested models)
schema = TicketAnalysis.model_json_schema()
# Pydantic includes the definitions of MentionedEntity, Category, Priority
# automatically in the schema
Custom Validators in Pydantic
from pydantic import BaseModel, field_validator, model_validator
from typing import Optional
import re
class ContactExtraction(BaseModel):
name: str
email: Optional[str] = None
phone: Optional[str] = None
company: Optional[str] = None
@field_validator("email")
@classmethod
def validate_email(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, v):
raise ValueError(f"Invalid email: {v}")
return v.lower()
@field_validator("phone")
@classmethod
def clean_phone(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
# Strip everything except digits and +
cleaned = re.sub(r'[^\d+]', '', v)
if len(cleaned) < 7:
raise ValueError(f"Phone number too short: {v}")
return cleaned
@model_validator(mode="after")
def validate_at_least_one_contact(self) -> "ContactExtraction":
if not self.email and not self.phone:
raise ValueError("Must have at least an email or a phone number")
return self
# Test
try:
c = ContactExtraction(
name="John Garcia",
email="JOHN@COMPANY.COM", # Normalized to lowercase
phone="(55) 1234-5678" # Cleaned to "5512345678"
)
print(c.model_dump())
except ValueError as e:
print(f"Validation error: {e}")
The Full Flow: From Schema to Validated Result
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal, List, Optional
import json
client = OpenAI()
# Step 1: Define the schema with Pydantic
class ArticleMetadata(BaseModel):
title: str
author: Optional[str] = None
publication_date: Optional[str] = None
main_topics: List[str] = Field(max_length=5)
tone: Literal["informative", "opinion", "technical", "explanatory"]
technical_level: Literal[1, 2, 3, 4, 5]
executive_summary: str = Field(max_length=200)
# Step 2: Build the tool with the Pydantic schema
def create_tool() -> dict:
return {
"type": "function",
"function": {
"name": "extract_article_metadata",
"description": "Extracts metadata and analysis from an article",
"parameters": ArticleMetadata.model_json_schema()
}
}
# Step 3: Call and validate
def analyze_article(text: str) -> ArticleMetadata:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are an editorial content analyst."},
{"role": "user", "content": f"Analyze this article:\n\n{text}"}
],
tools=[create_tool()],
tool_choice={"type": "function", "function": {"name": "extract_article_metadata"}},
temperature=0
)
args = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
return ArticleMetadata(**args) # Pydantic validates
# Step 4: Use the result
article = """
Artificial Intelligence in Medicine: AI-Assisted Diagnosis
By Dr. Carlos Mendoza, January 15, 2025
Machine learning systems are revolutionizing medical diagnosis.
In the last 5 years, deep learning algorithms have reached accuracy comparable
to specialists in radiology, dermatology and ophthalmology...
"""
meta = analyze_article(article)
print(f"Title: {meta.title}")
print(f"Topics: {meta.main_topics}")
print(f"Tone: {meta.tone}")
print(f"Technical level: {meta.technical_level}/5")
print(f"Summary: {meta.executive_summary}")
Comparison: JSON Mode vs Function Calling
| Aspect | JSON Mode | Function Calling |
|---|---|---|
| JSON guarantee | ✅ Always valid JSON | ✅ Always valid JSON |
| Schema guarantee | ❌ Only with json_schema | ✅ The model MUST use the schema |
| Extra keys | Possible | ❌ Only the defined fields |
| Guaranteed types | ❌ | ✅ The model follows the schema's types |
| Code integration | Manual (json.loads) | Automatic (in tool_calls) |
| Multiple outputs | 1 JSON per response | Multiple tool calls in 1 response |
| Complexity | Low | Medium |
| Availability | OpenAI + Anthropic (with instructions) | OpenAI native, Anthropic with tools |
Connection to the Project
In the Structured Data Extractor (capsule 08) you'll use:
- Pydantic models for
InvoiceData,EmailSummary,ArticleMetadata - Function calling as the main extraction mechanism
model_json_schema()to generate schemas automatically- Custom validators for dates, emails, amounts
Troubleshooting
Problem 1: The tool call returns nothing (finish_reason isn't "tool_calls")
Cause: The model didn't understand it has to use the tool, or tool_choice isn't forcing it.
Fix:
# Check finish_reason
print(response.choices[0].finish_reason)
# "tool_calls" = it used the tool ✅
# "stop" = it answered with text instead of the tool ❌
# Force the use of the specific tool
tool_choice={"type": "function", "function": {"name": "my_function"}}
# Don't use tool_choice="auto" if you need a guarantee
Problem 2: Pydantic ValidationError when parsing the output
Cause: The model returned a wrong type or a missing field.
Diagnosis:
from pydantic import ValidationError
try:
result = MyModel(**args)
except ValidationError as e:
print(e.json(indent=2))
# Shows exactly which field failed and why
Fix: Add the expected format to the field's description, or add a field_validator with coercion.
Problem 3: Schema too complex (heavily nested models)
Cause: Very deep JSON Schemas can confuse the model.
Fix: Simplify the schema for the first version:
- Use
strfor complex fields and parse them later - Split into multiple calls if the schema has >10 nested fields
- Use
Optional[X]for non-critical fields
Problem 4: Anthropic tool use vs OpenAI function calling — the differences
# OpenAI: access the result
tool_call = response.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
# Anthropic: access the result
for block in response.content:
if block.type == "tool_use" and block.name == "my_function":
args = block.input # Already a dict, no json.loads needed
break
Exercises
Exercise 1: An invoice schema with Pydantic
Define a complete Pydantic model to extract from an invoice: number, date, total, vendor, items (a list of {description, quantity, price}). Generate the JSON Schema.
See solution
from pydantic import BaseModel, Field
from typing import List, Optional, Literal
class InvoiceItem(BaseModel):
description: str
quantity: int = Field(ge=1)
unit_price: float = Field(ge=0)
subtotal: Optional[float] = None
class InvoiceData(BaseModel):
number: str
date: str # YYYY-MM-DD
vendor: str
subtotal: float = Field(ge=0)
tax: float = Field(ge=0, default=0)
total: float = Field(ge=0)
currency: Literal["USD", "EUR", "MXN"] = "MXN"
items: List[InvoiceItem] = []
# Generate the schema
import json
schema = InvoiceData.model_json_schema()
print(json.dumps(schema, indent=2, ensure_ascii=False))
Exercise 2: Function calling with the Pydantic schema
Implement invoice extraction using function calling and validate the result with the Pydantic model from exercise 1.
See solution
from openai import OpenAI
import json
client = OpenAI()
def extract_invoice_fc(text: str) -> InvoiceData:
tools = [
{
"type": "function",
"function": {
"name": "extract_invoice",
"description": "Extracts structured data from an invoice",
"parameters": InvoiceData.model_json_schema()
}
}
]
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extract invoice data accurately."},
{"role": "user", "content": f"Extract data from: {text}"}
],
tools=tools,
tool_choice={"type": "function", "function": {"name": "extract_invoice"}},
temperature=0
)
args = json.loads(r.choices[0].message.tool_calls[0].function.arguments)
return InvoiceData(**args)
Exercise 3: A custom validator
Add to the InvoiceData from exercise 1 a validator that checks total == subtotal + tax (with a ±0.01 tolerance for rounding).
See solution
from pydantic import model_validator
class InvoiceDataValidated(InvoiceData):
@model_validator(mode="after")
def verify_total(self) -> "InvoiceDataValidated":
calculated_total = self.subtotal + self.tax
if abs(self.total - calculated_total) > 0.01:
# Don't raise: the LLM can extract inconsistent values
# Instead, correct or warn
print(f"⚠️ Inconsistent total: {self.total} vs {calculated_total}")
return self
Summary
- Pydantic: Defines the schema, validates the output, coerces types, validates with
model_validate_json(). Always use it with LLM outputs. - Function Calling (OpenAI): A tool with a JSON schema = the model MUST return those fields. More robust than JSON mode.
model_json_schema(): Generates the JSON Schema automatically from the Pydantic model — don't write it by hand.- Anthropic Tool Use: The equivalent of function calling. The result arrives in
block.input(already a dict). - Validators:
field_validatorfor individual validation,model_validatorfor cross-field validation. - Nested structures: Pydantic handles the schemas of nested models automatically.
Further resources
- OpenAI Function Calling Guide — Complete documentation with advanced examples
- Anthropic Tool Use — Tool use in Claude, including tool_choice
- Pydantic v2 BaseModel — Complete reference for the base model
- Pydantic Validators —
field_validator,model_validator, before/after modes - JSON Schema to Pydantic — How Pydantic generates and consumes JSON Schemas
- OpenAI Structured Outputs vs Function Calling — When to use each mechanism