Skip to content

Reconciliation and matching

intermediateCommon atStripeBrexRampPlaidRead firstHash mapsSortingPractise in the Sandbox →

Reconciliation pairs records from two sources that should describe the same events (bank statement vs your ledger, processor report vs your charges) and reports what doesn’t pair. Do it in passes from strict to loose: an exact match on a normalized key with a hash map (O(n + m)), then a tolerance match on the leftovers only. Every record must end in exactly one bucket: matched, only left, or only right.

Two people kept notes on the same dinner bill. One wrote “Pizza 18.00”, the other “pizza, $18”. One has a $3 tip the other forgot. You don’t compare every line with every other line in your head; you first tick off the obvious pairs, then look harder at what’s left, and finally list the lines nobody can explain.

That’s reconciliation. In payments it runs daily: what the bank says moved vs what your ledger says moved. The leftovers, called breaks or exceptions, are where real bugs and real money hide: a double charge, a missing refund, a fee nobody booked.

The rules that make it work in code:

  • Normalize before comparing. Case, spacing, punctuation and leading zeros are noise. "inv 17" and "INV-17" are the same reference.
  • Match one-to-one. If the bank shows the same $9.90 fee twice and your books once, one bank line must stay unmatched. Counting (a multiset) keeps duplicates honest; a plain set would hide the double charge.
  • Strict first, loose later. Exact matches are cheap and certain. Fuzzy matches are expensive and need review. Running the loose pass only on leftovers keeps it from stealing a record that had a perfect partner.

Edit either list (up to 8 lines each, date, amount, ref), set the tolerance and date window, and step through. Pass 1 pairs exact keys; pass 2 tries leftovers within tolerance; the last frame is the break report.

Match two record lists: exact first, then fuzzy
step through
statement
03-0242.00INV-17
03-029.90fee
03-029.90fee
03-04149.50ACME wire
03-0512.00refund 88
ledger
03-029.90FEE
03-0242.00inv 17
03-03150.00Acme Corp
03-0630.00INV-19
just comparedexact matchfuzzy match (review)unmatched / break
Pass 1 matches on an exact normalized key (date, amount, reference). It is O(n) with a hash map and never guesses.
1 / 9

Try setting the tolerance to 0: the Acme wire stops matching because the bank deducted a 50¢ wire fee. Try a date window of 0: the transfer booked on the 3rd but settled on the 4th is now a break. Both are the everyday reasons exact matching isn’t enough.

The problem says… Why reconciliation fits
“compare the bank file with our records” / “find discrepancies” The definition of reconciliation.
“payments vs invoices”, “orders vs shipments”, “charges vs payouts” Two sources describe overlapping events; pair them.
“within N cents” / “within a few days” A tolerance pass after the exact pass.
“report unmatched items on both sides” Every record ends in a bucket; leftovers are the report.
two very large sorted files A merge-style two-pointer scan instead of a hash map.

Exact pass: index one side, probe with the other

Section titled “Exact pass: index one side, probe with the other”
  1. Define the key and normalize it: e.g. (date, cents, reference uppercased without punctuation).
  2. Index the left side: key → queue of left indices. A queue, not a single value, so duplicates are kept.
  3. For each right record, pop one left index with the same key (FIFO makes pairing deterministic). No partner: the right record is a candidate break.
  4. Whatever is still in the queues is unmatched on the left.
match_exact.py
from collections import defaultdict, deque
from typing import NamedTuple
class Rec(NamedTuple):
date: str # YYYY-MM-DD
cents: int
ref: str
def norm_key(r: Rec) -> tuple[str, int, str]:
"""1. Normalize before comparing: case, spaces and punctuation are noise."""
ref = "".join(ch for ch in r.ref.upper() if ch.isalnum())
return (r.date, r.cents, ref)
def match_exact(left: list[Rec], right: list[Rec]):
"""Pair records with equal keys, one-to-one, keeping duplicates honest."""
pool: dict[tuple, deque[int]] = defaultdict(deque)
for i, r in enumerate(left): # 2. index the left side by key
pool[norm_key(r)].append(i)
pairs, only_right = [], []
for j, r in enumerate(right): # 3. each right record takes one left
q = pool.get(norm_key(r))
if q:
pairs.append((q.popleft(), j)) # FIFO, so pairing is deterministic
else:
only_right.append(j)
only_left = sorted(i for q in pool.values() for i in q) # 4. leftovers are breaks
return pairs, only_left, only_right
if __name__ == "__main__":
bank = [Rec("2026-03-02", 4_200, "INV-17"), Rec("2026-03-02", 990, "fee"), Rec("2026-03-02", 990, "fee")]
books = [Rec("2026-03-02", 990, "FEE"), Rec("2026-03-02", 4_200, "inv 17"), Rec("2026-03-03", 1_500, "INV-18")]
pairs, only_bank, only_books = match_exact(bank, books)
assert pairs == [(1, 0), (0, 1)]
assert only_bank == [2] # the second $9.90 fee has no partner: a duplicate charge?
assert only_books == [2] # booked but not yet on the statement
print("ok")

Control totals: find the bad days before the bad rows

Section titled “Control totals: find the bad days before the bad rows”

Before pairing thousands of rows, compare counts and sums per group (per day, per currency, per merchant). Groups whose totals agree almost always reconcile; drill into the ones that don’t. This is how finance teams reconcile, and it’s a great thing to mention in an interview.

  1. Group both sides by a coarse key and compute (count, sum) per group. See aggregation.
  2. Compare group by group over the union of keys. A group present on one side only is a break too.
  3. Run row-level matching only inside disagreeing groups.
breaks (control totals).py
from collections import defaultdict
def control_totals(rows: list[tuple[str, int]]) -> dict[str, tuple[int, int]]:
"""Group (date, cents) rows into date -> (count, sum). Cheap to compute on both sides."""
out: dict[str, list[int]] = defaultdict(lambda: [0, 0])
for date, cents in rows:
out[date][0] += 1
out[date][1] += cents
return {d: (c, s) for d, (c, s) in out.items()}
def breaks(a: list[tuple[str, int]], b: list[tuple[str, int]]) -> list[str]:
"""1. Compare totals per day first. 2. Only drill into days that disagree."""
ta, tb = control_totals(a), control_totals(b)
out = []
for day in sorted(ta.keys() | tb.keys()):
ca, sa = ta.get(day, (0, 0))
cb, sb = tb.get(day, (0, 0))
if (ca, sa) != (cb, sb):
out.append(f"{day}: count {ca} vs {cb}, sum {sa} vs {sb}, diff {sa - sb}")
return out
if __name__ == "__main__":
bank = [("2026-03-01", 1000), ("2026-03-01", 250), ("2026-03-02", 990), ("2026-03-02", 990)]
books = [("2026-03-01", 250), ("2026-03-01", 1000), ("2026-03-02", 990), ("2026-03-03", 400)]
assert breaks(bank, books) == [
"2026-03-02: count 2 vs 1, sum 1980 vs 990, diff 990",
"2026-03-03: count 0 vs 1, sum 0 vs 400, diff -400",
]
assert breaks(bank, bank) == [] # 3. equal totals: nothing to drill into
print("ok")

The fuzzy pass in the visualization uses a simple, explainable rule. For each unmatched right record, among unmatched left records with |amount diff| ≤ tolerance and |date diff| ≤ window, pick the closest amount, then the closest date, then the earliest index.

That rule is greedy: it commits to each pair as it goes. Greedy is easy to explain and stable, but it can be suboptimal: an early record can take a candidate that a later one needed more. If the problem asks for “the most matches” or “the smallest total difference”, that’s the assignment problem (bipartite matching, Hungarian algorithm), a different beast. Always ask which one is wanted, and state the tie-break order explicitly so results are deterministic.

Step Time Space
Exact pass, hash map O(n + m) O(n)
Exact pass, both sides sorted by key O(n log n + m log m), then O(n + m) merge O(1) extra after sorting
Control totals O(n + m) O(groups)
Tolerance pass, naive O(u · v) over leftovers u and v O(1)
Tolerance pass, leftovers bucketed by date or sorted by amount O((u + v) log v) with binary search on the window O(v)
Optimal one-to-one (Hungarian) O(k³) O(k²)

The exact pass usually clears 95%+ of real-world records, so even an O(u · v) fuzzy pass is cheap because u and v are small. Say that out loud: it justifies the two-pass design.

  • One-to-many. One payout covers 40 charges; one invoice is paid in two instalments. Match a record against a sum of records (subset-sum in the worst case, so real systems use a shared reference id instead).
  • Streaming reconciliation. Events from both sides arrive over time. Keep unmatched records in a map keyed by id, match when the partner arrives, and age out (alert on) anything unmatched past a deadline.
  • Three-way match. Purchase order, receipt and invoice must all agree before paying.
  • Sign and direction. A refund may be -12.00 on one side and 12.00 credit on the other. Normalize the sign into the key.
  • Currency and FX. Amounts in different currencies need a rate and a tolerance for rounding; see money arithmetic.
  • Using a set instead of a multiset. Two identical $9.90 fees collapse into one key, and the duplicate charge disappears from the report.
  • Fuzzy before exact. A loose rule grabs a record that had a perfect partner, creating two breaks out of zero.
  • Unstable tie-breaks. “Pick any candidate within tolerance” gives different results on different runs or orderings. Define a total order.
  • Over-normalizing. Stripping all digits from references, or rounding amounts to dollars, creates false matches. Normalize only what’s genuinely noise.
  • Floats for amounts. 149.5 vs 149.50000001. Parse to integer cents first.
  • Dropping the leftovers. The unmatched lists are the product. Report both sides, with a reason if you have one.
  • Ask: What identifies the same event on both sides? Can either side have duplicates? Is matching one-to-one? What tolerance on amount and date, and is it inclusive? Which side wins ties?
  • State the plan: “Normalize keys, exact pass with a hash map of queues, then a tolerance pass on leftovers with an explicit tie-break, then report both unmatched lists.”
  • State the invariant: “Every record is in exactly one of matched, only-left, only-right. I’ll assert that the counts add up.”
  • Expect follow-ups: one-to-many, very large files (sort and merge, or partition by date), streaming arrival, explaining why a record didn’t match.
Check yourself
pick one
The bank shows two identical $9.90 fees on March 2; your ledger shows one. You build a set of normalized keys from the ledger and mark each bank row matched if its key is in the set. What does the report say?
Check yourself
pick one
Why run the tolerance pass only after the exact pass, on the leftovers?
Check yourself
pick one
Each side has 1 million rows sorted by (date, amount). Memory is tight. What is a good exact-match strategy?

Classic exercises, not gym questions:

  • Diff two CSV exports of the same table by primary key and report added, removed and changed rows.
  • Reconcile two sorted transaction files with a merge walk and constant memory.
  • Streaming matcher: events arrive from two feeds; pair them by id, and emit an alert for anything unmatched after 10 minutes.
  • Given a payout amount and a list of charges, find which charges it covers (small n, backtracking), and explain why this doesn’t scale.
  • Martin Fowler, Accounting Patterns: accounts, entries and adjustments, the vocabulary breaks are reported in.
  • Modern Treasury, Bank reconciliation: bank vs ledger matching, exceptions and tolerance in practice.
  • Stripe Docs, Payout reconciliation report: how a processor ties individual charges to bank payouts.
  • Introduction to Algorithms (CLRS, 4th ed.), ch. 25 “Matchings in Bipartite Graphs” (including the Hungarian algorithm), for when “best one-to-one matching” really is the question.