Artificial IntelligencePython

How to Extract Structured Data from a PDF Using an LLM (Python)

PDFs are where structured data goes to die. Regex breaks on layout changes. Rule-based parsers need constant babysitting. This guide shows you how to use an LLM to pull clean, validated JSON out of any PDF โ€” invoices, contracts, research papers, forms โ€” reliably and at scale.

Why Use an LLM Instead of a Traditional PDF Parser?

Traditional PDF parsing libraries โ€” PyMuPDF, pdfplumber, pdfminer โ€” are excellent at extracting raw text and coordinates. But they stop there. Once you have the text, you still have to write code that finds the invoice total buried between two blank lines, or the party name that appears in bold on page three of a 40-page contract.

How to Extract Structured Data from a PDF Using an LLM (Python)

That logic is fragile. One template change from a vendor breaks your regex. A slightly different date format breaks your parser. You end up maintaining a forest of brittle rules that someone has to update every time a document layout changes.

LLMs sidestep this entirely. You give the model the document text and a description of the fields you want. It figures out where they are โ€” regardless of layout, phrasing, or language. The same prompt that extracts line items from one invoice format works on another invoice format without any code changes.

ApproachHandles layout variationHandles ambiguous textMaintenance burdenCost
Regex / rulesPoorNoneHighFree
ML extractors (LayoutLM)GoodModerateMediumGPU needed
LLM (GPT-4, Claude, etc.)ExcellentExcellentLowAPI cost / token

The tradeoff is API cost per document. For high-volume, low-margin pipelines processing millions of PDFs, a hybrid approach โ€” fast rule-based extraction for common cases, LLM fallback for edge cases โ€” often makes the most economic sense. For everything else, LLMs are the clear winner.

Prerequisites and Setup

You'll need Python 3.9+ and the following packages:

pip install pymupdf openai anthropic pydantic jsonschema pytesseract pillow

We'll use PyMuPDF (imported as fitz) for text extraction, Pydantic for schema validation, and either the OpenAI or Anthropic SDK for the LLM calls. The patterns below work with any capable LLM โ€” swap the client as needed.

Set your API key as an environment variable:

export OPENAI_API_KEY="sk-..."
# or
export ANTHROPIC_API_KEY="sk-ant-..."

Step 1 โ€” Extract Text from the PDF

Before the LLM can do anything, you need the document's text in a form the model can read. PyMuPDF handles this cleanly for digital PDFs (PDFs where the text layer exists):

import fitz  # PyMuPDF

def extract_text_from_pdf(pdf_path: str) -> str:
    """Extract all text from a digital PDF, page by page."""
    doc = fitz.open(pdf_path)
    pages = []

    for page_num, page in enumerate(doc, start=1):
        text = page.get_text("text")
        if text.strip():
            pages.append(f"--- Page {page_num} ---\n{text.strip()}")

    doc.close()
    return "\n\n".join(pages)

The page delimiter (--- Page N ---) is deliberate. Giving the model explicit page context helps when you need to extract fields that span pages or when the location of a field matters (e.g., "the total on the last page").

Preserving Table Structure

Plain text extraction collapses tables into undifferentiated rows. For documents where tables carry the important data (invoice line items, financial statements), use pdfplumber's table extraction instead:

import pdfplumber

def extract_with_tables(pdf_path: str) -> str:
    """Extract text and convert tables to markdown for better LLM parsing."""
    output = []

    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages, start=1):
            output.append(f"--- Page {page_num} ---")

            # Extract tables first
            for table in page.extract_tables():
                if not table:
                    continue
                # Convert to markdown
                header = " | ".join(str(c or "") for c in table[0])
                divider = " | ".join(["---"] * len(table[0]))
                rows = ["\n".join(
                    " | ".join(str(cell or "") for cell in row)
                    for row in table[1:]
                )]
                output.append(f"{header}\n{divider}\n{'\n'.join(rows)}")

            # Then remaining text
            text = page.extract_text()
            if text:
                output.append(text)

    return "\n\n".join(output)

Markdown tables give the LLM a clear structural signal. An LLM will reliably identify "Description", "Qty", and "Unit Price" columns in a markdown table โ€” far more reliably than in collapsed plain text.

Step 2 โ€” Design the Extraction Prompt

Prompt design is where most PDF extraction projects succeed or fail. The prompt has to be precise enough to get consistent output, but flexible enough to handle document variations.

The Core Pattern: Schema-First Prompting

Define exactly what you want in the prompt using a JSON schema or a clear field list. Don't leave the model to guess what structure you want โ€” that's where inconsistency creeps in.

INVOICE_SYSTEM_PROMPT = """
You are a precise data extraction assistant.
Extract structured data from the document text provided.
Respond ONLY with valid JSON matching the schema below.
Do not include markdown code fences, explanations, or any text outside the JSON.

SCHEMA:
{
  "invoice_number": "string or null",
  "invoice_date": "ISO 8601 date string (YYYY-MM-DD) or null",
  "due_date": "ISO 8601 date string or null",
  "vendor": {
    "name": "string or null",
    "address": "string or null",
    "email": "string or null"
  },
  "bill_to": {
    "name": "string or null",
    "address": "string or null"
  },
  "line_items": [
    {
      "description": "string",
      "quantity": "number or null",
      "unit_price": "number or null",
      "total": "number or null"
    }
  ],
  "subtotal": "number or null",
  "tax": "number or null",
  "total_due": "number or null",
  "currency": "3-letter ISO currency code or null",
  "payment_terms": "string or null"
}

RULES:
- If a field is not present in the document, set it to null.
- Dates must be in YYYY-MM-DD format. Convert from any input format.
- Monetary values must be numbers (no currency symbols or commas).
- Extract all line items found, even if descriptions are abbreviated.
"""

Prompt Engineering Tips for Extraction Tasks

  • Be explicit about null handling. Without clear instructions, models sometimes invent plausible values for missing fields. "Set to null if not present" prevents hallucination.
  • Normalize formats in the prompt. Specify ISO dates, numeric currency values, and other canonical formats. Let the LLM do the normalization work so you don't have to clean up later.
  • Forbid wrapper text explicitly. "Respond ONLY with valid JSON" and "no markdown code fences" prevents the most common parsing failures.
  • Include document-type context. "This is an invoice" helps the model weight ambiguous fields correctly. "Net 30" is a payment term on an invoice; it means something different on a fishing permit.

Step 3 โ€” Call the LLM and Parse the Response

import json
import re
from openai import OpenAI

client = OpenAI()

def extract_invoice_data(pdf_path: str) -> dict:
    # 1. Get the document text
    text = extract_text_from_pdf(pdf_path)

    # 2. Call the LLM
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": INVOICE_SYSTEM_PROMPT},
            {"role": "user", "content": f"Extract data from this document:\n\n{text}"},
        ],
        temperature=0,       # Deterministic output for extraction tasks
        max_tokens=2000,
    )

    raw = response.choices[0].message.content.strip()

    # 3. Safely parse JSON (handle accidental code fences)
    cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.MULTILINE)

    try:
        return json.loads(cleaned)
    except json.JSONDecodeError as e:
        raise ValueError(f"LLM returned invalid JSON: {e}\n\nRaw output:\n{raw}")

Always set temperature=0 for extraction

Extraction tasks need deterministic output. Temperature 0 makes the model pick the most probable token at each step, which means more consistent formatting and less invented variation. Never use the default temperature (usually 0.7โ€“1.0) for structured extraction.

Using Anthropic's Claude

The same pattern works with Claude. Swap the client and model:

import anthropic

client = anthropic.Anthropic()

def extract_with_claude(pdf_path: str) -> dict:
    text = extract_text_from_pdf(pdf_path)

    message = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=2000,
        system=INVOICE_SYSTEM_PROMPT,
        messages=[
            {"role": "user", "content": f"Extract data from this document:\n\n{text}"}
        ],
    )

    raw = message.content[0].text.strip()
    cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.MULTILINE)
    return json.loads(cleaned)

Step 4 โ€” Validate the Output with a JSON Schema

An LLM that produces the wrong JSON structure causes silent bugs that are hard to debug downstream. Validation catches these at the extraction layer, where the context for debugging is still available.

Use Pydantic for clean, Pythonic validation with helpful error messages:

from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import date

class Vendor(BaseModel):
    name: Optional[str] = None
    address: Optional[str] = None
    email: Optional[str] = None

class LineItem(BaseModel):
    description: str
    quantity: Optional[float] = None
    unit_price: Optional[float] = None
    total: Optional[float] = None

class InvoiceData(BaseModel):
    invoice_number: Optional[str] = None
    invoice_date: Optional[date] = None
    due_date: Optional[date] = None
    vendor: Optional[Vendor] = None
    bill_to: Optional[Vendor] = None
    line_items: list[LineItem] = Field(default_factory=list)
    subtotal: Optional[float] = None
    tax: Optional[float] = None
    total_due: Optional[float] = None
    currency: Optional[str] = None
    payment_terms: Optional[str] = None

    @field_validator("currency")
    @classmethod
    def validate_currency(cls, v):
        if v and len(v) != 3:
            raise ValueError("Currency must be a 3-letter ISO code")
        return v.upper() if v else v

# Use it:
def extract_and_validate(pdf_path: str) -> InvoiceData:
    raw_data = extract_invoice_data(pdf_path)
    return InvoiceData(**raw_data)  # Raises ValidationError on bad data

Pydantic coerces types automatically โ€” it'll convert the string "2024-03-15" to a Python date object and "1250.00" to a float. Validation errors include the field name and the invalid value, which makes debugging fast.

Handling Scanned PDFs with OCR

Scanned PDFs contain images, not text. PyMuPDF will extract nothing useful from them. You need OCR first.

The fastest path is Tesseract via pytesseract. For higher accuracy on noisy scans, consider cloud OCR (AWS Textract, Google Document AI, Azure Document Intelligence) โ€” which also handles tables and forms better.

Detecting Whether a PDF Needs OCR

import fitz

def needs_ocr(pdf_path: str, min_chars_per_page: int = 50) -> bool:
    """Returns True if the PDF appears to be scanned (no meaningful text layer)."""
    doc = fitz.open(pdf_path)
    total_chars = sum(len(page.get_text("text").strip()) for page in doc)
    avg_chars = total_chars / max(len(doc), 1)
    doc.close()
    return avg_chars < min_chars_per_page

OCR Pipeline with Tesseract

import fitz
import pytesseract
from PIL import Image
import io

def ocr_pdf(pdf_path: str, dpi: int = 300) -> str:
    """Render each page as an image and run Tesseract OCR."""
    doc = fitz.open(pdf_path)
    pages = []

    for page_num, page in enumerate(doc, start=1):
        # Render at high DPI for better OCR accuracy
        mat = fitz.Matrix(dpi / 72, dpi / 72)
        pix = page.get_pixmap(matrix=mat)
        img = Image.open(io.BytesIO(pix.tobytes("png")))

        text = pytesseract.image_to_string(img, lang="eng")
        if text.strip():
            pages.append(f"--- Page {page_num} ---\n{text.strip()}")

    doc.close()
    return "\n\n".join(pages)

def extract_text_auto(pdf_path: str) -> str:
    """Auto-detect and use the right extraction method."""
    if needs_ocr(pdf_path):
        print("Scanned PDF detected โ€” using OCR")
        return ocr_pdf(pdf_path)
    return extract_text_from_pdf(pdf_path)

DPI matters for OCR accuracy

72 DPI (screen resolution) produces poor OCR results on documents with small text. Always render at 200โ€“300 DPI minimum. For documents with very small print (legal footnotes, fine print), go to 400 DPI. The larger image size is worth the processing time.

Multi-Page Documents and Context Window Limits

A 100-page research paper or a long contract will exceed the context window of most LLMs, or at minimum drive up costs significantly. You have two strategies:

Strategy 1: Chunked Extraction with Merging

Split the document into overlapping chunks, extract from each, then merge results. Good for documents where the target data appears in specific sections (most invoices, most contracts).

def chunk_text(text: str, max_chars: int = 12000, overlap: int = 500) -> list[str]:
    """Split text into overlapping chunks."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + max_chars
        chunks.append(text[start:end])
        start = end - overlap  # overlap to avoid splitting mid-sentence
    return chunks

def extract_from_long_document(pdf_path: str) -> dict:
    text = extract_text_auto(pdf_path)

    if len(text) < 12000:
        # Short enough to fit in one call
        return extract_invoice_data(pdf_path)

    chunks = chunk_text(text)
    partial_results = []

    for i, chunk in enumerate(chunks):
        print(f"Processing chunk {i+1}/{len(chunks)}...")
        result = call_llm_with_text(chunk)  # your extraction function
        partial_results.append(result)

    # Merge: prefer non-null values from earlier chunks
    return merge_extractions(partial_results)

def merge_extractions(results: list[dict]) -> dict:
    """Merge partial results, preferring first non-null value per field."""
    merged = {}
    for result in results:
        for key, value in result.items():
            if key not in merged or merged[key] is None:
                merged[key] = value
    return merged

Strategy 2: Section-Targeted Extraction

For documents with predictable structure (contracts, research papers, annual reports), locate the relevant section first using keyword search, then extract from only that section. Much more efficient and often more accurate.

def find_relevant_section(text: str, keywords: list[str], window: int = 3000) -> str:
    """Extract a window of text around the first keyword match."""
    text_lower = text.lower()
    for kw in keywords:
        idx = text_lower.find(kw.lower())
        if idx != -1:
            start = max(0, idx - 200)
            end = min(len(text), idx + window)
            return text[start:end]
    return text[:window]  # fallback: first section

# For a contract, target the payment terms section:
payment_section = find_relevant_section(
    text,
    keywords=["payment terms", "payment schedule", "fees and payment"]
)

Production Considerations

Retry Logic and Error Handling

LLM APIs are rate-limited and occasionally return malformed responses. Wrap your extraction calls in retry logic from the start:

import time
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=30),
    reraise=True,
)
def extract_with_retry(pdf_path: str) -> InvoiceData:
    return extract_and_validate(pdf_path)

Confidence and Human-Review Flagging

Not every extraction will be reliable. Build in a mechanism to flag low-confidence results for human review:

def assess_confidence(data: InvoiceData) -> tuple[float, list[str]]:
    """
    Return a confidence score (0โ€“1) and a list of concerns.
    Flag for human review if score < 0.7.
    """
    concerns = []
    required_fields = ["invoice_number", "invoice_date", "total_due", "vendor"]

    missing = [f for f in required_fields if getattr(data, f) is None]
    if missing:
        concerns.append(f"Missing required fields: {', '.join(missing)}")

    # Check line item math
    if data.line_items and data.subtotal:
        computed = sum(item.total or 0 for item in data.line_items)
        if abs(computed - data.subtotal) > 0.02:
            concerns.append(f"Line item sum ({computed}) doesn't match subtotal ({data.subtotal})")

    score = 1.0 - (len(missing) * 0.15) - (len(concerns) * 0.1)
    return max(0.0, score), concerns

Cost Estimation

Extraction cost scales with token count. A typical one-page invoice runs about 500โ€“800 tokens of input text. At GPT-4o pricing, that's roughly $0.001โ€“$0.002 per document. Multi-page contracts can run 10โ€“30x more.

Reduce costs by trimming headers, footers, and boilerplate before sending to the LLM. Running a quick heuristic to strip repeated navigation text can cut token counts by 20โ€“40% on some document types.

Real-World Examples by Document Type

The same pipeline adapts to any structured document. Change the schema and system prompt โ€” the rest of the code stays identical.

Medical Records / Lab Reports

Key fields: Patient name, DOB, test names, reference ranges, abnormal flags, ordering physician

Use the system prompt to normalize units (mg/dL vs mmol/L). Flag any value outside reference range explicitly.

Legal Contracts

Key fields: Parties, effective date, termination date, governing law, payment amounts, key obligations, notice periods

Contracts are long โ€” use section-targeted extraction for specific clauses rather than processing the full document.

Research Papers

Key fields: Title, authors, abstract, DOI, journal, year, methodology, key findings, limitations

Abstracts are usually on page 1. For findings, extract section by section to stay within context limits.

Bank Statements

Key fields: Account holder, account number (last 4), statement period, opening balance, closing balance, transactions[]

Transaction tables are the main challenge. Use table-aware extraction (pdfplumber) for accuracy.

Job Applications / CVs

Key fields: Name, contact info, work experience[], education[], skills[], certifications[]

CV formats vary wildly. LLMs handle this variation far better than any rule-based parser.

Putting the Full Pipeline Together

Here's the complete flow in one place โ€” a single function you can drop into any project:

def process_invoice_pdf(pdf_path: str) -> dict:
    """
    Full pipeline: extract โ†’ call LLM โ†’ validate โ†’ assess confidence.
    Returns a dict with the extracted data and metadata.
    """
    # 1. Extract text (auto-detect scanned vs digital)
    text = extract_text_auto(pdf_path)

    # 2. Call LLM with retry
    raw = call_llm_with_text(text)

    # 3. Parse and validate
    data = InvoiceData(**raw)

    # 4. Assess confidence
    score, concerns = assess_confidence(data)

    return {
        "data": data.model_dump(),
        "confidence": score,
        "concerns": concerns,
        "needs_review": score < 0.7,
    }

Key Takeaways

  • โœ“ Use PyMuPDF for digital PDFs; pdfplumber for table-heavy documents; Tesseract for scanned PDFs.
  • โœ“ Define your output schema in the system prompt โ€” schema-first prompting produces consistent JSON.
  • โœ“ Always set temperature=0 for extraction tasks.
  • โœ“ Strip code fences from LLM responses before calling json.loads().
  • โœ“ Validate with Pydantic โ€” it coerces types and gives useful error messages.
  • โœ“ For long documents, chunk with overlap or target specific sections.
  • โœ“ Build confidence scoring and human-review flagging in from the start.
  • โœ“ The same pattern works for any document type โ€” just change the schema.

Feedback

Live