* * * GUIDES * * *

Convert a bank statement PDF to CSV in Python.

UPDATED

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.text

03 / 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.