Articles / LLMs & Agents

[ LLMs & Agents ]PythonLLMsPydanticstructured output

Structured Data Extraction with LLMs, Python and Pydantic

Extract invoices and job postings with Python, Pydantic and Instructor. Validate types, review failures and measure field-level accuracy.

June 25, 2026·6 min read
Structured Data Extraction with LLMs, Python and Pydantic

An invoice in plain text needs fields your application can process: vendor, date, currency and amounts. This tutorial uses Python, Pydantic and Instructor to define those fields and validate an LLM response.

Validation checks structure and the rules you write. It does not prove that an amount or date matches the document; that requires source evidence and reviewed examples.

The Problem with Naive Extraction

The obvious first attempt is to ask an LLM to "extract the invoice date and total amount" and parse its response. This breaks in three ways:

  1. The LLM returns slightly different field names each time
  2. Dates come back in inconsistent formats (Jan 5, 2025-01-05, 05/01/25)
  3. There is no schema validation: a field can be a string when you expected a float

The solution is to force the LLM to return JSON that matches a Pydantic schema, and validate it before it ever enters your pipeline.

Setup

We will use the instructor library, which patches any OpenAI-compatible client to return validated Pydantic objects:

bash
pip install instructor openai pydantic
python
import instructor
from openai import OpenAI

client = instructor.from_openai(OpenAI())

If you prefer to use Anthropic's Claude:

bash
pip install instructor anthropic
python
import instructor
import anthropic

client = instructor.from_anthropic(anthropic.Anthropic())

The examples below use the OpenAI client. For Anthropic, adapt the model, creation method and parameters using the Instructor integration guide.

Example 1: Extracting Invoices

python
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
from decimal import Decimal

class LineItem(BaseModel):
    description: str
    quantity: int
    unit_price: Decimal
    total: Decimal

class Invoice(BaseModel):
    invoice_number: str
    vendor_name: str
    issue_date: date
    due_date: Optional[date] = None
    line_items: list[LineItem]
    subtotal: Decimal
    tax_rate: Optional[float] = Field(None, ge=0, le=1)
    total_amount: Decimal
    currency: str = 'USD'

INVOICE_TEXT = """
INVOICE #INV-2025-0847
Vendor: Acme Software Solutions
Date: March 12, 2025
Due: April 11, 2025

Services:
- Data Pipeline Development (40 hrs @ $150/hr): $6,000.00
- Cloud Infrastructure Setup (1 unit @ $800): $800.00
- Support & Documentation (8 hrs @ $100/hr): $800.00

Subtotal: $7,600.00
Tax (8%): $608.00
TOTAL DUE: $8,208.00
"""

invoice = client.chat.completions.create(
    model='gpt-4o-mini',
    response_model=Invoice,
    messages=[
        {
            'role': 'user',
            'content': f'Extract all invoice data from the following text:\n\n{INVOICE_TEXT}',
        }
    ],
)

print(f"Invoice: {invoice.invoice_number}")
print(f"Vendor:  {invoice.vendor_name}")
print(f"Date:    {invoice.issue_date}")   # datetime.date(2025, 3, 12): always a date object
print(f"Total:   {invoice.total_amount}") # Decimal('8208.00')
print(f"Items:   {len(invoice.line_items)}")
for item in invoice.line_items:
    print(f"  - {item.description}: ${item.total}")

Pydantic validates every field: issue_date is always a Python date object, total_amount is always a Decimal, and if the LLM hallucinates a field that does not match the schema, Instructor retries automatically.

Example 2: Job Postings

python
from enum import Enum
from typing import Optional

class ExperienceLevel(str, Enum):
    JUNIOR    = 'junior'
    MID       = 'mid'
    SENIOR    = 'senior'
    LEAD      = 'lead'
    PRINCIPAL = 'principal'

class SalaryRange(BaseModel):
    min_usd: Optional[int] = None
    max_usd: Optional[int] = None
    is_disclosed: bool

class JobPosting(BaseModel):
    title: str
    company: str
    location: str
    remote_policy: str                    # 'remote', 'hybrid', 'on-site'
    experience_level: ExperienceLevel
    required_skills: list[str]
    preferred_skills: list[str]
    salary: SalaryRange
    responsibilities: list[str] = Field(max_length=8)

JOB_TEXT = """
Senior ML Engineer: Stealth AI Startup (Remote / NYC)

We're looking for a Senior Machine Learning Engineer to join our small but mighty team.
You'll own the full ML lifecycle: data collection, model training, evaluation, and deployment.

What we need:
  5+ years of experience in ML/Data Science
  Strong Python skills (scikit-learn, PyTorch or TensorFlow)
  Production experience with model serving (FastAPI, Docker, Kubernetes)
  Familiarity with MLflow or similar experiment tracking

Nice to have:
  LLM fine-tuning experience
  Rust (our inference engine is written in Rust)

Responsibilities:
  Design and train models for our core recommendation system
  Set up and maintain our ML infrastructure on AWS
  Collaborate with product to define ML-driven features
  Mentor junior engineers

Compensation: USD 180,000 to USD 240,000 + equity. We don't believe in hiding salaries.
"""

job = client.chat.completions.create(
    model='gpt-4o-mini',
    response_model=JobPosting,
    messages=[{'role': 'user', 'content': f'Extract job posting data:\n\n{JOB_TEXT}'}],
)

print(f"Role:     {job.title} ({job.experience_level.value})")
print(f"Company:  {job.company} | {job.remote_policy}")
print(f"Salary:   USD {job.salary.min_usd:,} to USD {job.salary.max_usd:,}")
print(f"Required: {', '.join(job.required_skills)}")

Example 3: Batch Processing with Validation Stats

In production you process hundreds of documents, usually as one step inside an orchestrated pipeline (I cover that setup in orchestrating ML pipelines with Prefect). At that scale you need to know your extraction success rate:

python
from pydantic import ValidationError

class ExtractionResult(BaseModel):
    success: bool
    data: Optional[Invoice] = None
    error: Optional[str] = None
    raw_text_preview: str

def extract_invoice_safe(text: str) -> ExtractionResult:
    try:
        invoice = client.chat.completions.create(
            model='gpt-4o-mini',
            response_model=Invoice,
            max_retries=2,
            messages=[{
                'role': 'user',
                'content': f'Extract invoice data:\n\n{text}',
            }],
        )
        return ExtractionResult(success=True, data=invoice, raw_text_preview=text[:100])
    except Exception as e:
        return ExtractionResult(success=False, error=str(e), raw_text_preview=text[:100])

# Process a batch
invoice_texts = [INVOICE_TEXT, "Not an invoice at all", INVOICE_TEXT]

results = [extract_invoice_safe(t) for t in invoice_texts]

successes = [r for r in results if r.success]
failures  = [r for r in results if not r.success]

print(f"Success rate: {len(successes)}/{len(results)} ({len(successes)/len(results)*100:.0f}%)")
for f in failures:
    print(f"  Failed: {f.error}")

Handling Complex Nested Structures

For documents with conditional or complex structures, add validation logic directly in Pydantic:

python
from pydantic import field_validator, model_validator
from datetime import datetime

class Invoice(BaseModel):
    # ... fields as before ...

    @field_validator('issue_date', 'due_date', mode='before')
    @classmethod
    def parse_flexible_date(cls, v):
        if v is None:
            return v
        if isinstance(v, date):
            return v
        # Handle common LLM date formats
        for fmt in ('%Y-%m-%d', '%B %d, %Y', '%m/%d/%Y', '%d/%m/%Y'):
            try:
                return datetime.strptime(str(v), fmt).date()
            except ValueError:
                continue
        raise ValueError(f"Cannot parse date: {v}")

    @model_validator(mode='after')
    def validate_totals(self) -> 'Invoice':
        computed = sum(item.total for item in self.line_items)
        if abs(computed - self.subtotal) > Decimal('0.10'):
            raise ValueError(
                f"Line items sum ({computed}) does not match subtotal ({self.subtotal})"
            )
        return self

The model_validator catches cases where the LLM extracts individual line items that do not add up to the stated subtotal: a common source of errors in real invoice data.

Measure cost and quality before automating

Calculate cost from input and output tokens for every attempt and the provider's current rates. Include retries, OCR, storage and human review. Cost per document depends on length, model choice and failure rate; a fixed estimate without those conditions is not a useful budget.

Compare models on documents with reviewed field labels. Measure field-level accuracy, missing values, invented values and the share sent for review. Keep the evaluation set fixed when changing the prompt or schema. Version the model and dependencies as well.

Handle missing information explicitly

Distinguish an absent field from a real zero or an empty string. If the document does not contain a date, allow a missing value in the schema and retain the reason. Do not ask the model to fill gaps with plausible data. For amounts, compare currency, units and totals with the original text before writing to the destination system.

These examples demonstrate integration patterns, not guaranteed accuracy or a pipeline for every invoice format. Calls need a provider key and incur usage charges. Start with synthetic documents or remove sensitive data before sending them.

For questions over a document collection, continue with RAG with LangChain. To execute tools over extracted data, see agents with LangGraph.

References

Are you building an AI or data platform?

If you need to adapt this approach to your system, tell me about your data and the technical decision you need to resolve.

View all projects