Prerequisites
You need:
- Python 3.8 or newer.
pdfplumberfor digital PDFs; addpandasfor the Excel step andpypdfium2+requestsfor scanned tables.- For the OCR route only: a free API Ninjas API key — issued instantly, no credit card; existing accounts find it on the profile page.
pip install pdfplumber pandas pypdfium2 requestsStep 1: Extract the tables
A PDF has no native concept of a table — just characters positioned on a page, sometimes with ruling lines. pdfplumber reconstructs the grid from those positions, which is why it works on digital PDFs and returns nothing for scans (those are handled in Step 4). The core is short:
import csv
import pdfplumber
rows = []
with pdfplumber.open('report.pdf') as pdf:
for page in pdf.pages:
for table in page.extract_tables():
rows.extend(table)
with open('report.csv', 'w', newline='') as f:
csv.writer(f).writerows(rows)
print(f'Wrote {len(rows)} rows to report.csv')Step 2: Clean the rows
Real-world tables arrive with None cells, stray whitespace, and empty spacer rows. Normalize while collecting so the CSV comes out clean:
import csv
import pdfplumber
def pdf_tables_to_csv(pdf_path: str, csv_path: str) -> int:
rows = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
for table in page.extract_tables():
for row in table:
cells = [(cell or '').strip() for cell in row]
if any(cells): # drop fully empty rows
rows.append(cells)
with open(csv_path, 'w', newline='', encoding='utf-8') as f:
csv.writer(f).writerows(rows)
return len(rows)
count = pdf_tables_to_csv('report.pdf', 'report.csv')
print(f'{count} rows written')Two small habits prevent the classic CSV bugs: open the output with newline='' (stops blank lines on Windows), and keep numbers as text for now — currency symbols and thousands separators break naive parsing, so convert deliberately later.
Step 3: Use pandas — or go straight to Excel
If the data is headed for analysis, a DataFrame gets you sorting, math, and one-line export to either format:
import pandas as pd
import pdfplumber
with pdfplumber.open('report.pdf') as pdf:
tables = [t for page in pdf.pages for t in page.extract_tables()]
# First row of the first table is the header
df = pd.DataFrame(tables[0][1:], columns=tables[0][0])
df.to_csv('report.csv', index=False)
# Or straight to Excel
df.to_excel('report.xlsx', index=False)Step 4: Handle scanned tables (OCR)
A scanned table has no characters to extract, so the pipeline becomes: render the page, OCR it, rebuild rows from coordinates. Every word the Image to Text API returns carries a bounding box, and words that share a y position belong to the same row:
import io
import os
import pypdfium2 as pdfium
import requests
session = requests.Session()
session.headers['X-Api-Key'] = os.environ.get('API_NINJAS_KEY', 'YOUR_API_KEY')
def ocr_words(image) -> list:
buffer = io.BytesIO()
image.save(buffer, format='PNG')
buffer.seek(0)
response = session.post(
'https://api.api-ninjas.com/v1/imagetotext',
files={'image': ('page.png', buffer, 'image/png')},
timeout=30,
)
response.raise_for_status()
return response.json()
def words_to_rows(words: list, tolerance: int = 14) -> list:
"""Group OCR words into table rows by bounding-box y position."""
rows = []
for word in sorted(words, key=lambda w: (w['bounding_box']['y1'],
w['bounding_box']['x1'])):
y = word['bounding_box']['y1']
if rows and abs(rows[-1][0] - y) <= tolerance:
rows[-1][1].append(word)
else:
rows.append((y, [word]))
return [
[w['text'] for w in sorted(ws, key=lambda w: w['bounding_box']['x1'])]
for _, ws in rows
]
pdf = pdfium.PdfDocument('scanned_report.pdf')
image = pdf[0].render(scale=300 / 72).to_pil()
for row in words_to_rows(ocr_words(image)):
print(row)Here's what the API sends back for a simple table — headers, then a data row:
[
{
"text": "Qty",
"bounding_box": {
"x1": 102,
"y1": 340,
"x2": 148,
"y2": 366
}
},
{
"text": "Item",
"bounding_box": {
"x1": 210,
"y1": 340,
"x2": 270,
"y2": 366
}
},
{
"text": "Price",
"bounding_box": {
"x1": 480,
"y1": 340,
"x2": 546,
"y2": 366
}
},
{
"text": "2",
"bounding_box": {
"x1": 110,
"y1": 384,
"x2": 128,
"y2": 410
}
},
{
"text": "Widget",
"bounding_box": {
"x1": 210,
"y1": 384,
"x2": 296,
"y2": 410
}
},
{
"text": "19.99",
"bounding_box": {
"x1": 478,
"y1": 384,
"x2": 548,
"y2": 410
}
}
]Feed the reconstructed rows into the same csv.writer as Step 2. For source images rather than PDFs — photographed receipts, screenshots — skip the render and POST the image directly; details in How to OCR a PDF.
Step 5: Handle multi-page tables
Long tables repeat their header on every page. Since extraction runs page by page in order, the fix is one comparison — keep the first header, skip its duplicates:
rows = []
header = None
with pdfplumber.open('report.pdf') as pdf:
for page in pdf.pages:
for table in page.extract_tables():
for row in table:
cells = [(cell or '').strip() for cell in row]
if not any(cells):
continue
if header is None:
header = cells # first row anywhere = header
rows.append(cells)
elif cells != header: # skip repeated headers
rows.append(cells)A final sanity check worth automating: every row should have the same cell count as the header — log the ones that don't for manual review instead of letting them shift columns silently.
Where to go from here
If you need prose rather than tables, that's How to Convert a PDF to Text; for the deep dive on scanned documents, How to OCR a PDF. The complete script — cleanup and repeated-header handling included — is below.