Extract Tables from PDF in Python with pdfplumber: CSV, Excel and pandas (2026)
Bank statements, invoices, price lists and government reports are full of tables, and they are almost always shared as PDF files. Copying them into a spreadsheet by hand is slow and error-prone, and a plain text extraction gives you one long string with the columns mixed together. pdfplumber solves this: it reads every character, line and rectangle on a PDF page with their exact positions, finds the cell grid and returns each table as a list of rows that you can load straight into pandas, CSV or Excel. It installs with plain pip and needs no Java runtime (unlike tabula-py). In this tutorial we extract a ruled invoice table, turn it into a clean pandas DataFrame, save CSV and Excel files, handle a borderless table with the text strategy, merge a table that runs across two pages and draw a visual debug image of what pdfplumber detected. Everything was tested in a fresh virtual environment on Python 3.13.5 with pdfplumber 0.11.10 (released June 2026), pdfminer.six 20260107, pypdfium2 5.14.0, Pillow 13.0.0, pandas 3.1.0 and openpyxl 3.1.5, and every output block below is the real output of the scripts shown.
Which library should you use for PDF tables?
- pdfplumber: pure Python on top of pdfminer.six, precise control over table settings, built-in debug images. Best all-rounder for digital (text-based) PDFs.
- camelot: strong lattice and stream modes for ruled and whitespace tables, with a heavier dependency set that includes OpenCV.
- tabula-py: a wrapper around a Java program, so you need a Java runtime installed.
- pypdf: great for merging, splitting and plain text, but it has no table detection.
- OCR tools: needed only when the PDF is a scanned image with no text layer (see the tips below).
Install
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pdfplumber==0.11.10 pandas openpyxl
pip install reportlab # only to create the sample PDFs
Create two sample PDFs
To keep the tutorial reproducible we generate the PDFs with reportlab: an invoice with a fully ruled table and a sales report whose borderless table runs over two pages with a repeated header. Replace them with your own files at any step.
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
styles = getSampleStyleSheet()
# 1) Invoice with a ruled (bordered) table
items = [["Item", "Qty", "Unit price", "Amount"],
["Python course", "1", "49.00", "49.00"],
["Data cleaning e-book", "2", "12.50", "25.00"],
["API hosting (month)", "3", "9.99", "29.97"],
["Support plan", "1", "1,200.00", "1,200.00"]]
grid = Table(items, colWidths=[200, 50, 90, 90])
grid.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 0.8, colors.black),
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey)]))
SimpleDocTemplate("invoice.pdf", pagesize=A4).build(
[Paragraph("Invoice INV-2026-1104", styles["Title"]),
Paragraph("Customer: Acme Analytics Ltd", styles["Normal"]),
Spacer(1, 12), grid])
# 2) Sales report: borderless table that spans two pages
rows = [["Region", "Month", "Orders", "Revenue"]]
for i, region in enumerate(["North", "South", "East", "West"] * 15):
rows.append([region, f"2026-{i % 12 + 1:02d}", str(100 + i * 7), f"{2500 + i * 133.5:.2f}"])
plain = Table(rows, colWidths=[110, 90, 80, 100], repeatRows=1)
plain.setStyle(TableStyle([("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold")]))
SimpleDocTemplate("sales_report.pdf", pagesize=A4).build(
[Paragraph("Quarterly sales report", styles["Title"]), plain])
print("created invoice.pdf and sales_report.pdf")
Step 1: Extract a table from a PDF page
import pdfplumber
with pdfplumber.open("invoice.pdf") as pdf:
page = pdf.pages[0]
print("pages:", len(pdf.pages), "| size:", page.width, "x", page.height)
table = page.extract_table() # largest table on the page
for row in table:
print(row)
$ python tables_basic.py
pages: 1 | size: 595.2756 x 841.8898
['Item', 'Qty', 'Unit price', 'Amount']
['Python course', '1', '49.00', '49.00']
['Data cleaning e-book', '2', '12.50', '25.00']
['API hosting (month)', '3', '9.99', '29.97']
['Support plan', '1', '1,200.00', '1,200.00']
extract_table() returns the largest table on the page as a list of rows, and each row is a list of cell strings. Use extract_tables() (plural) when a page holds several tables; it returns a list of tables. Note that the title and customer line are not included, because they sit outside the ruled grid. Page sizes are in PDF points (1/72 inch), so 595 x 842 is an A4 page.
Step 2: Load the table into pandas and export CSV and Excel
import pandas as pd
import pdfplumber
with pdfplumber.open("invoice.pdf") as pdf:
rows = pdf.pages[0].extract_table()
df = pd.DataFrame(rows[1:], columns=rows[0])
for col in ["Qty", "Unit price", "Amount"]:
df[col] = pd.to_numeric(df[col].str.replace(",", ""))
print(df.dtypes.to_string())
print(df.to_string(index=False))
print("Invoice total:", round(df["Amount"].sum(), 2))
df.to_csv("invoice_items.csv", index=False)
df.to_excel("invoice_items.xlsx", index=False)
print("saved invoice_items.csv and invoice_items.xlsx")
$ python tables_to_pandas.py
Item str
Qty int64
Unit price float64
Amount float64
Item Qty Unit price Amount
Python course 1 49.00 49.00
Data cleaning e-book 2 12.50 25.00
API hosting (month) 3 9.99 29.97
Support plan 1 1200.00 1200.00
Invoice total: 1303.97
saved invoice_items.csv and invoice_items.xlsx
Every value comes out of a PDF as text, so convert numeric columns yourself. Removing the thousands separator first matters: without it, "1,200.00" would fail to convert. After that you can sum, filter and join the data like any other DataFrame, and to_excel uses openpyxl to write a real .xlsx file.
Step 3: Borderless tables with the text strategy
Many reports draw no lines at all; the columns are only aligned text. pdfplumber's default strategy looks for drawn lines, so it finds nothing. Switch both strategies to "text" so it builds the grid from word alignment:
import pdfplumber
with pdfplumber.open("sales_report.pdf") as pdf:
page = pdf.pages[0]
print("default 'lines' strategy:", page.extract_tables())
settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
table = page.extract_table(settings)
rows = [r for r in table if any(cell.strip() for cell in r)] # drop blank spacer rows
print("text strategy rows:", len(table), "-> after removing blanks:", len(rows))
for row in rows[:4]:
print(row)
$ python tables_borderless.py
default 'lines' strategy: []
text strategy rows: 71 -> after removing blanks: 36
['Region', 'Month', 'Orders', 'Revenue']
['North', '2026-01', '100', '2500.00']
['South', '2026-02', '107', '2633.50']
['East', '2026-03', '114', '2767.00']
The empty list proves the default found no table. With the text strategy every row was found, but the gaps between lines also became empty rows (71 rows instead of 36), so we filter out rows whose cells are all blank. If columns get merged or split in your own files, tune "text_x_tolerance", "snap_tolerance" or "min_words_vertical" in the same settings dictionary.
Step 4: Merge a table that spans several pages
import pandas as pd
import pdfplumber
SETTINGS = {"vertical_strategy": "text", "horizontal_strategy": "text"}
header, data = None, []
with pdfplumber.open("sales_report.pdf") as pdf:
for page in pdf.pages:
table = page.find_table(SETTINGS)
if table is None:
continue
rows = [r for r in table.extract() if any(c.strip() for c in r)]
header = rows[0]
data += [r for r in rows[1:] if r != header] # skip repeated header
print(f"page {page.page_number}: {len(rows) - 1} rows, bbox={tuple(round(v) for v in table.bbox)}")
df = pd.DataFrame(data, columns=header).astype({"Orders": int, "Revenue": float})
print("total rows:", len(df))
print(df.groupby("Region")[["Orders", "Revenue"]].sum().round(2).to_string())
df.to_csv("sales_all_pages.csv", index=False)
$ python tables_multipage.py
page 1: 35 rows, bbox=(114, 111, 435, 751)
page 2: 25 rows, bbox=(114, 83, 435, 543)
total rows: 60
Orders Revenue
Region
East 4650 97575.0
North 4440 93570.0
South 4545 95572.5
West 4755 99577.5
find_table() returns a Table object, which gives you the bounding box as well as the rows. We loop over all pages, keep the header once, skip the header that the report repeats on page 2 and end up with all 60 data rows in one DataFrame, ready to group and save. If the pages also contain other content that confuses detection, crop the page first with page.crop((x0, top, x1, bottom)) using the bbox values and extract from the cropped area only.
Step 5: See what pdfplumber detected
import pdfplumber
with pdfplumber.open("invoice.pdf") as pdf:
page = pdf.pages[0]
finder = page.debug_tablefinder()
print("edges:", len(finder.edges), "| intersections:", len(finder.intersections),
"| cells:", len(finder.cells), "| tables:", len(finder.tables))
im = page.to_image(resolution=110)
im.debug_tablefinder()
im.save("invoice_debug.png")
print("saved invoice_debug.png")
$ python tables_debug.py
edges: 11 | intersections: 30 | cells: 20 | tables: 1
saved invoice_debug.png
The saved image shows the page with detected edges in red, intersections as circles and cells shaded in blue. Five rows times four columns gives the 20 cells reported. When a table comes out wrong, this picture tells you immediately whether a line was missed or text was split, so you know which setting to change.
Common errors and fixes
TypeError: 'NoneType' object is not iterable:extract_table()andfind_table()returnNonewhen no table is found (we checked this on the borderless report with default settings). Guard withif table is Noneor try the text strategy.- Empty result on a scanned PDF: if
len(page.chars)is 0, the page is only an image. We confirmed a scanned page gives 0 characters, an empty text and no tables. Run OCR first, then rebuild rows from the text boxes. ValueError: invalid literal for int()in pandas: blank spacer rows or values like "1,200.00" are still in the data. Drop blank rows and strip separators before converting, as in Steps 2 and 3.- Cells containing
None: merged cells in ruled tables produceNonefor the covered positions. Fill them forward in pandas withdf.ffill()when that matches the meaning of the table. - Slow on large files: open the PDF once with a
withblock and process only the pages you need, for examplepdf.pages[2:5].
Where to use this next
- Split a long statement into the pages that hold tables, or merge monthly reports first, with pypdf: merge and split PDFs in Python.
- Format the exported data with headers, number formats and charts using our openpyxl Excel automation guide.
- For scanned PDFs without a text layer, extract the text first with OCR in Python with RapidOCR.
Conclusion
With pdfplumber you can turn PDF tables into real data in a few lines: extract_table() for ruled tables, the text strategy for borderless ones, find_table() and a simple loop for tables that cross pages, and a debug image when something looks wrong. Convert the strings to numbers in pandas, export to CSV or Excel, and a monthly copy-and-paste job becomes a script that runs in seconds.