How-to
Getting Started With Python Excel AutomationDeep dive

Extract Tables from a PDF into Excel with Python

Get tables out of PDF statements and reports and into a workbook with pdfplumber and pandas: ruled tables that span several pages, repeated header rows, amounts with thousands separators, day-first dates, borderless tables rebuilt from word positions, a control-total check and a formatted .xlsx.

Suppliers send statements as PDFs, banks send them as PDFs, and the old reporting system that nobody can change exports to PDF only. The numbers are in there — usually as a neat table — but copying them into Excel by hand is slow and error-prone, and Excel's own PDF import handles multi-page tables inconsistently. pdfplumber reads the text layer of a PDF together with the position of every character and line, which is enough to rebuild the table in Python, clean it with pandas and write it to a workbook. This page, part of Handling Excel File Formats and Conversions, walks through a two-page accounts receivable statement.

Prerequisites

Bash
pip install pdfplumber pandas openpyxl

statement.pdf lists 60 invoices in a ruled table that continues onto a second page, repeating its header row there. Amounts are formatted like 2,675.14, dates are day-first (02/09/2026), the statement total is printed in the introduction, and a small summary table without any ruling lines sits at the end. That mix — a gridded table, a page break, formatted numbers and a borderless table — is typical of system-generated PDFs.

From PDF pages to a clean worksheet pdfplumber extracts the table from each page, the page tables are stacked with repeated headers removed, text is converted to numbers and dates, the total is checked against the statement, and the result is written to Excel. Extract per page page.extract_tables() Stack pages drop repeated headers Convert types '2,675.14' → 2675.14 Check total against the printed figure Write .xlsx formats and widths

Step 1 — See what pdfplumber finds

Open the PDF and ask each page for its tables. For tables drawn with ruling lines, the default settings use those lines to find the cell boundaries:

Python
import pdfplumber

with pdfplumber.open("statement.pdf") as pdf:
    print(len(pdf.pages), "pages")
    for page in pdf.pages:
        for table in page.extract_tables():
            print(f"page {page.page_number}: {len(table)} rows, first two: {table[:2]}")

Each table comes back as a list of rows, each row a list of cell strings. The first page holds 35 rows and the second 27, and both start with the header row, because the report repeats it after the page break. Before writing any cleaning code, look at this raw output: it shows whether columns were split correctly, whether any cells came back as None (merged or empty cells), and whether a page contains more than one table.

A PDF with no text layer — a scan or a photographed page — returns no tables and no text at all. Check with page.extract_text(): if it is empty, the page is an image, and it needs OCR first. ocrmypdf adds a text layer to a scanned PDF, after which the steps below work, though OCR errors in the digits need the control-total check in Step 4 even more.

Step 2 — Stack the pages and drop repeated headers

Take the header from the first page, then keep every row from every page that is not a copy of it:

Python
import pandas as pd

with pdfplumber.open("statement.pdf") as pdf:
    tables = [t for page in pdf.pages for t in page.extract_tables()]

header = tables[0][0]
body = [row for t in tables for row in t if row != header]
raw = pd.DataFrame(body, columns=header)
print(raw.shape)
print(raw.head(3).to_string(index=False))

60 rows, five columns, all text. Comparing whole rows with the header is the simplest reliable filter for repeated headers; on some reports the repeated header is slightly different (a "continued" suffix, say), in which case filter on a column instead — drop rows whose Invoice value does not match the invoice number pattern.

Two page tables become one frame On the left the tables from page 1 and page 2, each starting with the same header row. On the right a single table with one header and the rows from both pages. A B C 1 Invoice Date Amount 2 INV-2401 02/09/2026 2,675.14 3 … … … 4 Invoice Date Amount 5 INV-2435 08/09/2026 2,228.31 A B C 1 Invoice Date Amount 2 INV-2401 02/09/2026 2,675.14 3 … … … 4 INV-2435 08/09/2026 2,228.31 before after drop header copies

Step 3 — Turn text into numbers and dates

Everything pdfplumber returns is a string. Convert the columns that should be numbers and dates, and be explicit about the formats — 2,675.14 needs its thousands separator removed, and 02/09/2026 is the 2nd of September, not the 9th of February:

Python
df = raw.copy()
df["Amount"] = pd.to_numeric(df["Amount"].str.replace(",", "", regex=False))
df["Date"] = pd.to_datetime(df["Date"], format="%d/%m/%Y")
df["Customer"] = df["Customer"].str.strip()
print(df.dtypes.to_dict())
print(df.head(3).to_string(index=False))

pd.to_numeric raises on anything it cannot parse, which is what you want here: a stray footnote marker or a cell that swallowed two values should stop the job, not become NaN. Statements from continental European systems print 2.675,14; for those, remove the dots and replace the comma with a point, as described in Read CSV Exports with Semicolons and Decimal Commas. Negative amounts printed as (1,234.00) or 1,234.00- need their own rule — see Convert Trailing Minus and CR/DR Amounts with pandas.

Step 4 — Check the result against the document

Extraction errors are silent: a row lost at a page break or a digit dropped by OCR produces a perfectly plausible table. Most statements print a total somewhere; find it in the text and compare:

Python
import re

with pdfplumber.open("statement.pdf") as pdf:
    first_page_text = pdf.pages[0].extract_text()
printed = float(re.search(r"Statement total: ([\d,]+\.\d{2})", first_page_text).group(1).replace(",", ""))
extracted = round(df["Amount"].sum(), 2)
print(f"printed {printed:,.2f}  extracted {extracted:,.2f}  rows {len(df)}")
assert abs(printed - extracted) < 0.005, "extracted total does not match the statement"
assert df["Invoice"].is_unique

If the PDF has no total, check the row count against the invoice numbers (a gap in INV-2401 … INV-2460 means a missing row) or compare with a figure from another system. The idea is the same as in Reconcile Excel Totals with Control Totals.

Step 5 — Borderless tables: rebuild rows from word positions

The summary table at the end has no lines, so the default settings do not see it as a table. Switching to the text-based strategy (vertical_strategy="text") guesses column edges from how words line up, and on this table it splits words in the middle — Inv / oices, 79,080 / .86. When each cell is a single word or number, grouping words by their vertical position is simpler and more predictable:

Python
from itertools import groupby

with pdfplumber.open("statement.pdf") as pdf:
    words = pdf.pages[-1].extract_words()

lines = [[w["text"] for w in grp]
         for _, grp in groupby(sorted(words, key=lambda w: (round(w["top"]), w["x0"])),
                               key=lambda w: round(w["top"]))]
start = lines.index(["Region", "Invoices", "Total"])
summary = pd.DataFrame(lines[start + 1:start + 3], columns=lines[start])
summary["Total"] = pd.to_numeric(summary["Total"].str.replace(",", "", regex=False))
print(summary.to_string(index=False))

extract_words returns each word with its coordinates. Sorting by the rounded top position and then by x0 puts words in reading order, and groupby on the top position collects each printed line. For borderless tables whose cells contain spaces — "Customer B01" — define the column boundaries yourself with explicit_vertical_lines, measured once from the header positions, and pass them to extract_table.

Three ways to read a table from a PDF Comparison of pdfplumber, camelot and tabula-py for extracting PDF tables, with their requirements and strengths. pdfplumber pure Python, no Java words, lines and positions borderless tables need rules camelot lattice and stream modes accuracy report per table needs Ghostscript for lattice tabula-py good defaults on simple reports needs a Java runtime less control over edge cases

Step 6 — Write the workbook

With clean types, writing is ordinary pandas work. Give the amount column a number format, the dates a date format, and widen the columns:

Python
with pd.ExcelWriter("statement.xlsx", engine="xlsxwriter", datetime_format="dd/mm/yyyy") as xw:
    df.to_excel(xw, sheet_name="Invoices", index=False)
    summary.to_excel(xw, sheet_name="Summary", index=False)
    money = xw.book.add_format({"num_format": "#,##0.00"})
    ws = xw.sheets["Invoices"]
    ws.set_column("A:B", 12)
    ws.set_column("C:C", 16)
    ws.set_column("E:E", 12, money)
    ws.autofilter(0, 0, len(df), len(df.columns) - 1)
    ws.freeze_panes(1, 0)
print(pd.read_excel("statement.xlsx", sheet_name=None).keys())

The amounts are now real numbers that sum, sort and pivot in Excel, and the dates are real dates — which is the whole point of the exercise.

Common pitfalls

  • Scanned PDFs. No text layer, no tables. OCR first, then check totals carefully.
  • Merged or empty cells. They come back as None or empty strings; forward-fill group labels that were printed once per block.
  • Wrapped cell text. A long customer name that wraps onto two lines inside a cell arrives with a newline in it; replace \n with a space.
  • Day-first dates. Always pass format= to to_datetime; guessing turns 02/09 into February for half the rows.
  • Header rows that differ slightly per page. Filter on the content of a key column instead of comparing whole rows.

Performance notes

pdfplumber parses every character on a page, so it handles hundreds of pages per minute rather than thousands. For a large archive, crop each page to the table's area with page.crop(bbox) before extracting — less text to analyse — and process files in parallel. Keep the raw extracted rows alongside the cleaned output while you tune the rules, so a wrong conversion can be traced back to what the PDF actually said.

Conclusion

Extract each page's tables with pdfplumber, stack them while dropping repeated headers, and convert the text to numbers and dates with explicit formats. Check the result against a total or a sequence printed in the document before trusting it. For tables without ruling lines, rebuild rows from word positions or give pdfplumber explicit column edges. Then write the clean frame to Excel with number and date formats.

Frequently asked questions

Can Python convert a PDF table to Excel? Yes. pdfplumber, camelot and tabula-py extract tables from PDFs that have a text layer; pandas then cleans the result and writes it with to_excel. Scanned PDFs need OCR, for example with ocrmypdf, before any of them can read the text.

Why does pdfplumber return no tables? Either the page is a scanned image with no text layer — extract_text() returns nothing — or the table has no ruling lines. For borderless tables use the text strategy, explicit column edges, or group extract_words() output by line.

How do I handle a table that continues across pages? Extract the table from every page, take the header from the first page, and keep every row that is not a copy of the header. Then check the row count or total against the document.

How do I convert "1,234.56" strings from a PDF into numbers? Remove the thousands separator and parse: pd.to_numeric(col.str.replace(",", "")). For European formats such as 1.234,56 remove the dots and replace the comma with a point first.

More on file formats & conversion

More on file formats & conversion →