Data Analysis with Python (pandas, polars, DuckDB) Module 15 – Data-quality checks you can automate
Reconciliation, audit trails and reproducibility
Reconcile rows, keys and totals between pipeline stages, keep an audit trail of every removed and changed row, and make each run reproducible with a run log.
What you will learn
- Compare row counts, distinct keys and totals between source, intermediate and final tables
- Record an audit trail of removed and changed rows with the step and the reason
- Build a reproducible analysis with seeds, pinned versions, input hashes and a run log
- Check a pipeline by running it again from a clean start and comparing the outputs
Before you start
What you’ll learn · Before you start
What you will learn
- Compare row counts, distinct keys and totals between source, intermediate and final tables
- Record an audit trail of removed and changed rows with the step and the reason
- Build a reproducible analysis with seeds, pinned versions, input hashes and a run log
- Check a pipeline by running it again from a clean start and comparing the outputs
Before you start
On this page
The original sixteen-order pipeline loses six rows, but its named rules initially explain only four. Reconciliation records rows, keys and totals at each stage; a row audit trail explains specific exclusions and changes. This lesson keeps that original pipeline and the real-month source-overlap analysis, then compares count accounting, identity, run logs and the declared fingerprint policy used for repeat runs.
Each stage accounts for the rows it changes
Reconciliation proves that the numbers in a final table can be traced back to their source. For every stage of a pipeline you count the rows, the distinct keys and the total of an additive column such as revenue, and every difference between two stages is explained by a named rule with a count. An audit trail keeps the rows each rule removed and the values each step changed, with the step and the reason, so that any single row can be accounted for. Reproducibility means that the same inputs, code and versions give the same outputs, and a run log records which inputs, code and versions a run used. Together they answer the three questions every report meets sooner or later: do the numbers add up, where did this row go, and would we get the same answer again?
The habit is small: after every step, record the row count, the number of distinct keys and the total, and have each
rule count the rows it removes. A step’s unexplained delta: A signed stage change remaining after the declared rule's accounted change is subtracted under a fixed reconciliation convention. is its dropped rows minus the rows its rule removed. That delta should be zero under the stated removal contract. Anything else is
unexplained, and an unexplained difference is a bug until someone explains it. Here a week of 16 orders goes
through four steps, chained with DataFrame.pipe:
week/reconcile_week.py
"""A week of orders through four steps, with a reconciliation table that explains every row that leaves."""
import pandas as pd
orders = pd.read_csv("orders.csv")
stores = pd.read_csv("stores.csv")
def drop_repeats(df):
repeat = df.duplicated()
return df[~repeat], int(repeat.sum()) # the rows a rule removes, counted by the rule itself
def exclude_cancelled(df):
cancelled = df["status"].eq("cancelled")
return df[~cancelled], int(cancelled.sum())
def add_region(df):
return df.merge(stores, on="store"), 0 # adds a column; no rule removes rows here
def keep_sales(df):
returned = df["status"].eq("returned")
return df[~returned], int(returned.sum())
def stage(df, name, by_rule=0):
return {"stage": name, "rows": len(df), "orders": df["order_id"].nunique(), "amount": df["amount"].sum(), "by rule": by_rule}
table = [stage(orders, "source")]
df = orders
for step in [drop_repeats, exclude_cancelled, add_region, keep_sales]:
df, by_rule = df.pipe(step)
table.append(stage(df, step.__name__, by_rule))
recon = pd.DataFrame(table)
recon["dropped"] = (-recon["rows"].diff()).fillna(0).astype(int)
recon["unexplained"] = recon["dropped"] - recon["by rule"]
print(recon.to_string(index=False))
lost = recon[recon["unexplained"] != 0]
for row in lost.itertuples():
print(f"\n{row.stage} lost {row.unexplained} rows that no rule removed") Output
stage rows orders amount by rule dropped unexplained
source 16 15 17040.0 0 0 0
drop_repeats 15 15 15540.0 1 1 0
exclude_cancelled 13 13 14100.0 2 2 0
add_region 11 11 12160.0 0 2 2
keep_sales 10 10 11520.0 1 1 0
add_region lost 2 rows that no rule removed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reconcile_week.py
week/orders.csv
order_id,store,status,amount
A-1001,S1,delivered,1250.00
A-1002,S2,delivered,480.00
A-1003,S1,cancelled,990.00
A-1004,S3,delivered,2100.00
A-1005,S4,delivered,760.00
A-1006,S2,delivered,1500.00
A-1006,S2,delivered,1500.00
A-1007,S3,returned,640.00
A-1008,S1,delivered,330.00
A-1009,S4,delivered,1180.00
A-1010,S2,cancelled,450.00
A-1011,S3,delivered,870.00
A-1012,S1,delivered,2400.00
A-1013,S2,delivered,560.00
A-1014,S3,delivered,1320.00
A-1015,S1,delivered,710.00 week/stores.csv
store,region
S1,West
S2,South
S3,North A reconciliation table as a ladder
Text description of the diagram
The source holds 16 rows, 15 distinct orders and an amount of 17,040.
- drop_repeats removes 1 exact repeat: 15 rows, 15 orders, 15,540.
- exclude_cancelled removes 2 cancelled orders: 13 rows, 13 orders, 14,100.
- add_region joins the stores table: 11 rows, 11 orders, 12,160. No rule removes rows here, yet 2 rows are lost, because store S4 has no region in the stores table.
- keep_sales removes 1 returned order: 10 rows, 10 orders, 11,520.
The 4 rows removed by rules go to the audit trail, each with its step and reason. The 2 rows lost in the join have no reason, which is what the reconciliation table flags.
Six rows leave between the source and the final table, and the rules explain four of them: one exact repeat, two cancelled orders and one return. The join that adds each store’s region was not meant to remove anything, yet it lost two rows, because store S4 is missing from the stores table and an inner join drops orders without a partner. The two orders were worth 1,940, so the week’s sales would have been reported as 11,520 instead of 13,460, and every other line of the report would have looked right. The table also shows what a correct join looks like: the same rows, the same number of orders and the same total before and after.
Compare Two Excel or CSV Files Compare two versions of a table, such as a report and its source extract, row by row in the browser.Use the original 16 rows and two lost join rows to locate the unexplained stage.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original source contains16 rows and 15 distinct order keys.
- One repeat, two cancellations and one return explain four of the six rows absent from the final result.
- The original inner join loses two store rows that its rule did not intend to remove. The result table identifies the stage instead of hiding the difference.
Checkpoint
Check: Each stage accounts for the rows it changes
Counts, row history and repeatability provide different evidence
A reconciliation table says how many rows each step removed; row audit trail: Retained row-level evidence of exclusions or changed values, with the responsible stage and reason under a stated identity and retention policy. say which rows and why. Audit trails keep the removed rows with the step and the reason, and every changed value with its old and new value, so that a question about one order becomes a lookup. Reproducibility is what lets anyone, including you in six months, re-run the analysis and get the same numbers. It needs the same input files, proven by their hashes, the same package versions, fixed random seeds and a log of the run. The UK government’s analysis profession makes reproducible analytical pipelines its default way of working and lists the minimum practices.
| Item | Details |
|---|---|
| Reconciliation | The question it answers: Do the numbers add up from source to report?; What you keep: Rows, keys and totals after every step, with a rule for every change |
| Audit trail | The question it answers: Where did this row go, and why does it differ?; What you keep: Removed rows and changed values, each with its step and reason |
| Reproducibility | The question it answers: Would a second run give the same answer?; What you keep: Input hashes, versions, seeds and parameters in a run log |
The parts below apply all three to real data. A full reconciliation of one month of the UCI Online Retail II data shows how two overlapping source sheets can inflate revenue. The count invariant, rows in equals rows out plus rows in the trail, detects the demonstrated unaccounted drop. Exact source-row accounting also needs row identity or an equivalent multiset check. A run-and-compare check re-runs the pipeline from a clean start and compares the outputs.
Keep counts, row identities and repeatability evidence separate when reading the original pipeline.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The stage table records how many rows and keys exist and how totals change under each rule.
- The trail records which rows were removed or altered, with reasons and before/after values.
- Inputs, versions, parameters and output comparison policy provide evidence about repeating the declared run. Hashes and rounded fingerprints have specific limits.
Checkpoint
Check: Counts, row history and repeatability provide different evidence
The exercise reports unexplained losses and additions
Preserve the original input rows while returning stage summaries and signed differences. An added row makes the dropped count negative; clamping it to zero hides the change. Locate the first nonzero unexplained delta, including a negative one. Keep the row and key definitions unchanged and compare the summary with the untouched source frame.
Exercise · Medium · Python
Reconcile a pipeline and find the step that loses rows
A pipeline is a list of steps. Each step is a pair (name, function), and function(rows) returns two things: the rows after the step, and the number of rows its own rule removed (0 for a step, such as a join, that is not meant to remove anything). Rows are dictionaries of text read from a CSV file, and a value is missing when it is empty or holds only spaces. Write three functions in recon.py.
stage_stats(rows, key, amount)returns{"rows": ..., "keys": ..., "total": ...}: the number of rows, the number of distinct non-missing values ofkey, and the sum of the non-missing values ofamount, converted withfloat()and rounded to 2 decimal places.reconcile(rows, steps, key, amount)returns the reconciliation table as a list of dictionaries: first the source, then one entry per step, in order. Each entry has"stage"(the name, or"source"), the three numbers ofstage_statsfor the rows after that stage,"dropped"(rows before the step minus rows after it, so a step that adds rows has a negative value) and"unexplained"(the dropped rows minus the rows its rule removed). The source has 0 for both.first_unexplained(table)returns the name of the first stage whose"unexplained"is not 0, orNonewhen every change is explained.
Do not change the rows you are given. The sample tests run a small pipeline in which one step loses rows that no rule removed, and another in which a step adds rows.
Starter code · recon.py
def missing(value):
"""A cell read from a CSV is missing when it is empty or holds only spaces."""
return value is None or str(value).strip() == ""
def stage_stats(rows, key, amount):
# The number of rows, the number of distinct non-missing keys, and the total of the non-missing amounts
# (the amounts are text: convert them with float), rounded to 2 decimal places.
return {"rows": 0, "keys": 0, "total": 0}
def reconcile(rows, steps, key, amount):
# One dictionary for the source, then one per step: its stage_stats, the rows it dropped, and the dropped rows
# that its rule does not explain. Each step is a pair (name, function); function(rows) returns
# (rows_after, rows_removed_by_its_rule).
return [{"stage": "source", **stage_stats(rows, key, amount), "dropped": 0, "unexplained": 0}]
def first_unexplained(table):
# The name of the first stage whose "unexplained" is not 0, or None.
return None The sample tests · test_recon.py
from recon import first_unexplained, reconcile, stage_stats
ORDERS = [
{"order_id": "B-1", "store": "S1", "status": "delivered", "amount": "1250.00"},
{"order_id": "B-2", "store": "S2", "status": "delivered", "amount": "480.50"},
{"order_id": "B-2", "store": "S2", "status": "delivered", "amount": "480.50"},
{"order_id": "B-3", "store": "S5", "status": "delivered", "amount": "760.00"},
{"order_id": "B-4", "store": "S1", "status": "cancelled", "amount": "990.00"},
{"order_id": "B-5", "store": "S3", "status": "delivered", "amount": ""},
{"order_id": "", "store": "S3", "status": "delivered", "amount": "100.00"},
{"order_id": "B-6", "store": "S5", "status": "delivered", "amount": "1180.25"},
]
REGIONS = {"S1": "West", "S2": "South", "S3": "North"}
def drop_repeats(rows):
seen, out = set(), []
for row in rows:
marker = tuple(sorted(row.items()))
if marker not in seen:
out.append(row)
seen.add(marker)
return out, len(rows) - len(out)
def exclude_cancelled(rows):
out = [r for r in rows if r["status"] != "cancelled"]
return out, len(rows) - len(out)
def add_region(rows):
"""Looks up each store's region: an inner join, so stores without a region disappear."""
return [{**r, "region": REGIONS[r["store"]]} for r in rows if r["store"] in REGIONS], 0
def one_row_per_item(rows):
"""A fan-out: every order of S1 is split into two items, so rows are added."""
out = []
for r in rows:
out.extend([r, {**r, "amount": "0"}] if r["store"] == "S1" else [r])
return out, 0
def test_stage_stats():
"""counts rows, distinct non-missing keys and the total of the non-missing amounts"""
assert stage_stats(ORDERS, "order_id", "amount") == {"rows": 8, "keys": 6, "total": 5241.25}
assert stage_stats([], "order_id", "amount") == {"rows": 0, "keys": 0, "total": 0}
def test_table_rows_and_columns():
"""has one row for the source and one for each step, in order"""
table = reconcile(ORDERS, [("drop_repeats", drop_repeats), ("exclude_cancelled", exclude_cancelled)], "order_id", "amount")
assert [r["stage"] for r in table] == ["source", "drop_repeats", "exclude_cancelled"]
assert table[0] == {"stage": "source", "rows": 8, "keys": 6, "total": 5241.25, "dropped": 0, "unexplained": 0}
assert table[1] == {"stage": "drop_repeats", "rows": 7, "keys": 6, "total": 4760.75, "dropped": 1, "unexplained": 0}
assert table[2] == {"stage": "exclude_cancelled", "rows": 6, "keys": 5, "total": 3770.75, "dropped": 1, "unexplained": 0}
def test_finds_the_join_that_loses_rows():
"""flags the step whose dropped rows no rule explains"""
steps = [("drop_repeats", drop_repeats), ("exclude_cancelled", exclude_cancelled), ("add_region", add_region)]
table = reconcile(ORDERS, steps, "order_id", "amount")
assert table[3] == {"stage": "add_region", "rows": 4, "keys": 3, "total": 1830.5, "dropped": 2, "unexplained": 2}
assert first_unexplained(table) == "add_region"
def test_rows_added_are_unexplained_too():
"""a step that adds rows has a negative drop, which no rule explains either"""
table = reconcile(ORDERS, [("one_row_per_item", one_row_per_item)], "order_id", "amount")
assert table[1]["rows"] == 10 and table[1]["dropped"] == -2 and table[1]["unexplained"] == -2
assert first_unexplained(table) == "one_row_per_item"
def test_clean_pipeline():
"""returns None when every change is explained, and keeps the input rows unchanged"""
before = [dict(r) for r in ORDERS]
table = reconcile(ORDERS, [("drop_repeats", drop_repeats)], "order_id", "amount")
assert first_unexplained(table) is None
assert ORDERS == before A hint
Build the source entry first, then loop over the steps. Before calling a step, remember len(rows); call rows, by_rule = step(rows) and compute dropped = before - len(rows) and unexplained = dropped - by_rule. In stage_stats, collect the keys in a set, skipping missing ones, and add up float(row[amount]) only where the amount is not missing.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Retain the original five tests, including the negative-drop fan-out and unchanged input rows.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original exercise input has eight rows and six distinct non-missing keys.
- The original tests check each stage's row count, distinct keys and total while accounting for its stated removal rule.
- The original fan-out adds two rows, so dropped is negative and unexplained remains negative. Do not clamp additions to zero.
Checkpoint
Check: The exercise reports unexplained losses and additions
One real month of Online Retail II, reconciled row by row Pro: opens with a Pro, Premium or Ultimate pass
Real data hides its biggest problems in the source files themselves. This part reconciles one month of the UCI Online Retail II workbook from the 65,004 rows read to the analysis table, with the rows, invoices, customers and revenue of every step.
In this part:
- Count each source before stacking overlapping sheets
- Every removed group needs its own count and meaning
- Report a result with the exclusions that define it
3 diagrams · 1 runnable example (reconcile_december.py, steps.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Audit trails: every removed row and changed value, with its reason Pro: opens with a Pro, Premium or Ultimate pass
An audit trail is only useful if it is complete, and the commonest way to lose completeness is a filter that removes more than its rule says. This part keeps the removed rows and changed values of a pipeline, checks the trail with an invariant, and answers the auditor’s question in one lookup.
In this part:
- A complete trail needs row identity as well as counts
1 diagram · 1 runnable example (audit_trail.py, orders.csv) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Reproducible runs: input hashes, versions, seeds and a second run that must agree Pro: opens with a Pro, Premium or Ultimate pass
A reproducible run is one you can prove you could repeat. This part writes a run log for the December pipeline, re-runs the pipeline from a clean start and compares the outputs, and reproduces three ways in which a second run differs from the first.
In this part:
- A run log records the inputs and comparison policy
- Reproducibility depends on more than a seed
- Keep code, validation and audit evidence together
3 diagrams · 1 runnable example (Python) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise the original quality contracts
Practice that adapts to you
Practise this lesson
6 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher · Warm-up
What is a reconciliation table, and what goes in each row?
A reconciliation table follows data through a pipeline, with one row per stage: the source, then each step, then the final table. Each row holds the number of rows, the number of distinct keys, such as order ids, and the total of an additive column, such as revenue, after that stage, together with the rows the step removed and the rows its rule accounts for. A filter's rule should explain every row it drops, and a join that only adds columns should leave rows, keys and totals unchanged. Any difference that no rule explains is a bug until someone explains it, for example orders lost in an inner join because a lookup table is missing a value. The table is cheap to build with a small helper called after every step, and it turns "the numbers look right" into a check.
-
Mid to senior
An auditor picks one order from the source system and asks why it is not in your quarterly revenue report. What must you have kept to answer within minutes, and how do you answer?
-
Mid to senior
A scheduled job's output file gets a different SHA-256 hash every night, even when its input files are byte for byte the same. How do you tell whether the results really changed, and how do you make the run reproducible?
-
Senior to staff
For the same month, the CRM reports 48,210 active customers, billing reports 46,955 and your analysis 47,388. Leadership wants one number by the end of the week. Plan the reconciliation: what do you compare, in what order, and what do you report?
Key takeaways
- Record rows, distinct keys and the total after every step, and let every rule count what it removes; any difference without a rule is unexplained.
- A step that only adds columns, such as a lookup join, must leave rows, keys and totals unchanged.
- An audit trail keeps removed rows and changed values with the step and reason, so one row’s history is a lookup.
- Reproducibility needs the same inputs, versions and seeds, recorded in a run log next to the report.
Terms in this lesson
- fingerprint policy
- The canonical representation hashed for result comparison, including its ordering, type and numerical precision choices.
- identity accounting
- A check that the required source row identities or row multiset are accounted for in the output and removal trail, beyond equal counts alone.
- row audit trail
- Retained row-level evidence of exclusions or changed values, with the responsible stage and reason under a stated identity and retention policy.
- unexplained delta
- A signed stage change remaining after the declared rule's accounted change is subtracted under a fixed reconciliation convention.
References
- pandas.DataFrame.pipe (pandas development team (NumFOCUS))
- pandas.DataFrame.sample (pandas development team (NumFOCUS))
- hashlib: secure hashes and message digests (file_digest) (Python Software Foundation)
- Command line and environment: PYTHONHASHSEED (Python Software Foundation)
- NumPy: Random Generator (NumPy developers)
- Reproducible Analytical Pipelines (RAP) strategy (Government Analysis Function (UK))
- Online Retail II (UCI Machine Learning Repository), CC BY 4.0 (University of California, Irvine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress