Extract Tables from PDF in Python with pdfplumber: CSV, Excel and pandas (2026)

Automation Nov 04, 2026

Watch on YouTube

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() and find_table() return None when no table is found (we checked this on the borderless report with default settings). Guard with if table is None or 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 produce None for the covered positions. Fill them forward in pandas with df.ffill() when that matches the meaning of the table.
  • Slow on large files: open the PDF once with a with block and process only the pages you need, for example pdf.pages[2:5].

Where to use this next

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.

Share:
Last updated: November 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices