PDF to CSV in Python: 4 Methods (Tables to Clean CSV)
Direct answer: For a digital PDF with visible grid lines, pdfplumber gets you to CSV in six lines:
import pdfplumber, csv
with pdfplumber.open("report.pdf") as pdf:
table = pdf.pages[0].extract_table()
with open("output.csv", "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(table)
That covers maybe 60% of real-world PDFs. The other 40% — borderless tables, merged cells, scanned pages, multi-page tables — each need a different tool. This post walks through all four options with runnable code and tells you which to reach for first.
When pdfplumber is enough
pdfplumber uses line-geometry analysis to detect table cells. It works well on PDFs where the table has drawn borders (horizontal and vertical rules) and the text inside each cell is clean, unmerged, and within the page’s coordinate space.
pip install pdfplumber
import pdfplumber
import csv
with pdfplumber.open("financial-statement.pdf") as pdf:
for page_num, page in enumerate(pdf.pages):
table = page.extract_table()
if table is None:
continue
with open(f"table_page_{page_num}.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerows(table)
If a page has multiple tables, use extract_tables() (plural):
with pdfplumber.open("multi-table.pdf") as pdf:
page = pdf.pages[0]
for i, table in enumerate(page.extract_tables()):
with open(f"table_{i}.csv", "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(table)
pdfplumber also lets you define an explicit bounding box if auto-detection clips the table:
# Crop to a region (x0, top, x1, bottom) and then extract
region = page.within_bbox((50, 100, 550, 400))
table = region.extract_table()
Works well when: Digital PDFs with drawn borders; cells don’t span multiple rows or columns.
Breaks when: Borderless tables, merged/spanning cells, scanned PDFs.
When you need lattice vs stream detection: camelot
camelot distinguishes between two extraction modes that map to two real-world table structures:
- lattice — for tables with drawn lines (cell borders). Uses the actual lines in the PDF to build the grid.
- stream — for borderless tables that rely on whitespace alignment. Uses column gaps and row spacing to infer structure.
pip install camelot-py[cv]
# also requires: pip install opencv-python ghostscript
import camelot
# For tables with visible grid lines
tables = camelot.read_pdf("grid-table.pdf", flavor="lattice")
tables[0].df.to_csv("output.csv", index=False)
# For whitespace-aligned (borderless) tables
tables = camelot.read_pdf("borderless-table.pdf", flavor="stream")
tables[0].df.to_csv("output.csv", index=False)
# Processing all pages
tables = camelot.read_pdf("multipage.pdf", pages="all", flavor="lattice")
for i, table in enumerate(tables):
table.df.to_csv(f"table_{i}.csv", index=False)
camelot returns a TableList — each element has a .df (pandas DataFrame) and a .parsing_report dict with accuracy, whitespace, and order scores. Use those to decide whether to trust the extraction:
for table in tables:
report = table.parsing_report
if report["accuracy"] < 80:
print(f"Table {report['order']} low accuracy ({report['accuracy']:.0f}%) — check manually")
else:
table.df.to_csv(f"table_{report['order']}.csv", index=False)
Works well when: Digital PDFs where you know the table structure up front and want explicit per-table accuracy scores.
Breaks when: Scanned PDFs, complex spanning cells, or when ghostscript isn’t available.
tabula-py: the fastest path to DataFrames
tabula-py wraps the Java-based Tabula library. It has the same lattice/stream split as camelot and can output directly to a list of DataFrames:
pip install tabula-py
# Requires Java 8+ on PATH
import tabula
# Returns a list of DataFrames — one per table found
dfs = tabula.read_pdf("report.pdf", pages="all")
for i, df in enumerate(dfs):
df.to_csv(f"table_{i}.csv", index=False, encoding="utf-8")
For the lattice/stream toggle (camelot calls it flavor; tabula calls it lattice boolean):
# Lattice mode — for tables with drawn grid lines
dfs = tabula.read_pdf("grid-table.pdf", pages="all", lattice=True)
# Stream mode — for borderless tables
dfs = tabula.read_pdf("borderless.pdf", pages="all", stream=True)
tabula can also write directly to CSV without loading into Python at all:
tabula.convert_into("report.pdf", "output.csv", output_format="csv", pages="all")
Works well when: You want DataFrames immediately and have Java available.
Breaks when: No Java runtime, scanned PDFs, complex merged cells.
When the above three break: pdfmux
pdfplumber, camelot, and tabula all share the same fundamental constraint: they operate on the PDF’s underlying coordinate data. Once a page is scanned (pixels, not vectors), or has complex merged cells spanning multiple rows, or mixes languages with right-to-left text, coordinate-based extraction falls apart.
pdfmux uses a self-healing extraction pipeline that classifies each page, routes it to the best extractor (PyMuPDF for clean digital pages, Docling for complex tables), and falls back to OCR for scanned pages. The result comes back as structured JSON with table rows and headers explicit — no coordinate guessing.
pip install pdfmux
import csv
from pdfmux import process
result = process("complex-report.pdf", output_format="json")
for i, table in enumerate(result.tables):
with open(f"table_{i}.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(table["headers"])
writer.writerows(table["rows"])
For scanned PDFs, add the OCR extra:
pip install pdfmux[ocr]
# pdfmux auto-detects scanned pages and routes them through OCR
result = process("scanned-invoice.pdf", output_format="json")
# Check per-table (or per-page) confidence before writing
for i, table in enumerate(result.tables):
print(f"Table {i}: {len(table['rows'])} rows, confidence: {table.get('confidence', 'n/a')}")
You can also go through pandas as an intermediate, which is useful when you need to clean or transform the data before writing CSV — this mirrors the pattern shown in extracting tables from PDF in Python:
import pandas as pd
from pdfmux import process
result = process("financial-report.pdf", output_format="json")
for i, table in enumerate(result.tables):
df = pd.DataFrame(table["rows"], columns=table["headers"])
# Optional: strip whitespace, coerce numeric columns
df = df.apply(lambda col: col.str.strip() if col.dtype == "object" else col)
df.to_csv(f"table_{i}.csv", index=False, encoding="utf-8")
Method comparison
| Method | Best for | Handles scanned PDFs? | Handles merged cells? | Setup effort |
|---|---|---|---|---|
| pdfplumber | Digital PDFs, ruled tables | No | Partial (single-row spans only) | Low (pip only) |
| camelot lattice | Digital PDFs, explicit grid lines | No | Yes (lattice mode) | Medium (needs ghostscript) |
| camelot stream | Digital PDFs, borderless tables | No | Poor | Medium |
| tabula-py | Fast batch, direct DataFrames | No | Partial | Medium (needs Java) |
| pdfmux | Mixed/scanned/complex PDFs, production pipelines | Yes (with [ocr]) | Yes | Low (pip only, models auto-download) |
Common pitfalls
Merged and spanning cells
pdfplumber returns None for cells that are part of a merged group — the cell that spans appears once in the first position, and the rest are None. You need to forward-fill before writing CSV:
import pdfplumber
def forward_fill(table):
"""Fill None cells with the last non-None value in the same column."""
result = []
prev_row = [None] * len(table[0]) if table else []
for row in table:
filled = [cell if cell is not None else prev_row[i] for i, cell in enumerate(row)]
result.append(filled)
prev_row = filled
return result
with pdfplumber.open("merged-cells.pdf") as pdf:
raw = pdf.pages[0].extract_table()
if raw:
filled = forward_fill(raw)
with open("output.csv", "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(filled)
Multi-line cells
A single cell in the PDF may contain a newline (\n) because the text wraps inside the cell. Most CSV parsers handle quoted fields with newlines, but Excel on Windows sometimes chokes. If you need Excel compatibility:
# Collapse newlines within cells before writing
cleaned = [[cell.replace("\n", " ") if cell else "" for cell in row] for row in table]
Multi-page tables
A table that spans two or more pages is the hardest case. pdfplumber and tabula both treat each page independently — you get separate DataFrames with a repeated header on each page. Concatenate and deduplicate the header rows:
import pdfplumber
import csv
all_rows = []
header = None
with pdfplumber.open("multi-page-table.pdf") as pdf:
for page in pdf.pages:
table = page.extract_table()
if not table:
continue
if header is None:
header = table[0]
all_rows.append(header)
all_rows.extend(table[1:])
else:
# Skip rows that look like a repeated header
data_rows = [r for r in table if r != header]
all_rows.extend(data_rows)
with open("output.csv", "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(all_rows)
pdfmux handles multi-page tables natively — the pipeline detects table continuation across page boundaries and returns a single merged table object. See extracting tables from PDF in Python for the full benchmark and method comparison.
UTF-8 BOM for Excel
Excel on Windows opens UTF-8 CSV files with a BOM correctly; without it, special characters (€, ©, accented letters) show as garbage. Add encoding="utf-8-sig" to produce a BOM:
with open("output.csv", "w", newline="", encoding="utf-8-sig") as f:
csv.writer(f).writerows(table)
If your data is purely ASCII, utf-8 and utf-8-sig are identical. Use utf-8-sig by default if the CSV will ever be opened in Excel.
Scanned PDFs and OCR
pdfplumber, camelot, and tabula will all return empty tables on a scanned PDF — there is no vector text for them to read. You need OCR first. The two options:
- Run an OCR pass first (pytesseract, ocrmypdf) to produce a searchable PDF, then feed that to pdfplumber/camelot.
- Use pdfmux with
pip install pdfmux[ocr], which handles the detection and OCR routing automatically.
For details on the OCR detection path — how to programmatically tell whether a page is scanned before wasting time on a coordinate-based extractor — see OCR PDF extraction in Python.
For a broader view of which library to reach for on different document types (not just tables), the ranked comparison of every Python PDF library has the full breakdown including benchmark numbers.
FAQ
Which Python library is best for converting PDF tables to CSV?
For digital PDFs with clear borders, pdfplumber is the simplest path — install is a single pip command and the API is clean. For borderless tables, camelot stream mode or tabula-py both work. For scanned PDFs, production pipelines, or documents with complex merged cells, pdfmux is the most reliable option — it routes each page to the best extractor automatically and handles OCR as a fallback without requiring a separate preprocessing step.
Why does my table CSV have empty cells where there should be data?
Two likely causes: (1) merged/spanning cells — the value appears once and the rest of the merged region is None. Forward-fill the None cells before writing. (2) The text in those cells is actually an image (scanned or annotated), not selectable vector text. Confirm by trying to select the text in a PDF viewer — if you can’t highlight it, you need OCR.
How do I extract multiple tables from a single PDF page to separate CSV files?
With pdfplumber, use page.extract_tables() (plural) — it returns a list, one entry per detected table. With pdfmux, the result.tables list contains every table detected across all pages, each with its page number. Iterate and write each one to its own file with to_csv(f"table_{i}.csv").
Try pdfmux
For PDFs where the above methods return empty tables, incorrect rows, or miss scanned pages entirely, pdfmux handles the difficult cases — mixed digital/scanned documents, borderless tables, merged cells, and multi-page table continuation.
pip install pdfmux
# or with OCR support for scanned PDFs:
pip install pdfmux[ocr]
Try it at pdfmux.com or review the full API in the pdfmux Python SDK docs.