A PM once sent me a PDF invoice at 4:52 PM on a Friday and asked, "Can you just make this editable?"
I said sure, thinking it was a five-minute job. It was not a five-minute job. It was a two-hour crash course in why PDFs are, structurally, one of the most hostile formats you can hand to a parser. A PDF doesn't know it has a table. It doesn't know it has rows or columns or cells. All it knows is "put the character '4' at coordinate (212, 588)." Everything that looks like a table to your eyes is something you have to reconstruct from a pile of positioned glyphs.
If you've ever been asked to "just" turn a document into a spreadsheet, this one's for you.
Why this is harder than it looks
Excel and CSV are inherently tabular. Rows, columns, cells, done. PDFs (and honestly, a lot of scanned images and even inconsistent Word docs) are visual formats. They describe how something should look on a page, not what the data means.
So "convert this PDF to Excel" is really three separate problems stacked on top of each other:
- Text extraction — get the raw characters and their positions off the page
- Structure inference — figure out which characters belong to the same row, and which belong to the same column
- Type recovery — decide that "1,204.50" is a number, "03/14/2026" is a date, and "Paid" is a status, not just a string Most of the pain lives in step 2. Step 1 is a solved problem. Step 3 is annoying but manageable. Step 2 is where every "why is my table scrambled" bug report comes from.
A basic extraction pipeline
If you're building this yourself in Python, pdfplumber is the most honest tool for the job because it exposes character-level coordinates instead of pretending it fully understands your layout.
import pdfplumber
import pandas as pd
with pdfplumber.open("invoice.pdf") as pdf:
page = pdf.pages[0]
table = page.extract_table()
df = pd.DataFrame(table[1:], columns=table[0])
df.to_excel("invoice.xlsx", index=False)
That works great on a clean, ruled table. The moment your PDF was generated by some internal reporting tool with no grid lines, extract_table() starts guessing based on whitespace gaps, and guessing is where things go sideways. Columns that are close together merge into one. A multi-line cell (like an address wrapping to two lines) gets split into two separate rows.
You can tune it with explicit strategies:
table_settings = {
"vertical_strategy": "text",
"horizontal_strategy": "text",
"intersection_tolerance": 5,
}
table = page.extract_table(table_settings)
This tells pdfplumber to infer columns from text alignment rather than looking for drawn lines, which helps with borderless tables but will absolutely still fall apart on anything irregular. There's no universal setting. You end up writing config per document type, which is a sentence that should make every engineer wince a little.
Scanned documents are a different problem entirely
If the PDF is a scanned image (common with older invoices, government forms, anything printed and re-scanned), there's no text layer at all. extract_table() returns nothing because there's nothing to extract. You need OCR first.
import pytesseract
from pdf2image import convert_from_path
pages = convert_from_path("scanned_report.pdf", dpi=300)
text = pytesseract.image_to_string(pages[0])
DPI matters more than people expect. Below 200 DPI, Tesseract starts confusing "8" and "B," or "1" and "l," which is fine for a sentence but catastrophic for a column of financial figures. 300 DPI is a reasonable floor. You also want the page perfectly deskewed, since even a 2-degree tilt measurably hurts OCR accuracy on tabular numeric data, because the row-alignment heuristics assume horizontal baselines.
Once you have OCR'd text, you're back to the structure-inference problem, except now your input also has recognition errors mixed in. This is usually where teams decide to draw a line between "documents we'll build custom parsing for" and "documents we just need converted, once, right now."
Multi-page tables: the quiet nightmare
A table that spans pages 3 through 7 of a PDF isn't one table to a parser, it's five. Headers usually don't repeat on every page, so you have to detect "this page's table has no header row, reuse the previous one" and stitch the DataFrames together yourself:
frames = []
header = None
for page in pdf.pages:
raw = page.extract_table()
if raw is None:
continue
if header is None:
header, rows = raw[0], raw[1:]
else:
rows = raw # no header on continuation pages
frames.append(pd.DataFrame(rows, columns=header))
full_df = pd.concat(frames, ignore_index=True)
This works until a continuation page accidentally repeats the header (some report generators do this inconsistently), and now you've got a header row sitting in the middle of your data. Worth a quick filter step checking if any row equals the header before you finalize the DataFrame.
When to build vs. when to just get the data out
If you're processing hundreds of structurally identical documents (same invoice template from the same vendor, every month), building a tuned extraction pipeline is worth it. You'll amortize the setup cost fast.
But most of us don't live in that world. Most of us get a one-off PDF from finance, or a client sends over a scanned form, and the actual requirement is "I need these numbers in a spreadsheet in the next ten minutes," not "I need a maintainable extraction service." For that case, I usually skip writing a parser altogether and just run it through PDF Converter, upload the file, get the Excel back, and sanity-check the numbers before I move on. It's not a replacement for a real pipeline when you actually need one, but it saves you from reinventing table-inference logic for a file you'll never touch again. You can check out the article: how to convert PDF to Excel in Google Sheets
The takeaway
"Just make it editable" hides three separate hard problems wearing a trench coat. If you're building this at scale, invest in per-template extraction logic and expect to maintain it. If you're just trying to unblock yourself on a Friday afternoon, there's no shame in reaching for a converter and getting back to your actual ticket.
If you've built a table-extraction pipeline that handles multi-page or borderless tables cleanly, I'd genuinely like to hear how; drop it in the comments.
Top comments (0)