pdfmux/blog
python

Extract data from bank statements (PDF) in Python: a 2026 guide

TL;DRParse bank statement PDFs into structured transactions in Python: multi-page tables, running-balance reconciliation, scanned OCR, and the code to do it.

Direct answer: Extract structured transactions from a bank statement PDF with pip install pdfmux and process("statement.pdf", output_format="json"). The JSON gives you per-page tables plus a confidence score, so you parse transaction rows (date, description, debit, credit, balance) into clean records and then run a running-balance reconciliation to catch any row the parser dropped. The reconciliation step is the part most tutorials skip — without it, a single missed or duplicated row silently corrupts the total, and you won’t notice until the numbers don’t tie out three weeks later. Bank statements are harder than invoices because the transaction table spans pages, repeats its header on each one, and every bank lays it out differently.


Why bank statements are harder than invoices

An invoice is a single structured document — one issuer, one layout, a handful of totals. A bank statement is the opposite: a long transaction ledger that breaks across page boundaries, with three traps that wreck naive extraction.

  1. Multi-page tables. A 6-month statement can run 12 pages. The transaction table starts on page 1 and continues to page 12, with the column header (Date | Description | Debit | Credit | Balance) repeated at the top of each page. A parser that treats each page independently will read those repeated headers as data rows.
  2. Per-bank layout variance. HSBC, Chase, Emirates NBD, and Wise all render statements differently — column order, date format, whether debits are negative or in a separate column, where the running balance sits. There is no single schema.
  3. Scanned statements. Many people upload a phone photo or a scan of a printed statement. Those pages have no text layer at all, so you need OCR before you can parse anything.

pdfmux is built for exactly this shape of problem. Its self-healing pipeline routes each page to the right extractor — fast text extraction for digital pages, OCR for scanned ones — and returns a per-page confidence score so you know which pages to trust. That last part is what makes reconciliation possible.

Step 1: Extract the statement as structured JSON

Start with the structured output. Markdown is great for RAG pipelines, but for transactions you want machine-readable rows, so use output_format="json".

from pdfmux import process

result = process("statement.pdf", quality="standard", output_format="json")

print(f"Pages: {result.page_count}")
print(f"Confidence: {result.confidence:.0%}")
print(f"Extractor: {result.extractor_used}")

# Tables come back as: [{headers: [...], rows: [[...]], page: N}]
for table in result.tables:
    print(f"Page {table['page']}: {len(table['rows'])} rows")

quality="standard" runs the full extract-audit-repair pipeline, which is what you want for a document where a single wrong digit matters. The result.confidence value is the average across pages — but the per-page breakdown is what you act on, because one scanned page in an otherwise-digital statement is exactly where errors hide.

Step 2: Stitch the multi-page table together

The transaction table is logically one table split across pages. Concatenate the rows from every page-level table that shares the statement’s column structure, and drop any row that is actually a repeated header.

def stitch_transactions(tables: list[dict]) -> list[list[str]]:
    """Merge per-page tables into one transaction list, dropping repeated headers."""
    if not tables:
        return []

    header = [h.strip().lower() for h in tables[0]["headers"]]
    all_rows = []

    for table in tables:
        for row in table["rows"]:
            normalized = [str(c).strip().lower() for c in row]
            # Skip rows that are just the header repeated at a page break
            if normalized == header:
                continue
            # Skip blank rows
            if not any(c.strip() for c in row):
                continue
            all_rows.append(row)

    return all_rows

rows = stitch_transactions(result.tables)
print(f"Stitched {len(rows)} transaction rows across {result.page_count} pages")

Because pdfmux scores 0.918 reading order on the opendataloader-bench of 200 real-world PDFs, the rows come out in chronological sequence — which matters enormously for a ledger, where order is the data.

Step 3: Parse rows into typed transactions

Now turn raw string rows into typed records. The fragile part is amounts: banks write 1,234.56, (1,234.56) for debits, 1234.56 DR, or split debit and credit into separate columns. Handle the common cases explicitly.

import re
from dataclasses import dataclass
from datetime import datetime

@dataclass
class Transaction:
    date: datetime
    description: str
    amount: float      # signed: negative = money out
    balance: float | None

def parse_amount(raw: str) -> float:
    """Parse a money string into a signed float. Parens or trailing DR = debit."""
    s = raw.strip()
    if not s:
        return 0.0
    negative = s.startswith("(") or s.upper().endswith("DR") or s.startswith("-")
    digits = re.sub(r"[^\d.]", "", s)
    if not digits:
        return 0.0
    value = float(digits)
    return -value if negative else value

def parse_date(raw: str) -> datetime:
    """Try the common bank date formats."""
    for fmt in ("%d/%m/%Y", "%m/%d/%Y", "%d %b %Y", "%Y-%m-%d", "%d-%m-%Y"):
        try:
            return datetime.strptime(raw.strip(), fmt)
        except ValueError:
            continue
    raise ValueError(f"Unrecognized date: {raw!r}")

def to_transaction(row: list[str], cols: dict[str, int]) -> Transaction:
    debit = parse_amount(row[cols["debit"]]) if "debit" in cols else 0.0
    credit = parse_amount(row[cols["credit"]]) if "credit" in cols else 0.0
    amount = credit - abs(debit) if ("debit" in cols and "credit" in cols) \
        else parse_amount(row[cols["amount"]])
    balance = parse_amount(row[cols["balance"]]) if "balance" in cols else None
    return Transaction(
        date=parse_date(row[cols["date"]]),
        description=row[cols["description"]].strip(),
        amount=amount,
        balance=balance,
    )

The cols map is where you absorb per-bank layout variance: detect column positions once from the header, then every row parses the same way regardless of issuer.

def map_columns(headers: list[str]) -> dict[str, int]:
    aliases = {
        "date": ["date", "transaction date", "posting date", "value date"],
        "description": ["description", "details", "narrative", "particulars"],
        "debit": ["debit", "withdrawal", "paid out", "dr"],
        "credit": ["credit", "deposit", "paid in", "cr"],
        "amount": ["amount"],
        "balance": ["balance", "running balance", "closing balance"],
    }
    cols = {}
    for i, h in enumerate(headers):
        key = h.strip().lower()
        for canonical, names in aliases.items():
            if key in names and canonical not in cols:
                cols[canonical] = i
    return cols

Step 4: Reconcile the running balance — the step that catches errors

This is the move that turns “probably correct” into “provably correct.” Every bank statement carries a running balance column. If your parsed transactions are complete and correct, then for each row:

previous balance + this transaction amount == this row’s stated balance

Walk the list and assert it. Any mismatch points at exactly the row where extraction went wrong — a dropped transaction, a misread amount, or a debit parsed as a credit.

def reconcile(transactions: list[Transaction], tolerance: float = 0.01) -> list[dict]:
    """Verify each row's balance == previous balance + amount. Returns mismatches."""
    problems = []
    for i in range(1, len(transactions)):
        prev, curr = transactions[i - 1], transactions[i]
        if prev.balance is None or curr.balance is None:
            continue
        expected = prev.balance + curr.amount
        if abs(expected - curr.balance) > tolerance:
            problems.append({
                "row": i,
                "date": curr.date.date().isoformat(),
                "description": curr.description,
                "expected_balance": round(expected, 2),
                "stated_balance": curr.balance,
                "drift": round(curr.balance - expected, 2),
            })
    return problems

problems = reconcile(transactions)
if problems:
    print(f"⚠️  {len(problems)} reconciliation mismatches — review these rows:")
    for p in problems:
        print(f"  Row {p['row']} ({p['date']}): drift of {p['drift']}")
else:
    print("✅ Statement reconciles cleanly — every row's balance checks out.")

A statement that reconciles to zero drift across every row is one you can hand to an accounting system without a human re-keying it. A statement with three mismatches tells you precisely which three rows a human should glance at — instead of re-checking all 240.

Step 5: Flag low-confidence (scanned) pages

If a page was scanned, OCR can misread a 7 as a 1. pdfmux’s per-page confidence is your filter. Cross-reference any reconciliation mismatch against page confidence: a mismatch on a 0.55-confidence page is almost certainly an OCR misread, while a mismatch on a 0.99 page is more likely a genuinely dropped row.

# Pages below 0.7 confidence are scanned or image-heavy — verify them by hand
low_conf = [p for p in result.pages if p.confidence < 0.7]
if low_conf:
    print(f"{len(low_conf)} page(s) need review: {[p.number for p in low_conf]}")
    print("Install OCR support for scanned statements: pip install pdfmux[ocr]")

For scanned statements specifically, install the OCR extra. pdfmux does OCR entirely on CPU with no GPU or API keys, so it runs the same on a laptop as on a server.

How the tools compare for bank statements

Not every PDF library handles the bank-statement shape well. Here is how the common options stack up on the things that actually matter for a transaction ledger:

ToolMulti-page tablesScanned supportPer-page confidenceCost
pdfmuxYes (stitched, reading-order 0.918)Yes ([ocr] extra)YesFree (MIT)
pdfplumberManual (per-page only)NoNoFree (MIT)
regex on raw textBrittle, breaks per bankNoNoFree
AWS TextractYesYesPer-fieldPer-page API fee

pdfplumber is excellent for a single clean table on one page — we compared it head-to-head with PyMuPDF — but it leaves multi-page stitching, OCR, and confidence entirely to you. Pure regex on extracted text works until the second bank’s statement lands with a different layout. The cloud APIs work but bill per page and send your financial documents to a third party, which is often a non-starter for statement data.

Putting it together

from pdfmux import process

def extract_statement(path: str) -> tuple[list, list]:
    result = process(path, quality="standard", output_format="json")
    rows = stitch_transactions(result.tables)
    cols = map_columns(result.tables[0]["headers"])
    transactions = [to_transaction(r, cols) for r in rows]
    transactions.sort(key=lambda t: t.date)
    problems = reconcile(transactions)
    return transactions, problems

txns, issues = extract_statement("statement.pdf")
print(f"{len(txns)} transactions, {len(issues)} reconciliation issues")

Export to a DataFrame or CSV from here — the same pattern as converting any PDF table to CSV:

import csv

with open("transactions.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["date", "description", "amount", "balance"])
    for t in txns:
        writer.writerow([t.date.date(), t.description, t.amount, t.balance])

Production checklist

  • Install with OCR + table support: pip install pdfmux[tables,ocr]
  • Use quality="standard", never "fast", for statements — accuracy over speed
  • Stitch multi-page tables and drop repeated headers
  • Map columns from the header once, parse rows uniformly
  • Reconcile the running balance — treat any mismatch as a hard error to review
  • Cross-check mismatches against per-page confidence to tell OCR misreads from dropped rows
  • Never auto-post an unreconciled statement to an accounting system

FAQ

Can it handle statements from any bank? The column-mapping approach absorbs most layout differences automatically. For an unusual bank, add its column names to the aliases dictionary — a two-line change, not a rewrite.

What about statements with no running balance column? Some statements omit the per-row balance. You lose the reconciliation check, so fall back to verifying the parsed total against the statement’s printed closing balance — a weaker but still useful guard.

Is my financial data sent anywhere? No. pdfmux runs locally — text extraction and OCR both happen on your own machine. Nothing leaves your environment, which is the main reason to avoid cloud document APIs for statement data.

How do I handle scanned (photographed) statements? Install the OCR extra (pip install pdfmux[ocr]). pdfmux detects pages with no text layer and routes them through OCR automatically — see detecting scanned PDFs in Python for how that detection works.

Can I extract other key-value fields like account number and statement period? Yes — use extract_structured for the header fields (account holder, account number, period, opening/closing balance) alongside the transaction table. The same approach works for PDF form and key-value extraction.

Keep reading

Last updated: June 2026. Benchmark figures are from the opendataloader-bench (200 real-world PDFs).