A bank reconciliation in code is: match book lines to bank lines, treat unmatched book deposits as in transit and unmatched book payments as outstanding, treat unmatched bank lines and amount differences as book-side adjustments, then assert that the statement balance adjusted for the first two equals the book balance adjusted for the rest. The book-side items become journal entries.
I'm on the ZeraBooks team. Reconciliation is the core of what our ledger does, and it's often explained as a form to fill in. Written as code it's short, and the structure makes it obvious which side of the reconciliation each item belongs on, which is the part people get wrong by hand.
The two sides
Every reconciling item belongs to exactly one side:
| Item | Where it is | Side | Why |
|---|---|---|---|
| Deposit in transit | Books, not bank yet | Bank | The bank will catch up next month |
| Outstanding check | Books, not bank yet | Bank | Same |
| Bank fee, interest | Bank, not books | Book | You haven't recorded it; you need to |
| Amount recorded wrong | Both, different amounts | Book | The bank cleared what was actually written |
Bank-side items need no entry; they resolve themselves. Book-side items need a journal entry, or the books stay wrong.
The code
from dataclasses import dataclass
from decimal import Decimal as D
@dataclass(frozen=True)
class Line:
date: str
ref: str # check number or short description
amount: D # money in positive, money out negative
def match(book: list[Line], bank: list[Line]):
"""Pair book and bank lines: same check number, else same amount. Each line used once."""
unmatched_bank = list(bank)
matched, book_only = [], []
for b in book:
cand = [x for x in unmatched_bank if x.ref == b.ref and b.ref.isdigit()] \
or [x for x in unmatched_bank if x.amount == b.amount and not x.ref.isdigit()]
if cand:
matched.append((b, cand[0]))
unmatched_bank.remove(cand[0])
else:
book_only.append(b)
return matched, book_only, unmatched_bank
def reconcile(book, bank, book_balance: D, statement_balance: D):
matched, book_only, bank_only = match(book, bank)
in_transit = sum((b.amount for b in book_only if b.amount > 0), D("0"))
outstanding = sum((-b.amount for b in book_only if b.amount < 0), D("0"))
# matched pairs whose amounts differ are book errors (the bank cleared what was written)
errors = [(b, k, k.amount - b.amount) for b, k in matched if b.amount != k.amount]
book_side = sum((k.amount for k in bank_only), D("0")) + sum((e[2] for e in errors), D("0"))
adj_bank = statement_balance + in_transit - outstanding
adj_book = book_balance + book_side
assert adj_bank == adj_book, f"unreconciled difference {adj_bank - adj_book}"
entries = [(k.ref, k.amount) for k in bank_only] + [(f"correct {b.ref}", diff) for b, _, diff in errors]
return adj_bank, in_transit, outstanding, entries
Signed amounts throughout: money in positive, money out negative. Decimal, never float.
The matching rule is intentionally simple: checks match on check number, everything else on exact amount, each bank line used once. Matching on check number first is what lets the code detect an amount error on a check: the pair is found by number, and the amounts disagree. Real-world matching needs date windows and handling for split deposits, but the reconciliation logic after matching doesn't change.
A fictional October
Books for a small firm's checking account at October 31 show a balance of 19,826.65. The bank statement's closing balance is 18,962.40.
book = [
Line("10-03", "2210", D("-392.00")),
Line("10-14", "Deposit", D("5200.00")),
Line("10-20", "2214", D("-612.00")),
Line("10-27", "2217", D("-874.25")),
Line("10-31", "Deposit", D("2350.00")),
]
bank = [
Line("10-06", "2210", D("-362.00")),
Line("10-15", "Deposit", D("5200.00")),
Line("10-31", "Service fee", D("-35.00")),
Line("10-31", "Interest", D("4.50")),
]
reconcile(book, bank, D("19826.65"), D("18962.40"))
# (Decimal('19826.15'), Decimal('2350.00'), Decimal('1486.25'),
# [('Service fee', Decimal('-35.00')), ('Interest', Decimal('4.50')), ('correct 2210', Decimal('30.00'))])
(Only the lines relevant to reconciling items are shown; the month's other matched transactions don't change the result.)
Reading the result
Bank side. The 2,350.00 deposit made on October 31 hasn't reached the bank: in transit. Checks 2214 and 2217, for 612.00 and 874.25, haven't cleared: 1,486.25 outstanding.
18,962.40 plus 2,350.00 minus 1,486.25 is 19,826.15.
Book side. The bank charged a 35.00 service fee and paid 4.50 of interest, neither recorded yet. And check 2210 was written for 362.00 but entered in the books as 392.00; the bank cleared 362.00, so the books understate cash by 30.00.
19,826.65 minus 35.00 plus 4.50 plus 30.00 is 19,826.15.
Both sides land on 19,826.15. The assertion passes, and the function returns three journal entries to post: bank fees 35.00, interest income 4.50, and a 30.00 correction to whatever check 2210 was charged to.
Why the assertion matters
If the assertion fails, the difference is real and unexplained. Common causes, roughly in order:
- A transaction that's in the books twice. The difference equals that amount.
- A transposition, such as 874.25 entered as 847.25. The difference is divisible by 9 (27.00 here).
- Last month's reconciliation wasn't actually finished, so an old outstanding item is missing from this month's list.
- A bank line matched to the wrong book line by amount, when two transactions share an amount.
Never "fix" a failed reconciliation by posting the difference to a suspense or miscellaneous account. That makes the assertion pass and hides the problem.
One thing to add before using this
Carry forward outstanding items. A check outstanding at September 30 that clears in October appears on October's bank statement with no October book line. As written, the code would treat it as a bank-only item and propose an entry, which would double-count it. Seed book with last month's outstanding and in-transit items to fix that.
How ZeraBooks does it
ZeraBooks is an AI-native general ledger. It reads your bank and card statements, matches them to the books and proves each month against the statement's printed opening and closing balance: the statement must add up, follow the previous statement and agree with the ledger before a month closes. The AI never does the arithmetic; balances are computed in code. Proposed entries like the three above are approved by a person and can be undone. There's no live bank feed; statements are uploaded as PDF or CSV. It's $79 a month billed annually or $149 monthly, with a 7-day trial.
The step-by-step version without code, with a worked month, is our guide on how to do a bank reconciliation.
Disclosure: I work at ZeraBooks.
Top comments (0)