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:

  1. Define the contract: What the data has to look like
  2. Validate the output: The LLM returned what you expected
  3. 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

AspectJSON ModeFunction Calling
JSON guarantee✅ Always valid JSON✅ Always valid JSON
Schema guarantee❌ Only with json_schema✅ The model MUST use the schema
Extra keysPossible❌ Only the defined fields
Guaranteed types✅ The model follows the schema's types
Code integrationManual (json.loads)Automatic (in tool_calls)
Multiple outputs1 JSON per responseMultiple tool calls in 1 response
ComplexityLowMedium
AvailabilityOpenAI + 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 str for 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_validator for individual validation, model_validator for cross-field validation.
  • Nested structures: Pydantic handles the schemas of nested models automatically.

Further resources

  1. OpenAI Function Calling Guide — Complete documentation with advanced examples
  2. Anthropic Tool Use — Tool use in Claude, including tool_choice
  3. Pydantic v2 BaseModel — Complete reference for the base model
  4. Pydantic Validatorsfield_validator, model_validator, before/after modes
  5. JSON Schema to Pydantic — How Pydantic generates and consumes JSON Schemas
  6. OpenAI Structured Outputs vs Function Calling — When to use each mechanism