DEV Community

mahuan2928
mahuan2928

Posted on

Stop Copy-Pasting Invoices: Extract PDF Data to CSV with 100 Lines of Python

Stop Copy-Pasting Invoices: Extract PDF Data to CSV with 100 Lines of Python

Every month, somebody on every finance team does the same soul-crushing job: open an invoice PDF, copy the invoice number into a spreadsheet, copy the date, copy the total, repeat forty times. It's slow, it's boring, and it's exactly the kind of work where humans make quiet mistakes — a transposed digit in a total doesn't announce itself until the books don't balance.

The good news: if your invoices are text-based PDFs (the kind exported by accounting software, not scanned paper), this is a solved problem with embarrassingly little code. In this post we'll build a batch extractor in Python: point it at a folder of PDFs, get a CSV out. Full working demo, MIT licensed, is on GitHub — link at the end.

Step 1: Get the text out of the PDF

We'll use pdfplumber, which wraps pdfminer and gives you clean page text:

import pdfplumber

def pdf_text(path):
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)
Enter fullscreen mode Exit fullscreen mode

That's the whole "PDF parsing engine." For born-digital invoices, the text layer is already there — we just need to mine it.

Step 2: Extract fields with anchored regexes

Invoice layouts vary, but the labels are remarkably stable. Three patterns cover our three fields:

FIELDS = {
    "invoice_number": re.compile(
        r"Invoice\s*(?:No\.?|Number|#)\s*[:#]?\s*([A-Za-z0-9\-]+)", re.IGNORECASE),
    "invoice_date": re.compile(
        r"\bDate\s*[:\-]?\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE),
    "total_amount": re.compile(
        r"^\s*Total\s*:\s*\$?\s*([\d,]+\.\d{2})", re.IGNORECASE | re.MULTILINE),
}
Enter fullscreen mode Exit fullscreen mode

One detail matters here: the total_amount pattern is anchored to the start of a line. Without that anchor, Subtotal: $1,000.00 happily matches a search for Total: — and your CSV fills up with subtotals wearing a fake moustache. Ask me how I know.

Amounts get a light normalization (1,100.00 → 1100.00) so the CSV is spreadsheet- and diff-friendly.

Step 3: Never drop a row silently

Here's the design decision I'd defend in a code review: when a field isn't found, we still write the row, leave the field empty, and name the gap in a status column:

filename,invoice_number,invoice_date,total_amount,status
partial.pdf,INV-2026-099,2026-03-01,,missing: total_amount
Enter fullscreen mode Exit fullscreen mode

A batch tool that quietly skips files is a batch tool that creates reconciliation mysteries. Forty invoices in, thirty-nine rows out, and no clue which one vanished? No thanks. The status column turns "the numbers don't add up" into a ten-second lookup.

Step 4: Batch it and test it

The main loop is unglamorous: glob *.pdf, extract each, write rows with csv.DictWriter, print a per-file status. The part most tutorials skip is the test: the demo repo generates two synthetic invoices with ReportLab, ships an expected_output.csv with the known-correct answers, and the smoke test is literally:

python make_sample.py
python extract.py --input ./samples --out results.csv
diff results.csv expected_output.csv   # silence means success
Enter fullscreen mode Exit fullscreen mode

Synthetic samples matter more than they look. You can't put real client invoices in a public repo, and "it worked on my machine once" isn't a test. Generate your fixtures, check them in, and let the diff be the judge.

Where the demo stops — and production starts

This demo extracts three hardcoded fields from well-behaved PDFs. Real invoice work is messier, and three problems show up immediately:

  1. Every vendor has a different layout. Hardcoded regexes become a maintenance hobby. You want the fields — names, patterns, types — defined in a config file, so a new vendor is a new YAML block, not a code change.
  2. Extraction without validation is a liability. Regex found a number after Total: — but is it right? The check that catches real money errors is cross-field arithmetic: does total == subtotal + tax? I once generated a test invoice with a deliberately wrong total (500 + 50 ≠ 560) specifically to watch a validator flag it. That's the feature that pays for itself the first time it catches a fat-fingered invoice before payment, not after.
  3. Someone has to review the weird ones. In production you want a validation report and a "needs human review" list — the 5% of files that deserve eyes — instead of a human re-checking 100% of them.

Add per-field confidence scores, Excel output, and an OCR path for scanned PDFs, and the demo grows into the tool I actually ship to clients.

Try it / get the full version

If you adapt the demo to your own invoice layouts, I'd love to hear which pattern broke first — vendor creativity in invoice design is truly boundless.

— Marty Ma, Senior AI & Data Engineer (Tokyo)

Top comments (0)