Reconcile a Bank Statement CSV with a Ledger in Python
Month-end reconciliation matches every line on the bank statement to an entry in the ledger. A first attempt with merge on amount and date matches about half the lines and reports the rest as unexplained: card payments that settle two days after they were booked, one customer transfer that pays three invoices, bank charges nobody entered, references typed as INV 2231 in one system and INV-002231 in the other. Worse, two £45.00 payments on the same day each match both ledger entries, producing four matches and a reconciliation that appears to balance when it does not.
Root Cause
A bank statement and a ledger describe the same money from different systems with no shared identifier. Matching is therefore a set of rules, not a join, and plain merge misapplies them in two ways. It matches all candidates at once, so duplicate amounts create many-to-many matches and the same bank line is "used" several times. And it has no notion of precedence: a weak match on amount alone can consume a ledger entry that a strong match on reference should have claimed. Real reconciliation matches in passes from strongest to weakest evidence and removes matched records from both pools after each pass, so every line is matched at most once and strong evidence always wins.
Minimal Diagnostic
Measure how ambiguous the data is before choosing rules: how many amounts repeat on each side, how far booking and value dates drift, and whether references can be extracted at all.
# pip install "pandas>=2.2"
import re
from pathlib import Path
import pandas as pd
BANK = Path("in/bank-2026-09.csv")
LEDGER = Path("in/ledger-2026-09.csv")
REF = re.compile(r"INV[\s\-]*0*(\d{3,6})", re.I)
def load(path: Path, date_col: str, amount_col: str, text_col: str) -> pd.DataFrame:
try:
frame = pd.read_csv(path, dtype={text_col: "string"})
except (OSError, pd.errors.ParserError) as exc:
raise SystemExit(f"cannot read {path}: {exc}")
return pd.DataFrame({
"date": pd.to_datetime(frame[date_col], dayfirst=True, errors="coerce"),
"amount": pd.to_numeric(frame[amount_col], errors="coerce").round(2),
"text": frame[text_col].fillna(""),
})
if __name__ == "__main__":
bank = load(BANK, "Date", "Amount", "Description")
ledger = load(LEDGER, "posting_date", "amount", "memo")
for name, f in (("bank", bank), ("ledger", ledger)):
repeats = f["amount"].duplicated(keep=False).mean()
refs = f["text"].str.contains(REF).mean()
print(f"{name}: {len(f)} lines, {repeats:.0%} share a repeated amount, {refs:.0%} carry an invoice ref")
pairs = bank.merge(ledger, on="amount", suffixes=("_bank", "_ledger"))
lag = (pairs["date_bank"] - pairs["date_ledger"]).dt.days
print("date lag for same-amount pairs:", lag.clip(-10, 10).value_counts().sort_index().to_dict())
bank: 612 lines, 23% share a repeated amount, 41% carry an invoice ref
ledger: 655 lines, 27% share a repeated amount, 88% carry an invoice ref
date lag for same-amount pairs: {-1: 12, 0: 301, 1: 84, 2: 61, 3: 19, 4: 3, ...}
A quarter of amounts repeat, so amount alone is ambiguous; most lags fall between zero and three days, which sets the date window; and references exist on enough lines to be the strongest first pass.
Fix: Match in Passes and Consume Matched Lines
Each pass selects candidates, resolves ambiguity to strictly one-to-one pairs, records them, and removes them from both pools. Changed lines are commented.
# pip install "pandas>=2.2"
import re
from pathlib import Path
import pandas as pd
REF = re.compile(r"INV[\s\-]*0*(\d{3,6})", re.I)
WINDOW_DAYS = 3
def prepare(frame: pd.DataFrame, prefix: str) -> pd.DataFrame:
out = frame.copy().reset_index(drop=True)
out[f"{prefix}_id"] = out.index # changed: stable row ids
out["ref"] = out["text"].str.extract(REF, expand=False) # changed: normalised ref
out["cents"] = (out["amount"] * 100).round().astype("Int64") # changed: exact integer money
return out
def one_to_one(cands: pd.DataFrame, sort_cols: list[str]) -> pd.DataFrame:
"""Keep each bank line and each ledger line at most once, best candidates first."""
cands = cands.sort_values(sort_cols, kind="stable")
cands = cands.drop_duplicates("bank_id", keep="first") # changed: no reuse of bank lines
return cands.drop_duplicates("ledger_id", keep="first") # changed: no reuse of ledger lines
def reconcile(bank: pd.DataFrame, ledger: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
bank, ledger = prepare(bank, "bank"), prepare(ledger, "ledger")
matches = []
def take(pairs: pd.DataFrame, rule: str) -> None:
nonlocal bank, ledger
if pairs.empty:
return
matches.append(pairs[["bank_id", "ledger_id"]].assign(rule=rule))
bank = bank[~bank["bank_id"].isin(pairs["bank_id"])] # changed: consume
ledger = ledger[~ledger["ledger_id"].isin(pairs["ledger_id"])]
c = bank.dropna(subset=["ref"]).merge(ledger.dropna(subset=["ref"]), on=["ref", "cents"],
suffixes=("_b", "_l"))
take(one_to_one(c, ["bank_id"]), "reference")
c = bank.merge(ledger, left_on=["cents", "date"], right_on=["cents", "date"], suffixes=("_b", "_l"))
take(one_to_one(c, ["bank_id"]), "amount+date")
c = bank.merge(ledger, on="cents", suffixes=("_b", "_l"))
c["lag"] = (c["date_b"] - c["date_l"]).dt.days.abs()
c = c[c["lag"] <= WINDOW_DAYS]
take(one_to_one(c, ["lag", "bank_id"]), f"amount+{WINDOW_DAYS}d") # changed: nearest date first
matched = pd.concat(matches, ignore_index=True) if matches else pd.DataFrame(
columns=["bank_id", "ledger_id", "rule"])
return matched, bank, ledger
if __name__ == "__main__":
from diagnose import load, BANK, LEDGER # loader from the diagnostic
matched, open_bank, open_ledger = reconcile(load(BANK, "Date", "Amount", "Description"),
load(LEDGER, "posting_date", "amount", "memo"))
print(matched["rule"].value_counts().to_string())
print(f"unmatched: {len(open_bank)} bank lines, {len(open_ledger)} ledger lines")
reference 248
amount+date 219
amount+3d 118
unmatched: 27 bank lines, 70 ledger lines
Money is compared as integer cents. Floating-point amounts that print identically can differ in the fifteenth decimal place after arithmetic in a spreadsheet export, and a join on float equality misses them unpredictably. Sorting by date lag before de-duplicating makes the window pass choose the nearest date when two ledger entries have the same amount — a deterministic rule an auditor can follow.
Reading the Match Profile
The counts per rule are more than a progress report — they describe the health of the bookkeeping process. A month where most matches come from the reference pass means invoices carry references that customers copy into their payments. A growing share of window matches means booking dates drift further from settlement, often because card batches are posted weekly. Track the profile month to month next to the reconciliation itself:
When the unmatched bar grows while the others stay flat, something upstream changed — a new payment provider with a different description format, or a ledger export that dropped the memo column. Those are process fixes, and the profile finds them faster than inspecting individual lines.
Variant Fix 1: One Payment Settles Several Invoices
A customer pays £3,840.00 for invoices of £1,200.00, £1,440.00 and £1,200.00. No single ledger line matches. For the remaining unmatched lines, look for combinations of a customer's open ledger entries within the date window that sum exactly to the bank amount:
# pip install "pandas>=2.2"
from itertools import combinations
import pandas as pd
MAX_GROUP = 4 # combinations grow fast; keep the search bounded
def many_to_one(bank: pd.DataFrame, ledger: pd.DataFrame, customer_col: str = "customer") -> list[dict]:
found, used = [], set()
for b in bank.itertuples():
pool = ledger[(ledger[customer_col] == getattr(b, customer_col))
& ((ledger["date"] - b.date).dt.days.abs() <= 10)
& (~ledger["ledger_id"].isin(used))]
cents = list(zip(pool["ledger_id"], pool["cents"]))
hit = None
for size in range(2, min(MAX_GROUP, len(cents)) + 1):
for combo in combinations(cents, size):
if sum(c for _, c in combo) == b.cents:
hit = [lid for lid, _ in combo]
break
if hit:
break
if hit:
used.update(hit)
found.append({"bank_id": b.bank_id, "ledger_ids": hit, "rule": "many-to-one"})
return found
The search is restricted to the same customer and a wider date window, and capped at four invoices, because unrestricted subset-sum search over hundreds of lines is both slow and prone to coincidental matches. The customer column has to be derived on the bank side, usually from the payer name, which is the kind of normalisation described in clean column names and whitespace in pandas. Treat many-to-one matches as proposals for review rather than automatic postings.
Variant Fix 2: Bank Charges and Interest Not in the Ledger
Some statement lines have no ledger counterpart until someone posts them: account fees, interest, card terminal charges. Classify them by description before matching so they appear as "to be posted" rather than as unexplained differences:
# pip install "pandas>=2.2"
import pandas as pd
UNPOSTED_RULES = {
"bank fee": r"\b(ACCOUNT FEE|MONTHLY FEE|CHARGES?)\b",
"interest": r"\bINTEREST\b",
"card fees": r"\b(MERCHANT|TERMINAL) (FEE|SERVICE)\b",
}
def classify_unposted(bank_open: pd.DataFrame) -> pd.DataFrame:
out = bank_open.copy()
out["category"] = "unexplained"
for category, pattern in UNPOSTED_RULES.items():
hit = out["text"].str.upper().str.contains(pattern, regex=True) & (out["category"] == "unexplained")
out.loc[hit, "category"] = category
return out
A short, reviewed rules table beats clever fuzzy matching here: it is predictable, and the unexplained remainder shrinks to lines that genuinely need a person.
Verification
A reconciliation is only valid if the numbers tie out. For each side, the total of matched lines plus the total of unmatched lines must equal the side's total, and no line may appear in two matches.
# pip install "pandas>=2.2"
import pandas as pd
def verify(bank_all: pd.DataFrame, ledger_all: pd.DataFrame, matched: pd.DataFrame,
open_bank: pd.DataFrame, open_ledger: pd.DataFrame) -> None:
assert not matched["bank_id"].duplicated().any(), "a bank line was matched twice"
assert not matched["ledger_id"].duplicated().any(), "a ledger line was matched twice"
b = prepare(bank_all, "bank") # same ids as during matching
l = prepare(ledger_all, "ledger")
matched_bank = b.loc[b["bank_id"].isin(matched["bank_id"]), "cents"].sum()
matched_ledger = l.loc[l["ledger_id"].isin(matched["ledger_id"]), "cents"].sum()
assert matched_bank == matched_ledger, f"matched totals differ: {matched_bank} vs {matched_ledger}"
assert matched_bank + open_bank["cents"].sum() == b["cents"].sum(), "bank side does not tie out"
assert matched_ledger + open_ledger["cents"].sum() == l["cents"].sum(), "ledger side does not tie out"
print(f"tied out: matched {matched_bank / 100:,.2f}; "
f"open bank {open_bank['cents'].sum() / 100:,.2f}, open ledger {open_ledger['cents'].sum() / 100:,.2f}")
The matched totals must be equal on both sides because every one-to-one match pairs identical amounts; if they differ, a pass allowed non-equal amounts or reused a line. The open balances on each side are what the accountant investigates and, eventually, what appears on the reconciliation statement.
FAQ
Should I allow small amount differences, such as FX rounding? Only in a separate, late pass with a very small tolerance and a distinct rule name, so those matches can be reviewed. Never mix tolerance into the exact passes.
What date should I use from the bank file? Value date if available — it reflects when money moved — otherwise booking date. Record which one, because the lag distribution differs.
How do I carry unmatched items into next month? Save the open lines with their original ids and include them as inputs next month; many timing differences clear within days.
Can this handle multiple bank accounts? Run it per account. Mixing accounts multiplies ambiguous amounts and hides transfers between your own accounts, which are best matched as their own pass.
Related
- Comparing and Reconciling Spreadsheets with Python — outcomes, exception workbooks and history
- Fix Merge Indicator Unexpected left_only Rows — dtype and whitespace issues that block joins
- Fixing Encoding Errors in CSV Files — bank exports in legacy encodings
- Normalize Inconsistent Date Formats in CSV — day-first and month-first dates in one file