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.
.webp)
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.
| Approach | Handles layout variation | Handles ambiguous text | Maintenance burden | Cost |
|---|---|---|---|---|
| Regex / rules | Poor | None | High | Free |
| ML extractors (LayoutLM) | Good | Moderate | Medium | GPU needed |
| LLM (GPT-4, Claude, etc.) | Excellent | Excellent | Low | API 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 dataPydantic 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_pageOCR 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 mergedStrategy 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), concernsCost 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=0for 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.