To find a missing bank statement, sort each account's statements by period and compare every statement's closing balance with the next statement's opening balance. They should be identical to the cent. When they aren't, something sits between them: a missing statement, a missing page, or a statement from a different account mixed in. The difference tells you how much activity you haven't seen.

Checking dates alone misses things. Balance carry-forward doesn't, because the bank has already done the arithmetic for you.

Why dates aren't enough

The obvious check is "do I have a statement for every month?" It fails in at least four ways:

  • Statement cycles often don't follow calendar months. A cycle of the 15th to the 14th means "October" and "November" are both partly in one statement.
  • Some banks issue an extra statement when an account changes (a product switch, a new account number, a fraud reissue). The months look complete but a short statement is missing in the middle.
  • Two statements for different accounts at the same bank look like one complete series if you only read dates.
  • A statement can be present but truncated: the first page is there, the transaction pages aren't.

Balances catch all of these.

The data

One row per statement, as printed in each statement's summary:

from dataclasses import dataclass from datetime import date from decimal import Decimal as D @dataclass class Stmt: account: str # last 4 is usually enough  start: date end: date opening: D closing: D source: str # file name, so you can point to the PDF  statements = [ Stmt("2208", date(2026, 3, 15), date(2026, 4, 14), D("8102.44"), D("9930.18"), "mar.pdf"), Stmt("2208", date(2026, 4, 15), date(2026, 5, 14), D("9930.18"), D("7415.60"), "apr.pdf"), Stmt("2208", date(2026, 6, 15), date(2026, 7, 14), D("11288.02"), D("10640.95"), "jun.pdf"), Stmt("2208", date(2026, 7, 15), date(2026, 8, 14), D("10640.95"), D("12004.31"), "jul.pdf"), Stmt("5519", date(2026, 7, 1), date(2026, 7, 31), D("2250.00"), D("1980.40"), "sav-jul.pdf"), ] 

The check

from itertools import groupby def chain(statements): findings = [] key = lambda s: s.account for account, group in groupby(sorted(statements, key=lambda s: (s.account, s.start)), key=key): prev = None for s in group: if prev: gap_days = (s.start - prev.end).days if s.start <= prev.end: findings.append((account, "overlap", prev.source, s.source, None)) elif s.opening != prev.closing: findings.append((account, "balance gap", prev.source, s.source, s.opening - prev.closing)) elif gap_days > 1: findings.append((account, "date gap, balances agree", prev.source, s.source, D("0"))) prev = s return findings for f in chain(statements): print(f) 

Output:

('2208', 'balance gap', 'apr.pdf', 'jun.pdf', Decimal('3872.42')) 

April's statement closes at 7,415.60. June's opens at 11,288.02. The May 15 to June 14 statement is missing, and $3,872.42 of net activity is in it. That is a specific, short request to make: "please send the statement for account 2208 ending June 14."

Account 5519 has only one statement, so it can't fail a chain check. That's worth flagging separately: a single statement proves nothing about completeness.

The case where dates have a gap but balances agree

The "date gap, balances agree" branch is there on purpose. It usually means the account had no activity, and some banks don't issue a statement for an empty cycle. That's fine. But it can also mean the missing statement had deposits and withdrawals that net to exactly zero. Rare, but in litigation work rare is the point. Flag it, don't fail it.

Pair it with the per-statement check

The chain check assumes each statement is internally right. Prove that first: opening plus the statement's own transactions should equal its closing. If a statement fails that, its closing balance is still printed by the bank, so the chain check still works, but the transaction rows you extracted are incomplete.

So the order is:

  1. Each statement ties to itself (rows add up from opening to closing).
  2. Each account's statements chain (closing equals the next opening).
  3. Request anything missing, and rerun 1 and 2 on what arrives.
  4. Only then, start analysis, with the source file kept on every row.

Where this matters most

Forensic accountants, divorce teams and lenders all receive statement productions from someone else, and an incomplete production is the norm, not the exception. The ZeraBooks write-up on forensic bank statement analysis covers the columns reviewers want once the set is complete.

It matters in ordinary bookkeeping too. Disclosure: I work on ZeraBooks, and its general ledger software reconciles every month against the uploaded statement, so a month that doesn't carry forward shows up as a month that doesn't tie, instead of a quiet hole in the books.