Reconciliation and matching
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.
The idea in plain words
Section titled “The idea in plain words”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.
See it
Section titled “See it”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.
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.
When to reach for it
Section titled “When to reach for it”| 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. |
How it works
Section titled “How it works”Exact pass: index one side, probe with the other
Section titled “Exact pass: index one side, probe with the other”- Define the key and normalize it: e.g.
(date, cents, reference uppercased without punctuation). - Index the left side:
key → queue of left indices. A queue, not a single value, so duplicates are kept. - 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.
- Whatever is still in the queues is unmatched on the left.
from collections import defaultdict, dequefrom 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")import assert from "node:assert/strict";
interface Rec { date: string; cents: number; ref: string } // date is YYYY-MM-DD
/** 1. Normalize before comparing: case, spaces and punctuation are noise. */export function normKey(r: Rec): string { const ref = r.ref.toUpperCase().replace(/[^A-Z0-9]/g, ""); return JSON.stringify([r.date, r.cents, ref]); // a tuple key that is safe in a Map}
/** Pair records with equal keys, one-to-one, keeping duplicates honest. */export function matchExact(left: Rec[], right: Rec[]) { const pool = new Map<string, number[]>(); left.forEach((r, i) => { // 2. index the left side by key const k = normKey(r); const q = pool.get(k); if (q) q.push(i); else pool.set(k, [i]); }); const pairs: [number, number][] = []; const onlyRight: number[] = []; right.forEach((r, j) => { // 3. each right record takes one left, FIFO const q = pool.get(normKey(r)); if (q && q.length) pairs.push([q.shift()!, j]); else onlyRight.push(j); }); const onlyLeft = [...pool.values()].flat().sort((a, b) => a - b); // 4. leftovers are breaks return { pairs, onlyLeft, onlyRight };}
const bank: Rec[] = [{ date: "2026-03-02", cents: 4_200, ref: "INV-17" }, { date: "2026-03-02", cents: 990, ref: "fee" }, { date: "2026-03-02", cents: 990, ref: "fee" }];const books: Rec[] = [{ date: "2026-03-02", cents: 990, ref: "FEE" }, { date: "2026-03-02", cents: 4_200, ref: "inv 17" }, { date: "2026-03-03", cents: 1_500, ref: "INV-18" }];const r = matchExact(bank, books);assert.deepEqual(r.pairs, [[1, 0], [0, 1]]);assert.deepEqual(r.onlyLeft, [2]); // second $9.90 fee: a duplicate charge?assert.deepEqual(r.onlyRight, [2]); // booked but not yet on the statementconsole.log("ok");Rec[] bank = [new("2026-03-02", 4_200, "INV-17"), new("2026-03-02", 990, "fee"), new("2026-03-02", 990, "fee")];Rec[] books = [new("2026-03-02", 990, "FEE"), new("2026-03-02", 4_200, "inv 17"), new("2026-03-03", 1_500, "INV-18")];var (pairs, onlyBank, onlyBooks) = Recon.MatchExact(bank, books);if (!pairs.SequenceEqual([(1, 0), (0, 1)])) throw new Exception("pairs");if (!onlyBank.SequenceEqual([2])) throw new Exception("second $9.90 fee should be unmatched");if (!onlyBooks.SequenceEqual([2])) throw new Exception("INV-18 is not on the statement yet");Console.WriteLine("ok");
record Rec(string Date, long Cents, string Ref); // Date is YYYY-MM-DD
static class Recon{ // 1. Normalize before comparing: case, spaces and punctuation are noise. public static (string, long, string) NormKey(Rec r) => (r.Date, r.Cents, new string(r.Ref.ToUpperInvariant().Where(char.IsAsciiLetterOrDigit).ToArray()));
// Pair records with equal keys, one-to-one, keeping duplicates honest. public static (List<(int, int)>, List<int>, List<int>) MatchExact(Rec[] left, Rec[] right) { var pool = new Dictionary<(string, long, string), Queue<int>>(); for (int i = 0; i < left.Length; i++) // 2. index the left side by key { var k = NormKey(left[i]); if (!pool.TryGetValue(k, out var q)) pool[k] = q = new Queue<int>(); q.Enqueue(i); } var pairs = new List<(int, int)>(); var onlyRight = new List<int>(); for (int j = 0; j < right.Length; j++) // 3. each right record takes one left, FIFO { if (pool.TryGetValue(NormKey(right[j]), out var q) && q.Count > 0) pairs.Add((q.Dequeue(), j)); else onlyRight.Add(j); } var onlyLeft = pool.Values.SelectMany(q => q).Order().ToList(); // 4. leftovers are breaks return (pairs, onlyLeft, onlyRight); }}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.
- Group both sides by a coarse key and compute
(count, sum)per group. See aggregation. - Compare group by group over the union of keys. A group present on one side only is a break too.
- Run row-level matching only inside disagreeing groups.
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")import assert from "node:assert/strict";
type Row = [date: string, cents: number];
/** Group rows into date -> [count, sum]. Cheap to compute on both sides. */export function controlTotals(rows: Row[]): Map<string, [number, number]> { const out = new Map<string, [number, number]>(); for (const [date, cents] of rows) { const t = out.get(date) ?? [0, 0]; out.set(date, [t[0] + 1, t[1] + cents]); } return out;}
/** 1. Compare totals per day first. 2. Only drill into days that disagree. */export function breaks(a: Row[], b: Row[]): string[] { const ta = controlTotals(a), tb = controlTotals(b); const days = [...new Set([...ta.keys(), ...tb.keys()])].sort(); const out: string[] = []; for (const day of days) { const [ca, sa] = ta.get(day) ?? [0, 0]; const [cb, sb] = tb.get(day) ?? [0, 0]; if (ca !== cb || sa !== sb) out.push(`${day}: count ${ca} vs ${cb}, sum ${sa} vs ${sb}, diff ${sa - sb}`); } return out;}
const bank: Row[] = [["2026-03-01", 1000], ["2026-03-01", 250], ["2026-03-02", 990], ["2026-03-02", 990]];const books: Row[] = [["2026-03-01", 250], ["2026-03-01", 1000], ["2026-03-02", 990], ["2026-03-03", 400]];assert.deepEqual(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.deepEqual(breaks(bank, bank), []); // 3. equal totals: nothing to drill intoconsole.log("ok");(string, long)[] bank = [("2026-03-01", 1000), ("2026-03-01", 250), ("2026-03-02", 990), ("2026-03-02", 990)];(string, long)[] books = [("2026-03-01", 250), ("2026-03-01", 1000), ("2026-03-02", 990), ("2026-03-03", 400)];var got = Totals.Breaks(bank, books);string[] want = ["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"];if (!got.SequenceEqual(want)) throw new Exception(string.Join("\n", got));if (Totals.Breaks(bank, bank).Count != 0) throw new Exception("3. equal totals: nothing to drill into");Console.WriteLine("ok");
static class Totals{ // Group rows into date -> (count, sum). Cheap to compute on both sides. public static Dictionary<string, (int Count, long Sum)> Control(IEnumerable<(string Date, long Cents)> rows) => rows.GroupBy(r => r.Date).ToDictionary(g => g.Key, g => (g.Count(), g.Sum(r => r.Cents)));
// 1. Compare totals per day first. 2. Only drill into days that disagree. public static List<string> Breaks((string, long)[] a, (string, long)[] b) { var ta = Control(a); var tb = Control(b); var outp = new List<string>(); foreach (var day in ta.Keys.Union(tb.Keys).Order(StringComparer.Ordinal)) { var (ca, sa) = ta.GetValueOrDefault(day); var (cb, sb) = tb.GetValueOrDefault(day); if (ca != cb || sa != sb) outp.Add($"{day}: count {ca} vs {cb}, sum {sa} vs {sb}, diff {sa - sb}"); } return outp; }}Tolerance pass: rules, not magic
Section titled “Tolerance pass: rules, not magic”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.
What it costs
Section titled “What it costs”| 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.
Variations you’ll meet
Section titled “Variations you’ll meet”- 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.00on one side and12.00 crediton 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.
Pitfalls
Section titled “Pitfalls”- 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.5vs149.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.
In the interview
Section titled “In the interview”- 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
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”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.
Go deeper
Section titled “Go deeper”- 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.