Independent guide. Not affiliated with the pdfplumber project or its maintainers.Official repository: jsvine/pdfplumber ↗

Tables

Export tables to pandas, CSV, Excel and JSON

pdfplumber gives you tables as lists of strings. Getting them into pandas, Excel or a database takes a header, a little number cleaning and, for long documents, stitching pages together. Each recipe below was run on the sample PDFs and checked against the totals printed in them.

Tested with pdfplumber 0.11.10 · Python 3.13Updated 2026-11-04

Short answer

Use the first row as column names, then convert the number columns, because every cell arrives as text:

to_dataframe.py
import pandas as pd
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    header, *rows = pdf.pages[0].extract_table()

df = pd.DataFrame(rows, columns=header)
print(dict(df.dtypes.astype(str)))          # every cell arrives as text

for col in ["Qty", "Unit price", "Amount"]:
    df[col] = pd.to_numeric(df[col].str.replace(",", ""))
print(dict(df.dtypes.astype(str)))
print(df[["Item", "Qty", "Amount"]].head(3).to_string(index=False))
print("total:", df["Amount"].sum())
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
{'Item': 'str', 'Description': 'str', 'Qty': 'str', 'Unit price': 'str', 'Amount': 'str'}
{'Item': 'str', 'Description': 'str', 'Qty': 'int64', 'Unit price': 'float64', 'Amount': 'float64'}
   Item  Qty  Amount
HP-1042   12   220.8
HP-2210   40   278.0
HP-3307   64   118.4
total: 1035.4

The total matches the invoice’s printed subtotal of 1,035.40. Install pandas with python -m pip install pandas (and openpyxl for Excel files).

Clean numbers before you calculate

Financial PDFs write numbers for people: thousands separators, currency symbols, percent signs, negatives in parentheses, dashes for zero and the typographic minus sign. One small function handles all of them:

to_number.py
import re

def to_number(cell):
    """'1,234.56' -> 1234.56, '(45.00)' -> -45.0, '6.8%' -> 6.8, '' / None / '—' -> None"""
    if cell is None:
        return None
    s = cell.strip().replace("−", "-")          # real minus sign
    if s in ("", "-", "—", "–", "n/a"):
        return None
    negative = s.startswith("(") and s.endswith(")")
    s = re.sub(r"[^\d.\-]", "", s)                    # drop $, %, commas, spaces
    value = float(s) if s not in ("", "-", ".") else None
    return -value if (negative and value is not None) else value

for raw in ["1,234.56", "(45.00)", "6.8%", "$ 3,820.00", "−12", "—", None]:
    print(f"{raw!r:14} -> {to_number(raw)!r}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
'1,234.56'     -> 1234.56
'(45.00)'      -> -45.0
'6.8%'         -> 6.8
'$ 3,820.00'   -> 3820.0
'−12'          -> -12.0
'—'            -> None
None           -> None

Apply it per column with df[col].map(to_number). Keep the original text column until you have checked a few rows; a value like 1.234,56 (European format) needs a different rule.

Many pages into one DataFrame

Statements and reports continue a table across pages, usually repeating the header on each one. Extract page by page with the same settings, drop the repeated header rows, and concatenate. This is the three-page bank statement, with columns taken from the header positions on each page:

statement_to_dataframe.py
import pandas as pd
import pdfplumber

def column_edges(page, names):
    header = [w for w in page.extract_words() if w["text"] in names]
    edges = [header[0]["x0"] - 4]
    edges += [(a["x1"] + b["x0"]) / 2 for a, b in zip(header, header[1:])]
    return edges + [header[-1]["x1"] + 4]

names = ["Date", "Description", "Debit", "Credit", "Balance"]
rows = []
with pdfplumber.open("samples/bank-statement.pdf") as pdf:
    for page in pdf.pages:
        table = page.extract_table({
            "vertical_strategy": "explicit",
            "explicit_vertical_lines": column_edges(page, names),
            "horizontal_strategy": "lines",
        })
        rows += [r for r in table if r != names]       # drop the repeated header

df = pd.DataFrame(rows, columns=names)
df["Date"] = pd.to_datetime(df["Date"])
for col in ["Debit", "Credit", "Balance"]:
    df[col] = pd.to_numeric(df[col].str.replace(",", ""), errors="coerce").fillna(0)

print(df.shape)
print(df.tail(3).to_string(index=False))
computed = 2450.00 - df["Debit"].sum() + df["Credit"].sum()
print(f"computed closing balance {computed:,.2f} | printed {df['Balance'].iloc[-1]:,.2f}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
(58, 5)
      Date    Description  Debit  Credit  Balance
2026-10-31  Interest paid   0.00     6.0 11819.08
2026-10-31 ATM withdrawal  38.43     0.0 11780.65
2026-10-31 Hardware store 148.26     0.0 11632.39
computed closing balance 11,632.39 | printed 11,632.39

58 transactions, and the closing balance computed from the extracted debits and credits equals the printed one. A check like that is the best evidence a table came out right. More on continuation rows and headers in multi-page tables.

CSV

The standard library is enough; newline="" and an explicit encoding avoid blank lines on Windows and mangled accents:

to_csv.py
import csv

import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    table = pdf.pages[0].extract_table()

with open("table.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(table)

print(open("table.csv", encoding="utf-8").read().splitlines()[:3])
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
['Item,Description,Qty,Unit price,Amount', 'HP-1042,Stainless hex bolts M8 x 40 (box of 100),12,18.40,220.80', 'HP-2210,"Aluminium extrusion 2020, 1 m",40,6.95,278.00']

With pandas, df.to_csv("table.csv", index=False). Use encoding="utf-8-sig" if the file will be opened by double-clicking it in Excel, which otherwise misreads UTF-8 without a byte-order mark. The tutorial shows the output.

Excel, one sheet per table

tables_to_excel.py
import pandas as pd
import pdfplumber

with pdfplumber.open("samples/report.pdf") as pdf, pd.ExcelWriter("tables.xlsx") as xlsx:
    for page in pdf.pages:
        for i, table in enumerate(page.extract_tables(), start=1):
            df = pd.DataFrame(table[1:], columns=table[0])
            df.to_excel(xlsx, sheet_name=f"page{page.page_number}-table{i}", index=False)

# Read it back to check
for name, sheet in pd.read_excel("tables.xlsx", sheet_name=None).items():
    print(name, sheet.shape)
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
page1-table1 (14, 3)
page2-table1 (34, 3)

Sheet names are limited to 31 characters and cannot contain []:*?/\. Writing .xlsx needs openpyxl (or xlsxwriter):

Terminal
python -m pip install pandas openpyxl

JSON records

A list of dictionaries keyed by the header is the most portable format for APIs and document databases:

to_json.py
import json
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    header, *rows = pdf.pages[0].extract_table()

records = [dict(zip(header, row)) for row in rows]
print(json.dumps(records[:2], indent=2))
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
[
  {
    "Item": "HP-1042",
    "Description": "Stainless hex bolts M8 x 40 (box of 100)",
    "Qty": "12",
    "Unit price": "18.40",
    "Amount": "220.80"
  },
  {
    "Item": "HP-2210",
    "Description": "Aluminium extrusion 2020, 1 m",
    "Qty": "40",
    "Unit price": "6.95",
    "Amount": "278.00"
  }
]

Duplicate or empty header cells become clashing keys. Rename them before zipping (header = [h or f"col{i}" for i, h in enumerate(header)]).

Markdown for LLMs and docs

Language models read Markdown tables far better than flattened text, which is why table-aware RAG pipelines convert them. A dependency-free converter:

to_markdown.py
import pdfplumber

def to_markdown(table):
    clean = [[(c or "").replace("\n", " ").replace("|", "\\|") for c in row] for row in table]
    header, *body = clean
    lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
    lines += ["| " + " | ".join(row) + " |" for row in body]
    return "\n".join(lines)

with pdfplumber.open("samples/invoice.pdf") as pdf:
    print(to_markdown(pdf.pages[0].extract_table()[:4]))
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
| Item | Description | Qty | Unit price | Amount |
|---|---|---|---|---|
| HP-1042 | Stainless hex bolts M8 x 40 (box of 100) | 12 | 18.40 | 220.80 |
| HP-2210 | Aluminium extrusion 2020, 1 m | 40 | 6.95 | 278.00 |
| HP-3307 | Linear bearing LM8UU | 64 | 1.85 | 118.40 |

pandas can do the same with df.to_markdown(index=False) if the tabulate package is installed. See pdfplumber for AI and RAG for chunking strategies.

Data types worth knowing

What you get Why What to do
None The cell is covered by a merged neighbour, or the column came from misaligned rules Merged cells, or raise snap_tolerance
"" A real cell with no text Keep, or treat as missing with df.replace("", pd.NA)
"Line one\nline two" Text wrapped inside the cell .str.replace("\n", " "), or keep line breaks for addresses
"12,480" Thousands separator to_number() above
"(45.00)" Accounting negative to_number() above