The best test for a bank statement parser, and the one we used to pick the best bank statement converter, is a tie-out: for every account on the statement, the printed opening balance plus the sum of the extracted rows must equal the printed closing balance, to the cent. If that holds, the extraction is almost certainly right. If it doesn't, the size of the gap usually tells you which row to look at. That check was the first criterion when we ranked 12 statement converters, and this post shows how to run it yourself in Python and in SQL.
Disclosure: I work on ZeraBooks, which makes one of those converters and ranks itself first. The code below is vendor-neutral. Point it at any parser's output.
Why not just measure accuracy?
Because "accuracy" for a statement parser is slippery. Per field? Per row? Per document? On whose documents? Vendors publish numbers like "99%+" or "99.9%" without the method, so you can't compare them and you can't reproduce them.
A statement gives you something better than a test set: a ground truth printed on the document itself. The bank tells you where the account started and where it ended. Your parser's rows have to connect those two numbers. That makes the check:
- Per document, so you know about this file, not an average.
- Free, because the bank already did the work.
- Strict, because one wrong digit anywhere breaks it.
- Explainable, because the size of the gap points at the kind of mistake.
The one thing it can't catch is two errors that cancel each other out. That's rare enough that I'd still take this check over any percentage.
The tie-out in Python
Rules that matter more than they look:
-
Use
Decimal, neverfloat.0.1 + 0.2is not0.3in floating point, and a tie-out that's off by0.00000000001is a tie-out you'll learn to ignore. That defeats the point. - Group by account. One PDF often holds a checking and a savings account. Tie out each one separately, or a gap in one hides inside the other.
- Money out is negative. Normalise to one signed amount column before summing. If your parser emits separate debit and credit columns, convert first.
-
Parentheses mean negative. Some banks print
(132.45).
Here's a small, runnable version with a diagnostic step. The sample has two accounts from one statement, and the savings account's printed closing balance has two digits swapped, which is the classic OCR failure.
import csv
import io
from collections import defaultdict
from decimal import Decimal
CENT = Decimal("0.01")
def money(text):
"""Parse '1,234.56', '-132.45' or '(132.45)' into an exact Decimal."""
text = text.strip().replace(",", "").replace("$", "")
if text.startswith("(") and text.endswith(")"):
text = "-" + text[1:-1]
return Decimal(text).quantize(CENT)
def tie_out(opening, amounts, closing):
"""Return the gap: 0 means opening + rows == closing to the cent."""
return (opening + sum(amounts, Decimal("0")) - closing).quantize(CENT)
def diagnose(gap, amounts):
"""Turn a non-zero gap into a short list of things to look at first."""
hints = []
if gap == 0:
return hints
for a in amounts:
if a == gap:
hints.append(f"row {a} may be doubled")
if a == -gap:
hints.append(f"a row of {-a} may be missing")
if a * 2 == gap or a * 2 == -gap:
hints.append(f"row {a} may have the wrong sign")
cents = int(abs(gap) * 100)
if cents % 9 == 0:
hints.append(f"gap {gap} divides by 9: look for two swapped digits")
return hints
SAMPLE = """account,date,description,amount,balance
CHK 4417,2026-09-01,Opening,,18432.17
CHK 4417,2026-09-02,Card settlement,1250.00,19682.17
CHK 4417,2026-09-03,Rent,-2400.00,17282.17
CHK 4417,2026-09-08,Payroll,-3184.20,14097.97
CHK 4417,2026-09-15,Customer deposit,5327.77,19425.74
SAV 0011,2026-09-01,Opening,,3210.00
SAV 0011,2026-09-05,Transfer in,300.00,3510.00
SAV 0011,2026-09-30,Interest,1.34,3511.34
"""
# Printed on the statement's summary page, per account.
PRINTED = {
"CHK 4417": (money("18,432.17"), money("19,425.74")),
"SAV 0011": (money("3,210.00"), money("3,511.43")), # last two digits swapped
}
rows = defaultdict(list)
for r in csv.DictReader(io.StringIO(SAMPLE)):
if r["amount"]:
rows[r["account"]].append(money(r["amount"]))
for account, amounts in rows.items():
opening, closing = PRINTED[account]
gap = tie_out(opening, amounts, closing)
status = "Proven" if gap == 0 else f"Needs a look (gap {gap})"
print(f"{account}: {len(amounts)} rows, {status}")
for hint in diagnose(gap, amounts):
print(" ", hint)
Output:
CHK 4417: 4 rows, Proven
SAV 0011: 2 rows, Needs a look (gap -0.09)
gap -0.09 divides by 9: look for two swapped digits
The diagnose function encodes four rules of thumb that cover most real failures:
| Gap looks like | Usual cause |
|---|---|
| Exactly one row's amount | That row was extracted twice |
| Minus one row's amount | A row was dropped, often at a page break |
| Twice one row's amount | A withdrawal came out as a deposit, or the reverse |
| A multiple of 9 (in cents) | Two neighbouring digits swapped somewhere |
The 9 rule works because swapping two adjacent digits a and b changes a number by 9 × (a - b) × 10^n. A 0.09 gap points at the last two digits; 630.00 points at the hundreds and tens. It can't tell you whether the swap is in a row or in the printed balance you typed in, so check both.
Find the exact row with the running balance
Most statements also print a running balance on each row. That's a second, finer check: each row's printed balance should equal the previous balance plus this row's amount. The first row where it doesn't is your culprit.
from decimal import Decimal
def first_break(opening, rows):
"""rows: (description, amount, printed_running_balance). Return the first row
where the previous balance plus this amount doesn't give the printed balance."""
expected = opening
for i, (desc, amount, balance) in enumerate(rows, start=1):
expected += amount
if expected != balance:
return i, desc, expected, balance
expected = balance # carry the bank's figure, so one error is reported once
return None
rows = [
("Card settlement", Decimal("1250.00"), Decimal("19682.17")),
("Rent", Decimal("-2400.00"), Decimal("17282.17")),
("Payroll", Decimal("-3814.20"), Decimal("14097.97")), # misread: 3184.20
("Customer deposit", Decimal("5327.77"), Decimal("19425.74")),
]
hit = first_break(Decimal("18432.17"), rows)
if hit:
i, desc, expected, printed = hit
print(f"row {i} ({desc}): expected {expected}, statement says {printed}, "
f"off by {printed - expected}")
Output:
row 3 (Payroll): expected 13467.97, statement says 14097.97, off by 630.00
Note the expected = balance line. After a break, carry the bank's printed figure forward rather than your computed one. Otherwise one bad row makes every row after it look bad too.
The same checks in SQL
If extracted rows land in a database, run the checks there. Store money as integer cents. This runs as-is in SQLite 3.25 or newer (window functions), and needs only small changes for Postgres.
CREATE TABLE stmt_rows (
account TEXT,
seq INTEGER,
description TEXT,
amount_cents INTEGER,
balance_cents INTEGER
);
CREATE TABLE stmt_summary (
account TEXT PRIMARY KEY,
opening_cents INTEGER,
closing_cents INTEGER
);
INSERT INTO stmt_rows VALUES
('CHK 4417', 1, 'Card settlement', 125000, 1968217),
('CHK 4417', 2, 'Rent', -240000, 1728217),
('CHK 4417', 3, 'Payroll', -381420, 1409797),
('CHK 4417', 4, 'Customer deposit', 532777, 1942574),
('SAV 0011', 1, 'Transfer in', 30000, 351000),
('SAV 0011', 2, 'Interest', 134, 351134);
INSERT INTO stmt_summary VALUES
('CHK 4417', 1843217, 1942574),
('SAV 0011', 321000, 351134);
-- 1. Tie-out per account: opening + rows must equal closing.
SELECT s.account,
COUNT(r.seq) AS rows,
s.opening_cents + SUM(r.amount_cents) - s.closing_cents AS gap_cents,
CASE WHEN s.opening_cents + SUM(r.amount_cents) = s.closing_cents
THEN 'Proven' ELSE 'Needs a look' END AS status
FROM stmt_summary s
JOIN stmt_rows r ON r.account = s.account
GROUP BY s.account
ORDER BY s.account;
-- 2. Which row breaks the printed running balance?
SELECT * FROM (
SELECT account, seq, description,
balance_cents - amount_cents
- COALESCE(LAG(balance_cents) OVER w,
(SELECT opening_cents FROM stmt_summary s
WHERE s.account = r.account)) AS break_cents
FROM stmt_rows r
WINDOW w AS (PARTITION BY account ORDER BY seq)
)
WHERE break_cents <> 0;
Output from sqlite3 -header -column :memory: < tieout.sql:
account rows gap_cents status
-------- ---- --------- ------------
CHK 4417 4 -63000 Needs a look
SAV 0011 2 0 Proven
account seq description break_cents
-------- --- ----------- -----------
CHK 4417 3 Payroll 63000
Query 1 is the gate: nothing with a non-zero gap goes downstream. Query 2 is the pointer: it names the row.
Try it on a real 140-row file
ZeraBooks publishes the file from its 10-bank sample run, ten fictional statements (Chase, Bank of America, Wells Fargo, Navy Federal, Synchrony, Monzo, RBC, Desjardins, TD, CommBank) downloaded as one CSV with the account and source file on every row. Here's a running-balance check over it:
import csv
import sys
from collections import defaultdict
from decimal import Decimal
path = sys.argv[1]
by_account = defaultdict(list)
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
by_account[row["Account"]].append(row)
for account, rows in by_account.items():
if not rows[0]["Balance"]:
print(f"{account}: {len(rows)} rows, no running balance on the statement")
continue
opening = Decimal(rows[0]["Balance"]) - Decimal(rows[0]["Amount"])
breaks, prev = 0, opening
for r in rows:
if prev + Decimal(r["Amount"]) != Decimal(r["Balance"]):
breaks += 1
prev = Decimal(r["Balance"])
print(f"{account}: {len(rows)} rows, {opening} -> {prev}, breaks: {breaks}")
curl -s -o all-details.csv "https://www.zerabooks.com/marketing/reviews/best-converter/All%20details%20-%2010%20documents.csv"
python3 alldetails.py all-details.csv
RBC Business Account 5108: 14 rows, 31204.66 -> 19215.82, breaks: 0
Chase Business Complete Checking 4417: 21 rows, 18432.17 -> 19425.74, breaks: 0
Initiate Business Checking 2265: 18 rows, 9614.08 -> 14819.40, breaks: 0
High Yield Savings 7392: 4 rows, 24150.00 -> 25037.64, breaks: 0
Monzo Business Lite 0921: 13 rows, 6845.20 -> 11914.71, breaks: 0
CommBank Business Transaction Account 7714: 13 rows, 15320.40 -> 11700.77, breaks: 0
Every Day Business Plan Chequing 3307: 12 rows, 22840.15 -> 20219.38, breaks: 0
Business Advantage Fundamentals Banking 8803: 18 rows, no running balance on the statement
Compte d'opérations entreprise 1234: 13 rows, 14378.90 -> 10771.60, breaks: 0
EVERYDAY CHECKING 6620: 12 rows, 1482.30 -> 4702.00, breaks: 0
MEMBERSHIP SAVINGS 0011: 2 rows, 3210.00 -> 3511.34, breaks: 0
Two things worth noticing. Ten PDFs produce eleven accounts, because the Navy Federal statement holds a checking and a savings account. And the Bank of America statement prints no running balance on its rows, so only the summary-page tie-out applies there. Your test harness needs to handle both cases without crashing or silently passing.
What else belongs in a parser test suite?
The tie-out catches wrong numbers. It doesn't catch everything. These are the other checks I'd add, all cheap:
- Row count. If the statement prints a transaction count, match it.
- Dates inside the period. Every row's date falls between the statement's start and end, with the right year. December-to-January statements are where year inference breaks.
- No junk rows. No page headers, "balance forward", "continued on next page" or subtotal lines in the output.
-
Reference numbers intact. Long IDs stored as text, not numbers. Excel shows a 17-digit reference as
1.23E+16and keeps only 15 significant digits. - No statement without balances marked as passing. If the PDF prints no opening or closing balance, the result is "unverified", not "proven".
How did this shape the ranking?
When we ranked 12 converters, the first question for each tool was whether it runs this check itself. ZeraBooks marks each account Proven or Needs a look based on exactly this tie-out, per account and per cardholder. DocuClipper says it runs reconciliation checks on its output. MoneyThumb describes automatic reconciliation against the summary totals. Hubdoc errors when totals don't match. Several others don't say. Those answers come from each vendor's own pages, with checked dates, in ZeraBooks' ranking of the 12 converters.
AI-based parsers make this more important, not less. A model reads layouts it has never seen, which is great, and it can also misread a digit with total confidence. ZeraBooks wrote a separate ranking on how each AI bank statement converter backs its accuracy claim, and the short version is that the arithmetic check has to live outside the model.
If you're building or buying a parser, put the tie-out in CI. Feed it every statement layout you've ever seen fail. A parser that passes the tie-out on a hundred ugly real-world statements is better tested than one with a great number on a landing page.
Top comments (0)