Python August 2, 2026 14 min read

Bank statement parser in Python — libraries, the traps, and the code that proves it worked

Parsing bank statement PDFs in Python is a well-worn path: pdfplumber, camelot, tabula, a pile of regular expressions, and a column map per bank. It works, right up until the bank changes its layout or the client sends a scan. This page is an honest engineering guide — what each library is actually good at, the five failure modes that quietly corrupt output, the balance check that proves an extraction is complete no matter who produced it, and when calling an API is the cheaper answer.

FlowParse
flowparse.io

What you are actually being asked to do

The task sounds small: read a PDF bank statement, get a list of transactions with dates, descriptions and amounts, load them into pandas or a database. For one bank, with a text-based PDF and a stable layout, an afternoon of pdfplumber and regular expressions genuinely solves it.

The task gets big at the edges, and in production it is all edges. Ten banks means ten layouts. A client sends a scan instead of a download. The bank redesigns its statement in March and your parser silently starts returning fewer rows. None of these announce themselves — the code keeps running and the numbers keep looking plausible.

This page covers both halves: how to write the parser well, and how to know it is right. The second half is the one most tutorials skip, and it is the one that matters when the output feeds a ledger, a tax return or a lending decision.

FlowParse
flowparse.io

Why bank statements resist parsing

A PDF has no table. What it has is a set of glyphs with coordinates, and any table you see is something your eye reconstructs from alignment and whitespace. Every Python library that claims to extract tables is really inferring structure from geometry, which is why the same code that works perfectly on one statement returns interleaved nonsense on another.

Bank statements are the worst version of this problem. Descriptions wrap onto second and third lines. Amounts sit in separate debit and credit columns, or in one column with a trailing minus, or with CR and DR suffixes. Sections restart with different headers mid-document. Interest summaries and marketing boxes sit between transaction blocks and look exactly like data to a naive parser.

Then there is the scanned statement, which has no text layer at all. Nothing built on pdfminer will return a single character from it, and the workaround — OCR — changes the whole shape of the problem, adding a new class of errors that a pure-text pipeline never has to handle.

The library landscape, honestly

There are five tools people actually reach for, and they are not interchangeable. Two extract text with coordinates, two try to detect tables for you, and one is a rendering engine that happens to be very fast. Picking the wrong one for your statement layout costs a day.

Note the licensing and dependency footnotes in the table — they are the things that turn up in code review rather than in tutorials. PyMuPDF is fast and AGPL-licensed, with a commercial licence available from Artifex if your product is proprietary. tabula-py needs a Java runtime on every machine that runs it, including your CI. Camelot dropped its Ghostscript requirement as of version 1.0.0, which made it considerably easier to install than its reputation suggests.

LibraryWhat it is good atWhat to know
pdfplumberWords with coordinates; full control over row and column logicMIT; the pragmatic default for statements. You write the layout logic.
pdfminer.sixThe low-level layout engine underneath pdfplumberUse it only if you need something pdfplumber does not expose.
camelotRuled tables (lattice) and whitespace-aligned tables (stream)Ghostscript no longer required since 1.0.0 — pdfium is the default backend.
tabula-pyQuick tabular extraction, good on ruled tablesWraps tabula-java: a Java runtime must exist wherever it runs.
PyMuPDF (fitz)Speed, rendering pages to images for OCRAGPL, or a commercial licence from Artifex. Check before shipping.
pytesseract + TesseractScanned statements with no text layerA separate binary; accuracy depends heavily on scan quality.

The pragmatic starting point: words and coordinates

For statements, `extract_table()` disappoints more often than it delights, because bank statements rarely have the ruled cells table detection relies on. The approach that survives contact with real documents is lower level: pull every word with its coordinates, group words into visual rows by their vertical position, then assign each word to a column by its horizontal midpoint.

That midpoint detail matters more than it looks. Amounts are right-aligned, so a wide number can start well inside the column to its left; assigning by the left edge captures figures into the description column and produces a parser that works on small amounts and breaks on large ones. Grouping by midpoint, with a tolerance of a couple of points on the vertical axis, is what makes wrapped descriptions and tall rows behave.

The column boundaries themselves are measured once per bank and hard-coded. That is the honest cost of this approach — it is a per-bank configuration, and it is why the maintenance section below exists.

pdfplumber — rows by y, columns by midpoint
# The honest skeleton of a hand-written statement parser.
import pdfplumber, re

DATE = re.compile(r"^\d{2}[/.-]\d{2}[/.-]\d{2,4}$")
AMOUNT = re.compile(r"^-?[\d.,]+-?$")

def rows_from_page(page, tol=2.5):
    """Group words into visual rows by their y position."""
    rows = {}
    for w in page.extract_words(use_text_flow=False, keep_blank_chars=False):
        key = round(w["top"] / tol)
        rows.setdefault(key, []).append(w)
    for key in sorted(rows):
        yield sorted(rows[key], key=lambda w: w["x0"])

def parse(path, columns):
    """columns = {"date": (40, 95), "description": (95, 330), "amount": (330, 410),
                  "balance": (410, 480)}  — x ranges measured once, per bank."""
    out = []
    with pdfplumber.open(path) as pdf:
        for page in pdf.pages:
            for words in rows_from_page(page):
                cell = {name: [] for name in columns}
                for w in words:
                    mid = (w["x0"] + w["x1"]) / 2
                    for name, (x0, x1) in columns.items():
                        if x0 <= mid < x1:
                            cell[name].append(w["text"])
                            break
                date = " ".join(cell["date"])
                if not DATE.match(date):        # header, footer, wrapped line, legal prose
                    continue
                out.append({
                    "date": date,
                    "description": " ".join(cell["description"]),
                    "amount": " ".join(cell["amount"]),
                    "balance": " ".join(cell["balance"]),
                })
    return out

When table detection is worth trying

Camelot's *lattice* mode is excellent when the statement genuinely has ruled cells — some business and credit-card statements do — because the lines give it unambiguous cell boundaries. Its *stream* mode infers columns from whitespace and behaves reasonably on evenly spaced layouts, though it struggles with the wrapped descriptions that are endemic to statements.

tabula-py is the quickest way to get a DataFrame out of a well-behaved table, and the Java dependency is the thing that decides it. In a container you control, fine. In a serverless function or a client's locked-down environment, adding a JRE to satisfy a PDF parser is a conversation you will lose.

The realistic pattern is: try camelot first because it costs ten minutes, and fall back to a pdfplumber parser when it returns something that needs more cleanup than it saved. Do not spend a week tuning table-detection parameters — that time is better spent on the validation described below, because a perfectly tuned parser with no completeness check is still a parser you cannot trust.

FlowParse
flowparse.io

The scanned statement changes everything

If `page.extract_text()` returns an empty string, there is no text layer and no amount of pdfplumber tuning will help. The document is a picture of a statement. The Python path from here is to render each page — PyMuPDF is the fast option — and run OCR with Tesseract, or to preprocess the whole file with a tool like OCRmyPDF so that a text layer exists before your parser sees it.

OCR introduces errors your text pipeline never had: a 3 read as an 8, a decimal point lost in noise, columns bleeding into each other where the scan is skewed. Those errors are individually small and collectively serious, because a single misread digit in an amount changes a total by an order of magnitude and looks entirely plausible in the output.

This is where a self-built pipeline usually stops being economical. Handling scans well means deskewing, denoising, per-page confidence handling and a way to catch the digit errors that survive all of that — and each of those is a project. The scanned bank statement page describes how the hosted path handles the same problem.

FlowParse
flowparse.io

The debit/credit trap

Statements express direction in at least four ways, and every one of them has burnt somebody. Two columns, debit and credit, with one populated per row. One signed column with a leading minus. One column with a trailing minus, which is a mainframe convention still very much alive. CR and DR suffixes, sometimes with the sign meaning the opposite of what you assume, because a credit to your account is a debit in the bank's ledger.

Normalise to a single signed amount as early as possible, and write the rule down explicitly: money leaving the account is negative. Then test it against a row you can verify by hand in both directions. Half of all statement-parsing bugs are a sign convention nobody wrote down.

There is a related trap in the balance column. Exporting the running balance where the amount belongs produces a file that imports cleanly into accounting software and is completely wrong — every transaction carries the account's balance instead of its value. If your parser has a balance column, assert that consecutive balances differ by the transaction amount; the assert costs one line and catches the whole class.

FlowParse
flowparse.io

The rows that disappear at page breaks

The most expensive failure in statement parsing is the row that never appears. It happens at page boundaries, where a transaction's description wraps onto the next page, where a section restarts without repeating its header, and where a parser that requires a header row per block quietly abandons blocks that do not have one.

Nothing about the output looks wrong afterwards. The columns are clean, the descriptions are sensible, the dates are in order. You discover it weeks later when a reconciliation is off by one transaction, and by then finding which of two thousand rows is missing costs more than the original work.

The defence is not better parsing — it is arithmetic. Any statement that prints a running balance carries its own audit inside it, and checking that arithmetic is the only way to prove absence rather than hope for it. That is the next section, and if you take one thing from this page, take that.

Numbers and dates will lie to you

`1.234,56` is one thousand two hundred and thirty-four in most of Europe and one point two three in a naive `float()`. `03/04/2026` is the third of April or the fourth of March depending on the bank's country, and both parse without error. Currency symbols, non-breaking spaces and thin-space thousand separators all sit inside amount strings that look clean when printed.

Parse defensively and centrally: one function that converts a string to a `Decimal`, handling both separator conventions, parentheses for negatives, trailing minus, and CR/DR suffixes. One function that converts dates, with the day-first convention passed in per bank rather than guessed per row. Both functions raise on anything they do not understand — silence is how bad data enters a pipeline.

Use `Decimal`, not `float`. Financial code that accumulates floats produces balance checks that fail by 0.000000001 and engineers who start widening tolerances to make tests pass, which is exactly how a real one-cent discrepancy gets waved through later.

The check that proves the extraction is complete

Accuracy and completeness are different properties, and only one of them is provable without labelled data. You cannot know whether a description was read correctly without the original in front of you. You *can* know, mathematically, whether any row is missing: opening balance plus the sum of the transactions must equal the closing balance. If it does, nothing was dropped and no amount was misread in a way that changes the total.

This check is worth more than any accuracy percentage, because it runs on every single document in production rather than on a test set, and it is indifferent to who did the extraction. Run it on your pdfplumber parser's output, on a vendor's API response, on a CSV a client emailed you. The code below is complete and does not depend on anything on this page.

Run it per account, not per file. A statement covering two accounts has two arithmetic chains, and summing everything together produces a check that passes while both accounts are wrong. Handle multi-currency the same way — never sum across currencies to make a total balance.

The balance check — works with any extractor
# The check that matters, and it works with ANY extractor —
# your own parser, a vendor API, or a CSV somebody emailed you.
from decimal import Decimal, InvalidOperation

def to_decimal(s):
    """Handle 1,234.56 / 1.234,56 / (1,234.56) / 1234.56- / CR-DR suffixes."""
    s = str(s).strip().replace(" ", "").replace("\u00a0", "")
    neg = s.startswith("(") and s.endswith(")") or s.endswith("-") or s.upper().endswith("DR")
    s = s.strip("()").rstrip("-")
    s = s.upper().replace("CR", "").replace("DR", "")
    if "," in s and "." in s:                       # last separator wins
        dec = max(s.rfind(","), s.rfind("."))
        s = s[:dec].replace(",", "").replace(".", "") + "." + s[dec + 1:]
    elif "," in s:
        s = s.replace(",", ".") if len(s.split(",")[-1]) == 2 else s.replace(",", "")
    try:
        v = Decimal(s or "0")
    except InvalidOperation:
        raise ValueError(f"unparseable amount: {s!r}")
    return -v if neg and v > 0 else v

def reconciles(opening, closing, transactions, tolerance=Decimal("0.01")):
    total = sum(to_decimal(t["amount"]) for t in transactions)
    drift = (to_decimal(opening) + total) - to_decimal(closing)
    return abs(drift) <= tolerance, drift

ok, drift = reconciles(statement["opening_balance"],
                       statement["closing_balance"],
                       statement["transactions"])
if not ok:
    raise SystemExit(f"Extraction is incomplete or wrong: off by {drift}")
FlowParse
flowparse.io

The per-bank template treadmill

The first parser takes a day. The second takes half a day because you reuse the row grouping. By the fifth bank you have a configuration format, a small library of column maps, and a test suite. That is a real internal product, and it needs an owner.

The cost that surprises teams is not writing new parsers — it is the existing ones breaking. Banks redesign statements, add a promotional block, change a column width by four points. Your parser does not crash; it returns slightly different results, and without a completeness check nobody notices until a reconciliation fails.

So budget for the treadmill honestly: a column map per bank, a regression test per bank with a real statement committed as a fixture, and someone who fixes them when they break. If your product's value is in what you do with the transactions rather than in reading the PDF, that is a considerable amount of engineering pointed at the wrong problem.

When writing your own is the right call

Build it yourself when the documents must never leave your machine. That is a genuine advantage of the Python route and no hosted API can match it — pdfplumber and Tesseract run entirely locally, and for some compliance regimes that ends the discussion.

Build it when you have one or two banks with stable, text-based layouts and no growth plans. The work is bounded, the maintenance is small, and adding a dependency on an external service for a solved problem is not an improvement.

Build it when parsing *is* your product — a lending platform whose differentiator is statement analysis may reasonably want the whole pipeline in house, including the failure modes. Even then, the balance check above belongs in your code regardless of who does the extraction.

When it stops being worth it

It stops being worth it the moment the bank list is open-ended. Client-facing products receive statements from institutions you have never heard of, and every new one is a new column map. That is a queue that never empties.

It stops being worth it when scans arrive. Building an OCR pipeline that handles a photographed statement well is a different discipline from parsing text PDFs, and doing it badly is worse than not doing it — quietly wrong numbers beat an honest failure.

And it stops being worth it when the maths says so. A developer-day is worth more than a large volume of pages; if the per-page cost of an API is less than the time you spend maintaining parsers, the build is a hobby rather than a decision. The cost section does that arithmetic properly.

The same job as one HTTP call

There is no FlowParse Python SDK and no `pip install` — we would rather not maintain a wrapper around one POST request. The API is JSON over HTTPS: base64 the file, send it, read the structured result. Fifteen lines with `requests`, no dependency to track, no version to upgrade.

What comes back is already normalised: ISO dates, one signed amount per transaction, a running balance where the statement had one, plus the account and period metadata. That normalisation is most of what your parser was for — the debit/credit collapse, the locale-aware number parsing, the wrapped-description joining are all done before the JSON reaches you.

Handle the status codes deliberately, because two of them should never be retried. A 422 means the document held nothing extractable and is not billed. A 429 means your page budget is exhausted, and hammering it will not produce pages. Only a 5xx deserves a backoff — every retry that reaches the API is a real extraction that costs real pages.

extract.py — the whole integration
# Same job, one call. No parser to maintain, no per-bank column map.
import base64, os, time, requests

API = "https://flowparse.io/api/v1/extract"
KEY = os.environ["FLOWPARSE_API_KEY"]

def extract(path, retries=2):
    with open(path, "rb") as fh:
        payload = {
            "file": base64.b64encode(fh.read()).decode(),
            "filename": os.path.basename(path),
        }
    for attempt in range(retries + 1):
        r = requests.post(
            API,
            headers={"Authorization": f"Bearer {KEY}"},
            json=payload,
            timeout=180,          # the server gives up at 110s; leave headroom
        )
        if r.status_code == 200:
            body = r.json()               # {type, pages, billedPages, data:{type,data}}
            return body["data"]["data"]
        if r.status_code == 422:          # nothing extractable — NOT billed, do not retry
            raise ValueError(r.json().get("error", "unreadable document"))
        if r.status_code == 429:          # page budget exhausted — retrying will not help
            raise RuntimeError(r.json().get("error", "out of pages"))
        if r.status_code >= 500 and attempt < retries:
            time.sleep(2 ** attempt)      # only 5xx is worth retrying: retries are billed
            continue
        r.raise_for_status()

st = extract("october.pdf")
print(st["bank_name"], st["currency"], len(st["transactions"]), "transactions")

Timeouts, file size and what fails

Two limits are worth knowing before you write the wrapper. Documents are capped at 20 MB, which a scanned year of statements will exceed. Extraction has an internal ceiling of 110 seconds, after which you get an error rather than a hung connection — long, heavily scanned files are the ones that reach it.

Set your client timeout above the server's, not below it. A `requests` timeout of 30 seconds on a call the server may legitimately spend 90 seconds on produces a client-side failure while the extraction completes and bills normally. That is the worst combination: you pay and you get nothing.

For big documents, split before sending. A year of statements is twelve monthly PDFs, each of which extracts comfortably, and splitting is a few lines with pypdf. The same rule applies to automation platforms, which have their own shorter windows — Power Automate stops at two minutes and Airtable at thirty seconds.

Into pandas, and out to Excel

From the JSON, a DataFrame is one constructor call, and because dates arrive as ISO strings and amounts as numbers there is no format guessing to do. That is the part that usually consumes an afternoon when the input came from your own parser.

Do the balance assertion inside the pipeline rather than in a notebook you ran once. One line, on every document, failing loudly — that is what stops a silently short statement reaching whatever you build on top of it.

For output, either write the frame yourself with `to_excel`, or ask the API for a workbook directly if you want the formatted version with the original columns preserved. The PDF to Excel API covers the second route, and the export endpoint also produces accounting formats such as QBO, OFX and CSV shaped for a specific ledger.

pandas — DataFrame, check, export
import pandas as pd

df = pd.DataFrame(st["transactions"])          # date, description, category, amount, balance
df["date"] = pd.to_datetime(df["date"])        # already ISO, so no format guessing
df["amount"] = pd.to_numeric(df["amount"])

# The same completeness check, one line:
assert abs(st["opening_balance"] + df["amount"].sum() - st["closing_balance"]) < 0.01

monthly = df.groupby([df["date"].dt.to_period("M"), "category"])["amount"].sum()
df.to_excel("october.xlsx", index=False)

Processing a folder without making a mess

The natural next step is a loop over a directory, and the natural mistake is unbounded concurrency. A thread pool of four or eight is plenty; a hundred parallel requests will earn rate limiting and produce a failure pattern that is hard to distinguish from a bad document.

Make the loop resumable. Write each result to disk keyed by the input filename, skip files whose output already exists, and record failures in a list you can rerun. Extraction is billed, so a crash on file 180 of 200 should not mean paying for the first 179 again.

If the goal is one consolidated workbook rather than 200 JSON files, the merge endpoint does the consolidation server-side, and batch processing does the same thing in the browser with no code at all. Both handle the column-alignment problem that appears the moment statements come from different banks.

Testing a parser you cannot fully verify

Commit real statements as fixtures — anonymised if necessary, but real, because synthetic PDFs do not reproduce the layouts that break parsers. For each fixture, store the expected transaction count, the expected sum and the opening and closing balances. Those three numbers catch almost every regression without anyone maintaining a full expected-output file.

Add a property test on the invariants rather than the values: every row has a date, every amount parses to a Decimal, the balance chain is monotonic in the sense that each balance equals the previous one plus the amount. Invariants keep holding when a bank tweaks its layout; hard-coded expected rows do not.

Run the suite against the newest statement from each bank monthly. Layout changes are the main source of silent breakage, and a monthly failing test is a far better way to hear about one than a reconciliation query from your accountant.

The arithmetic, without the sales pitch

Do the sum with your own numbers rather than anyone's marketing. Count the banks you need, the hours each parser and its tests take, the hours per month spent on breakage, and the volume of pages you process. Then compare that to the metered cost of the same volume.

For a single bank at low volume, the build usually wins and this page has given you most of the code. For an open-ended bank list, or the moment scans enter the picture, the maintenance line dominates everything else and the comparison stops being close.

FactorYour own parserExtraction API
First bankHalf a day to two daysAn hour, including the wrapper
Each additional bankHours, plus a regression testNothing
Layout changeSilent breakage until someone noticesAbsorbed upstream
Scanned statementsA separate OCR projectHandled
Data leaves your machineNo — a real advantageYes, to the API
Ongoing costEngineering timePer page processed
Completeness proofYou write it (code above)Run on every statement, plus your own

How to measure accuracy without labelled data

Nobody has labelled bank statements lying around, and hand-labelling a hundred of them to benchmark a parser is a week you will not get back. So measure what you can measure: the proportion of documents whose balance chain reconciles, the proportion where the row count matches a manual count on a sample, and the number of rows that fail a type or invariant check.

The reconciliation rate is the single most useful production metric. It is objective, it needs no ground truth, and it moves when something breaks. Track it per bank and you will see a layout change the week it happens rather than the quarter it happens.

For the fields the arithmetic cannot check — descriptions, categories, reference numbers — sample. Ten documents a month read against their originals is enough to catch systematic problems, and it is the only honest way to know how good the non-numeric fields are.

Where the hosted route does not help

There is no Python SDK, no package on PyPI and no plans for one — the integration is a POST request, and a wrapper around it would be a dependency without a purpose. Everything you need is in the code above.

There is no self-hosted, on-premise or air-gapped version, and the extraction is not open source. If your requirement is that statements never leave your infrastructure, the honest answer is that the Python libraries in this page are your route and we are not a candidate.

And we are not an archive: originals are deleted immediately after extraction, so retaining the source PDFs remains your job. Extracted data is a working dataset; the document the bank issued is the evidence, and something in your stack has to keep it.

A worked example: eight banks and a growing list

A lending-operations team started where this page starts: one pdfplumber parser for the bank most of their applicants used, written in a day and genuinely good. Then the applicant mix widened. Eight parsers later they had a small internal library, a configuration format, a fixture suite, and one engineer spending a couple of days a month on layout drift.

The failure that changed the decision was not a crash. One bank added a promotional panel between transaction blocks; the parser treated the panel's rows as data on some pages and skipped a genuine block on others. Roughly two per cent of statements came out short, all of them looking clean. It surfaced through a customer query, not through a test.

What they kept was the balance check — the same function in this page, now run on every statement regardless of source. What they stopped was writing parsers. The engineering went back to the underwriting model, which was the actual product, and the reconciliation rate became a dashboard number rather than an unknown.

Where to go next

The endpoint reference and every response schema are in the API docs; keys come from the API dashboard. The bank statement API page covers statement-specific behaviour, and PDF to JSON API covers the general case.

For the invoice equivalent of this page — different failure modes, same philosophy — see invoice parser in Python. For the completeness argument in detail, bank statement validation explains what the check can and cannot prove.

If the answer turns out to be that you did not want to write code at all, bank statement to Excel does this by upload and batch does a folder at a time.

Keep the check, drop the parsers

Send the statement, get normalised transactions back, and run the same balance proof on every document — in fifteen lines of Python.

Frequently asked questions

Related