Convert a bank statement PDF to CSV in Python.
Bank statements have a built-in error detector: the running balance. Every row has to equal the previous balance minus the withdrawal plus the deposit, so after OCR you can check each transaction arithmetically instead of trusting it. Here's the full pipeline, PDF to verified CSV.
01 / INSTALL AND GET A KEY
One dependency. Your first 100 pages are free, no card, at pennyocr.com/dashboard.
pip install pennyocr
export PENNYOCR_API_KEY=pk_live_...02 / READ THE DOCUMENT
from pennyocr import PennyOCR
result = PennyOCR().ocr("statement.pdf") # every page, markdown tables
text = result.text03 / WHAT YOU GET BACK
Each page's transactions as a markdown table. Multi-line narrations are joined with <br>:
**Statement of account** 01-Sep-2026 to 30-Sep-2026
| Date | Narration | Chq/Ref No | Withdrawal | Deposit | Balance |
|---|---|---|---:|---:|---:|
| 01/09/26 | OPENING BALANCE | | | | 12,500.00 |
| 03/09/26 | UPI/ACME STORES | 4521 | 1,250.00 | | 11,250.00 |
| 05/09/26 | NEFT SALARY<br>SEPT | | | 45,000.00 | 56,250.00 |
| 09/09/26 | ATM WDL | 8812 | 5,000.00 | | 51,250.00 |04 / PARSE THE TRANSACTION TABLES
Collect the rows of every table that has a Balance column, across all pages:
import re
def money(s: str) -> float:
return float(re.sub(r"[^\d.]", "", s) or 0)
def transactions(md: str):
"""Rows of every markdown table that has a Balance column."""
rows, header = [], None
for line in md.splitlines():
line = line.strip()
if not line.startswith("|"):
header = None
continue
cells = [c.strip().replace("<br>", " ") for c in line.strip("|").split("|")]
if set("".join(cells)) <= set("-: "):
continue # the |---|---| separator row
if header is None:
header = cells
elif "Balance" in header:
rows.append(dict(zip(header, cells)))
return rows
txns = transactions(text)
print(len(txns), "rows")05 / CHECK THE RUNNING BALANCE
This catches a misread amount, a dropped row or a column shift. Flag the statement for review if anything fails.
def check_running_balance(txns):
"""Every row must equal the previous balance - withdrawal + deposit."""
bad = []
for prev, row in zip(txns, txns[1:]):
expected = money(prev["Balance"]) - money(row["Withdrawal"]) + money(row["Deposit"])
if abs(expected - money(row["Balance"])) > 0.005:
bad.append((row["Date"], expected, row["Balance"]))
return bad
problems = check_running_balance(txns)
print("balance OK" if not problems else f"misread rows: {problems}")06 / WRITE THE CSV
Only after the balance check passes:
import csv
with open("statement.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=txns[0].keys())
w.writeheader()
w.writerows(txns)
print("wrote statement.csv")Column names differ by bank ("Debit"/"Credit", "Dr"/"Cr", "Withdrawal Amt."), so map them once per bank. Cost: $0.00075 per page, so a 40-page statement costs three cents. Unlock password-protected PDFs before sending them.