Turning messy PDFs into clean tables, on your own machine

By Arshad Ansari

The hard part of data engineering isn't moving structured data around. It's getting structure out of the mess in the first place — invoices with floating headers, statements with inconsistent layouts, reports where "total" means three different things on three different pages.

You don't need an expensive document-AI API or a fine-tuned vision model to handle this. In 2026 you can do it locally: parse the document to clean text, feed it to a small local model, and use a schema to force the output into validated records. The real value is the validation, not the model.

Stage 1: parse the document

The first step is turning the PDF into clean, readable text. Open-source tools have gotten good at this. The main contenders are:

Docling (IBM) is structurally rich — it understands tables, headers, sections, and builds a semantic tree before rendering to markdown. Slower but accurate. Use when the document has complex layout or you need to preserve structure.

Marker (Datalab) prioritizes speed and accuracy. Handles table detection well, but is GPU-hungry. Optional --use_llm flag if you want vision enhancement. Use when you have GPU and care about OCR quality.

MarkItDown (Microsoft) is the fastest and simplest. Shallow extraction, minimal structure, good for straightforward documents. Use when you need a quick pass and don't need perfect layout preservation.

For most work, Docling is a safe choice. Here's a minimal example:

from docling.document_converter import DocumentConverter

# Initialize once and reuse
converter = DocumentConverter()

# Convert a local PDF to markdown
result = converter.convert("invoice.pdf")
markdown_text = result.document.export_to_markdown()

print(markdown_text)

That's it. You get clean markdown with tables, headers, and readable text. Run this locally, no API calls.

Stage 2: extract and validate

Now you have readable text, but you need structured data — an Invoice object with vendor, date, line items, total. This is where a local model and a schema come in.

Use the instructor library with Pydantic. Instructor wraps your local model (via Ollama or any OpenAI-compatible endpoint) and enforces the schema you define. If the model returns invalid data, instructor retries automatically.

First, define your target schema:

from pydantic import BaseModel, Field
from typing import List

class LineItem(BaseModel):
    description: str
    quantity: float
    unit_price: float
    total: float

class Invoice(BaseModel):
    vendor_name: str = Field(..., description="Name of the vendor/supplier")
    invoice_date: str = Field(..., description="Invoice date in YYYY-MM-DD format")
    invoice_number: str
    line_items: List[LineItem]
    total_amount: float = Field(..., description="Total invoice amount")

Now, initialize instructor and point it at your local model:

import instructor

# Use instructor with Ollama (or any OpenAI-compatible local endpoint)
client = instructor.from_provider("ollama/llama2")

# Extract structured data from the markdown text
invoice_data = client.create(
    response_model=Invoice,
    messages=[
        {
            "role": "user",
            "content": f"Extract invoice data from this text:\n\n{markdown_text}"
        }
    ],
)

print(invoice_data)
# Output: Invoice(vendor_name='ABC Corp', invoice_date='2026-06-15', ...)

Instructor automatically validates the response against your Pydantic model. If the model misses a required field or returns the wrong type, instructor retries with a corrected prompt. Most small local models (llama2, mistral) succeed on the first try for clean documents.

The schema is your real reliability lever. A strict Pydantic model with type hints and field descriptions catches bad output before it gets to your database.

Why this works

You're not relying on the model's knowledge or accuracy for the hard stuff. You're relying on the schema. The model reads the markdown text, spots the relevant fields, and returns data that fits your shape. Validation happens automatically.

This is different from prompt engineering or fine-tuning. You're just being explicit about what you want.

Honest boundaries

This approach has real limits. Know them before you start:

Parsing is the bottleneck. If the PDF is scanned or heavily corrupted, markdown comes out garbled. No amount of schema validation fixes that. Test your parse output before tuning extraction.

Tables and multi-column layouts are still hard. A neatly formatted invoice works. A chaotic three-column statement with merged cells or irregular spacing is a gamble. Marker with --use_llm does better, but costs compute.

Numbers need reconciliation. Never trust extracted totals without spot-checking. If an invoice says total=$1000 but sum(line_items)=$999.50, something went wrong in the parse or extract. Always validate before loading into your system.

Small models miss fields. A frontier LLM like Claude or GPT-4 catches nuances a local llama2 might skip. If extraction is failing for domain-specific fields, try a bigger model or fallback to a paid API for that subset.

Scanned PDFs with OCR errors compound every problem. Parsing produces gibberish, the model can't read it, validation fails. If you're dealing with ancient paper, consider a specialized OCR tool (Tesseract, Surya) before Docling.

Start with clean PDFs or documents with native text. Test on a handful before scaling to thousands.

When to stop here

This stack — Docling + instructor + Pydantic — handles invoices, statements, expense reports, and most tabular documents where layout is consistent. If it's not working, the issue is usually in parsing (bad PDF quality) not extraction (bad model).

Don't add a fine-tuned model, a retrieval layer, or a complex retry pipeline unless you can measure that the schema-based approach is actually failing. Most of the time it won't be. Add weight only when you hit a wall.

If you're stuck on a specific document type, consider whether you actually need this at all. Sometimes the right answer is a human review step, not a better model.


For a working walkthrough, see Generating test data with a local model — it covers similar ground with synthetic data.

For more on building reliable pipelines without over-engineering, grab the free book or reach out.

Common questions

How do I handle messy PDFs?
Split it into two stages and stop treating it as one problem. First parse the document to clean markdown — this is the bottleneck, and if the parse output is garbled no amount of cleverness downstream rescues it, so test the parse before tuning anything else. Then extract structured records with a model constrained by a strict schema, so the output is validated rather than hoped for. The real value is the validation, not the model: a schema that rejects a bad record turns "the extraction is unreliable" into a specific, fixable failure.
Do I need a document-AI API to extract data from PDFs?
No, not for the common cases. Docling for parsing plus instructor and Pydantic for schema-constrained extraction handles invoices, statements, expense reports and most tabular documents with consistent layout, entirely on your own machine — no API, no per-page bill, and no documents leaving your infrastructure. Reach for a frontier model only where you can measure the local one failing, typically on domain-specific fields a small model skips.
When does local PDF extraction stop working?
Four places, reliably. Scanned or corrupted PDFs, where parsing produces gibberish and every later stage compounds it — try a dedicated OCR tool first. Chaotic multi-column layouts with merged cells, which are a gamble where a neat invoice is not. Numbers, which always need reconciliation: if the stated total and the sum of line items disagree, something went wrong upstream and validation should catch it before loading. And domain-specific fields that small models miss. Start with documents that have native text, test on a handful, and only add weight when you can measure the simple approach failing.

Get new posts by email

Data engineering notes like this one — pipelines, warehouse cost, and what actually breaks in production. A few a month, never padded to hit a schedule.

No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.

Want the whole playbook?

If this was useful, the long version is my book. Local-First Analytics — 314 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, or request a free review copy.

Get the book

Not ready to buy? Read chapter 1 free — the whole chapter, no email required.

Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email [email protected].