How-to
Advanced Data Transformation And CleaningDeep dive

Reconcile Excel Totals Against Control Totals with Python

Prove that an Excel import or report is complete by reconciling it with control totals — record counts, amount totals and hash totals supplied by the source system — overall and per group, at every stage of a pipeline, with money compared in cents and a reconciliation sheet that shows exactly where figures diverge.

Finance teams do not trust a report because the code looks right; they trust it because the totals agree. When a system exports invoices to Excel, it can also provide control totals — the number of records and the sum of the amounts — either as rows at the bottom of the sheet or in a separate file. Recomputing them after the import proves that nothing was lost or duplicated, and reconciling again after each processing step shows exactly where a figure went astray. This guide, part of Validating Excel Data with Python, builds those checks with pandas.

An invoice export with control rows at the bottom Five invoice rows for Leeds, York and Hull are followed by a blank row and two control rows. The first control row states 5 records and the second states a total amount of 2,055.25, computed by the system that produced the file. A B C 1 invoice branch amount 2 INV-001 Leeds 1,200.10 3 INV-002 Leeds 349.90 4 INV-003 York 80.00 5 INV-004 York 15.25 6 INV-005 Hull 410.00 7 8 CONTROL records 5 9 CONTROL amount 2,055.25 the gold rows are the source system's own totals

Prerequisites

Bash
pip install pandas openpyxl

An invoice export with control rows, and a separate file of per-branch controls:

Python
import pandas as pd
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.append(["invoice", "branch", "amount"])
for row in [("INV-001", "Leeds", 1200.10), ("INV-002", "Leeds", 349.90), ("INV-003", "York", 80.00),
            ("INV-004", "York", 15.25), ("INV-005", "Hull", 410.00)]:
    ws.append(row)
ws.append([])
ws.append(["CONTROL", "records", 5])
ws.append(["CONTROL", "amount", 2055.25])
wb.save("invoices.xlsx")

wb = Workbook()
ws = wb.active
ws.append(["branch", "records", "amount"])
for row in [("Leeds", 2, 1550.00), ("York", 2, 95.25), ("Hull", 1, 410.00)]:
    ws.append(row)
wb.save("branch_controls.xlsx")

Step 1 — Separate the control rows from the data

Control rows share the sheet with the data, so they must be taken out before any calculation — otherwise the control amount is summed into the total it is meant to check:

Python
raw = pd.read_excel("invoices.xlsx")
is_control = raw["invoice"].eq("CONTROL")
controls = raw.loc[is_control].set_index("branch")["amount"]
data = raw.loc[~is_control & raw["invoice"].notna()].copy()
print(controls.to_dict())          # {'records': 5.0, 'amount': 2055.25}
print(len(data))                   # 5

The blank separator row is dropped with the notna filter. If controls come in a trailer file or an email instead, read them from there; the checks that follow are the same. Removing total rows that are not meant as controls — subtotals in a report layout — is covered in Remove Subtotal and Total Rows from an Excel Export.

Step 2 — Compare counts and amounts exactly

Compare the record count directly and the amount in integer cents. Summing floats and testing for equality is fragile in both directions: a correct file can fail by a rounding fraction, and a tolerance loose enough to absorb that can hide a genuine one-cent error:

Python
def cents(value):
    return int(round(float(value) * 100))

actual_count = len(data)
actual_cents = sum(cents(v) for v in data["amount"])
checks = {
    "records": (int(controls["records"]), actual_count),
    "amount_cents": (cents(controls["amount"]), actual_cents),
}
for name, (expected, actual) in checks.items():
    print(f"{name}: expected {expected}, actual {actual}, {'OK' if expected == actual else 'MISMATCH'}")
# records: expected 5, actual 5, OK
# amount_cents: expected 205525, actual 205525, OK

Converting each value to cents before summing — rather than summing floats and converting the result — keeps every addition exact. For currencies with other minor units, or amounts with more than two decimals, use decimal.Decimal with the precision the source system uses.

Step 3 — Reconcile per group

A matching grand total can hide two offsetting errors: 100 too much in Leeds and 100 too little in York. Per-group controls catch that. Aggregate the data the same way and compare with an outer merge, so a branch missing from either side shows up too:

Python
branch_controls = pd.read_excel("branch_controls.xlsx")
actual = data.groupby("branch").agg(records=("invoice", "size"), amount=("amount", "sum")).reset_index()
recon = branch_controls.merge(actual, on="branch", how="outer",
                              suffixes=("_control", "_actual"), indicator=True)
recon["records_diff"] = recon["records_actual"] - recon["records_control"]
recon["amount_diff"] = (recon["amount_actual"] - recon["amount_control"]).round(2)
print(recon[["branch", "records_diff", "amount_diff", "_merge"]].values.tolist())
# [['Hull', 0, 0.0, 'both'], ['Leeds', 0, 0.0, 'both'], ['York', 0, 0.0, 'both']]

Every branch reconciles. A branch that appears only in the controls means its rows never arrived; one that appears only in the data means rows were assigned to a branch the source does not know. The differences are rounded to cents for display; the pass/fail decision should still use the exact cent comparison from Step 2.

What each level of control catches A record count catches lost or duplicated rows. An amount total also catches changed values. Per-group totals catch errors that cancel out across groups. A hash total of invoice numbers catches a record swapped for another with the same amount. Grand totals lost or duplicated rows changed amounts offsetting errors swapped records Per-group + hash totals errors that cancel out rows in the wrong group swapped records needs richer controls

Step 4 — Reconcile at every stage

Controls prove the import. Every later step that can change rows — filters, merges, deduplication — needs the same discipline: for each stage, input must equal output plus rejected. Keep a small ledger as the pipeline runs:

Python
ledger = []

def stage(name, before, after, rejected):
    ledger.append({"stage": name, "in": len(before), "out": len(after), "rejected": len(rejected),
                   "in_cents": sum(map(cents, before["amount"])),
                   "out_cents": sum(map(cents, after["amount"])),
                   "rejected_cents": sum(map(cents, rejected["amount"]))})

keep = data["amount"] >= 20
valid, small = data[keep], data[~keep]
stage("drop amounts under 20", data, valid, small)

report = pd.DataFrame(ledger)
report["balanced"] = (report["in"] == report["out"] + report["rejected"]) & \
                     (report["in_cents"] == report["out_cents"] + report["rejected_cents"])
print(report[["stage", "in", "out", "rejected", "balanced"]].values.tolist())
# [['drop amounts under 20', 5, 4, 1, True]]

The ledger turns "the report total is lower than the export" into "stage 2 rejected one invoice worth 15.25", which is an answer rather than an investigation. Merges deserve the same treatment; the checks for them are in Validate a pandas Merge to Avoid Duplicate Rows.

Every stage must balance The import is checked against the source's control totals. Each later stage records rows and cents in, out and rejected, and passes only if input equals output plus rejected. The final output is compared with the control totals minus everything rejected along the way. Import vs control totals Filter in = out + rejected Merge rows unchanged Aggregate sum preserved Report = controls − rejected

Step 5 — Add a hash total

Counts and amounts cannot detect a record replaced by another with the same amount — INV-003 swapped for INV-033, both 80.00. A hash total can. If the source system provides one, recompute it the same way; if not, compute one yourself at import and carry it through the pipeline:

Python
import hashlib

def hash_total(ids):
    return sum(int("".join(filter(str.isdigit, i))) for i in ids)

def id_digest(ids):
    return hashlib.sha256("\n".join(sorted(ids)).encode()).hexdigest()[:12]

print(hash_total(data["invoice"]))            # 15
print(id_digest(data["invoice"]))
swapped = data["invoice"].replace({"INV-003": "INV-033"})
print(hash_total(swapped), id_digest(swapped) != id_digest(data["invoice"]))   # 45 True

The numeric hash total — the sum of the invoice numbers — is the traditional form, easy for a source system to produce. The SHA-256 digest of the sorted IDs is stronger: any change to the set of IDs changes it. Keep the digest in the run log; comparing two runs' digests shows at once whether the same set of records was processed.

Step 6 — Report and stop on a mismatch

Write the reconciliation next to the output so it travels with the report, and stop the run when any check fails:

Python
summary = pd.DataFrame([{"check": k, "expected": e, "actual": a, "ok": e == a} for k, (e, a) in checks.items()])
with pd.ExcelWriter("reconciliation.xlsx") as writer:
    summary.to_excel(writer, sheet_name="Controls", index=False)
    recon.drop(columns="_merge").to_excel(writer, sheet_name="By branch", index=False)
    report.to_excel(writer, sheet_name="Stages", index=False)
if not summary["ok"].all() or not report["balanced"].all():
    raise SystemExit("reconciliation failed - see reconciliation.xlsx")
print("reconciled")

A report that ships with its own reconciliation sheet answers the reader's first question before they ask it. Sending an alert when the check fails is covered in Send an Alert When an Excel Report Job Fails.

Common pitfalls

  • Summing the control rows into the data. Separate them first, or the total checks itself.
  • Tolerances on money. A tolerance of 0.01 hides one-cent errors. Compare integer cents exactly.
  • Controls that describe a different extract. A control total computed before the source applied its own filters — cancelled invoices, test accounts — will never match. Confirm with the system owner exactly which records the controls cover.
  • Trusting controls typed by hand. A control total someone copied into the sheet is a second data entry, not a proof. Prefer figures generated by the source system itself.
  • Reconciling only the final output. A mismatch at the end tells you something is wrong but not where. Balance every stage.

Conclusion

Control totals turn "the report looks right" into a proof. Separate control rows from the data, compare counts exactly and amounts in integer cents, reconcile per group with an outer merge so missing groups surface, keep a ledger showing that each stage's input equals output plus rejected, and add a hash total or ID digest to catch swapped records. Ship the reconciliation sheet with the report and stop the run when anything fails to balance.

Frequently asked questions

What is a control total? A figure computed by the source system when it produces a file — usually the number of records and the sum of an amount column — and sent with the data. Recomputing it after loading proves that nothing was lost, duplicated or altered on the way.

Why compare money in cents instead of floats? Floating-point sums of decimal amounts can differ from the exact total by a tiny fraction, so an equality test can fail on correct data or a loose tolerance can hide a real one-cent difference. Converting to integer cents makes the comparison exact.

What is a hash total? A sum or checksum of a column that has no business meaning, such as invoice numbers. It catches a record that was swapped for another with the same amount, which a count and amount total would miss.

Where in a pipeline should I reconcile? At every stage that can change row counts — after reading, after filtering, after each merge. Record input, output and rejected counts so that input always equals output plus rejected.

More on validation & data quality

More on validation & data quality →