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.
Short answer
Use the first row as column names, then convert the number columns, because every cell arrives as text:
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()){'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.4The 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:
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}")'1,234.56' -> 1234.56
'(45.00)' -> -45.0
'6.8%' -> 6.8
'$ 3,820.00' -> 3820.0
'−12' -> -12.0
'—' -> None
None -> NoneApply 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:
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}")(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.3958 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:
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])['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
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)page1-table1 (14, 3)
page2-table1 (34, 3)Sheet names are limited to 31 characters and cannot contain []:*?/\. Writing .xlsx needs openpyxl (or xlsxwriter):
python -m pip install pandas openpyxlJSON records
A list of dictionaries keyed by the header is the most portable format for APIs and document databases:
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))[
{
"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:
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]))| 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 |