A bank statement CSV is complete when two things hold: the opening balance plus every transaction equals the printed closing balance, and every row's running balance equals the previous balance plus that row's amount. If both checks pass to the cent, no row is missing, duplicated or misread. If either fails, the row where it first breaks tells you where to look.

That is the whole idea. The rest of this post is the code, and the edge cases that make it less simple than it sounds.

Why "the rows look right" isn't enough

Anyone who has pulled transactions out of a PDF statement has seen these:

  • A row that wrapped onto two lines and came out as two transactions, one with no amount.
  • A page break that dropped the last row of page 3.
  • A 1,234.50 that became 1234.5, or a (45.00) that lost its minus sign.
  • An OCR'd 8 that is really a 3.

Eyeballing 400 rows catches none of these reliably. Arithmetic catches all of them, because the bank already printed the answer: the closing balance.

The data

Assume the extractor (any tool, or your own parser) gives you rows like this, plus the opening and closing balances printed in the statement summary:

date,description,amount,balance 2026-09-01,OPENING BALANCE,,4210.55 2026-09-02,DEPOSIT MOBILE,1500.00,5710.55 2026-09-03,CHECK 1042,-842.10,4868.45 2026-09-03,CARD PURCHASE SHELL 0447,-96.37, 2026-09-05,ACH PAYROLL GUSTO,-2310.00,2462.08 2026-09-08,WIRE IN,3000.00,5462.08 

Notice the blank balance on the Shell row. Many banks print one running balance per day, not per row. The check has to cope with that.

The code

Use Decimal, never float. 0.1 + 0.2 is not a rounding error you want in a reconciliation.

import csv from dataclasses import dataclass from decimal import Decimal, InvalidOperation CENT = Decimal("0.01") def parse_money(raw: str) -> Decimal | None: s = (raw or "").strip().replace(",", "").replace("$", "") if not s: return None negative = s.startswith("(") and s.endswith(")") s = s.strip("()") if s.endswith("-"): # some banks print 45.00-  negative, s = True, s[:-1] try: value = Decimal(s) except InvalidOperation: raise ValueError(f"not a money value: {raw!r}") return -value if negative else value @dataclass class Break: row: int expected: Decimal printed: Decimal def tie_out(path: str, opening: Decimal, closing: Decimal): running = opening breaks: list[Break] = [] total_in = total_out = Decimal("0") with open(path, newline="") as f: for i, row in enumerate(csv.DictReader(f), start=2): amount = parse_money(row["amount"]) if amount is None: continue # header rows like OPENING BALANCE  running += amount if amount > 0: total_in += amount else: total_out += amount printed = parse_money(row.get("balance", "")) if printed is not None and printed.quantize(CENT) != running.quantize(CENT): breaks.append(Break(i, running, printed)) running = printed # resync so one bad row doesn't flag every row after it  return { "ties_to_closing": running.quantize(CENT) == closing.quantize(CENT), "difference": (closing - running).quantize(CENT), "money_in": total_in, "money_out": total_out, "breaks": breaks, } 

Two details worth calling out.

Resync after a break. If row 40 is misread, every running balance after it will be off by the same amount. Without the running = printed line you get 360 errors instead of one. With it, you get the one row that matters.

Blank balances are skipped, not failed. On a per-day balance statement, the check only fires on the last row of each day, which is exactly where the bank printed a number. The final closing check still covers every row.

Checking the printed totals too

Most statements also print "Total deposits" and "Total withdrawals" in the summary box. Compare them as well:

result = tie_out("sept.csv", opening=Decimal("4210.55"), closing=Decimal("5462.08")) printed_deposits = Decimal("4500.00") printed_withdrawals = Decimal("-3248.47") assert result["money_in"] == printed_deposits, "a deposit is missing or doubled" assert result["money_out"] == printed_withdrawals, "a withdrawal is missing or doubled" 

This catches a case the closing balance alone can miss: a $200 deposit that was dropped and a $200 withdrawal that was also dropped. The net is still right. The totals are not.

What a failing result tells you

Result Most likely cause
Closing doesn't tie, no row breaks A row is missing where the bank printed no running balance, or the sign of one amount is flipped (the difference is then exactly twice that amount)
One break, closing ties after resync A single misread amount on that row
Difference equals one row's amount That row is duplicated or missing
Difference divisible by 9 A transposed digit, like 54.10 read as 45.10

The "twice the amount" and "divisible by 9" tricks are old bookkeeper habits, and they still work on extracted data.

Credit cards and the sign problem

On a card statement, purchases increase the balance you owe. If your extractor writes purchases as negative numbers (money out of your pocket), flip the logic: running -= amount, or normalize the sign first. Decide once, per account type, and write it down. Mixing conventions between a checking account and a card is the most common reason a perfectly good extraction "fails".

Where this fits

This check is the floor, not the ceiling. It proves one statement is internally complete. It doesn't prove you have every statement in the set; that is a different check (each closing balance should equal the next statement's opening balance), and it is the first thing forensic accountants run on a document production. The ZeraBooks guide to forensic bank statement analysis walks through that chain check with a missing April.

Disclosure: I work on ZeraBooks, and the ZeraBooks bank statement converter runs this kind of proof on every statement before you download it, then shows the difference when one doesn't tie. If you are building your own parser, the code above is enough to stop trusting rows you haven't proven.