Back to the lesson·Lesson 2 of 8·Joins you can prove
Proving the join did what you said
The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.
What this lesson covers
- The check has to be cheaper than the bug
- Declare the relationship, always
- The reconciliation table
- Guard the row count when you meant to preserve it
- The unmatched rate is an indicator, not an error
- Normalise the key before you blame the join
- The suffix collision
- Keys with different names
- What comes next
Speaker notes
A reconciliation table from every join — matched rows, both anti-join counts, the row count before and after — plus the suffix collision that overwrites a column without telling you.The check has to be cheaper than the bug
- Nobody skips join checks because they think joins are safe.
Speaker notes
Nobody skips join checks because they think joins are safe. They skip them because checking takes four commands, the output has to be read, and the join "obviously" worked. So the check has to become one call that produces one small table. Write it once, put it in the file every script imports, and the cost of checking drops below the cost of wondering.Declare the relationship, always — In Python
joined = attendance.merge( roster, on="student_id", how="left", validate="many_to_one", )Speaker notes
The first line of defence is an argument you were already able to pass.Declare the relationship, always — In R
joined <- attendance |> left_join(roster, by = "student_id", relationship = "many-to-one")Declare the relationship, always
You expect pandas validate=dplyr relationship=One row each side one_to_oneone-to-oneMany left, one right many_to_onemany-to-oneOne left, many right one_to_manyone-to-manyGenuinely many-to-many omit many-to-manySpeaker notes
Four values, and choosing between them forces you to say what you believe:Declare the relationship, always
- dplyr warns on an unexpected many-to-many even without the argument — pandas does not
Speaker notes
dplyr warns on an unexpected many-to-many even without the argument; pandas does not. That difference has decided a number of quietly wrong figures, and it is the reason the Python examples in this course always passvalidate=. Note the last row. Passingmany-to-manyexplicitly is not a way of silencing the check — it is a claim that the fan-out is intended, and it belongs beside a comment saying what the resulting grain is.The reconciliation table — In Python (cont.)
def reconcile(left, right, on, left_name="left", right_name="right"): left_keys = left[on].drop_duplicates() right_keys = right[on].drop_duplicates() merged = left_keys.merge(right_keys, on=on, how="outer", indicator=True) counts = merged["_merge"].value_counts() return pd.Series({ f"{left_name} rows": len(left), f"{right_name} rows": len(right), "keys matched": int(counts.get("both", 0)), f"{left_name} only": int(counts.get("left_only", 0)), f"{right_name} only": int(counts.get("right_only", 0)), f"{left_name} unmatched %": round( 100 * counts.get("left_only", 0) / max(len(left_keys), 1), 2 ), })Speaker notes
The relationship argument tells you the shape was right. It does not tell you what failed to match, and that is usually the more interesting question.The reconciliation table — In Python (cont.)
print(reconcile(attendance, roster, ["student_id"], "attendance", "roster"))The reconciliation table — In R (cont.)
reconcile <- function(left, right, on, left_name = "left", right_name = "right") { left_keys <- dplyr::distinct(dplyr::select(left, dplyr::all_of(on))) right_keys <- dplyr::distinct(dplyr::select(right, dplyr::all_of(on))) tibble::tibble( measure = c(paste(left_name, "rows"), paste(right_name, "rows"), "keys matched", paste(left_name, "only"), paste(right_name, "only")), value = c( nrow(left), nrow(right), nrow(dplyr::inner_join(left_keys, right_keys, by = on)), nrow(dplyr::anti_join(left_keys, right_keys, by = on)), nrow(dplyr::anti_join(right_keys, left_keys, by = on)) ) ) }The reconciliation table — In R (cont.)
reconcile(attendance, roster, "student_id", "attendance", "roster")The reconciliation table
measure value attendance rows 70,245 roster rows 1,200 keys matched 1,200 attendance only 0 roster only 0 Speaker notes
Note it reconciles the distinct keys, not the rows. Comparing row counts across a one-to-many join tells you nothing; comparing the key sets tells you exactly who is on one side and not the other. That is the table to paste into a notebook cell above every join. Four seconds to read, and it makes the two silent failure modes impossible to miss.Guard the row count when you meant to preserve it — In Python
def join_preserving(left, right, on, how="left", **kwargs): before = len(left) out = left.merge(right, on=on, how=how, **kwargs) if len(out) != before: raise ValueError(f"join changed row count: {before} -> {len(out)}") return outGuard the row count when you meant to preserve it — In R
join_preserving <- function(left, right, by, ...) { before <- nrow(left) out <- dplyr::left_join(left, right, by = by, ...) if (nrow(out) != before) { stop(sprintf("join changed row count: %d -> %d", before, nrow(out))) } out }Speaker notes
This is strictly stronger thanvalidate=, because it also catches the case where the key is unique on both sides but the left table lost rows — which happens the moment someone changeshow="left"tohow="inner"while debugging something else and does not change it back.The unmatched rate is an indicator, not an error — In Python
summary = reconcile(distributions, registration, ["beneficiary_id"]) if summary["left unmatched %"] > 5: print(f"WARNING: {summary['left unmatched %']}% of distribution rows " "have no registration record")Speaker notes
A join that fails to match 6% of a beneficiary list is telling you something about registration, not about your code. Report it.The unmatched rate is an indicator, not an error — In R
unmatched <- nrow(anti_join(distributions, registration, by = "beneficiary_id")) share <- unmatched / nrow(distributions) if (share > 0.05) warning(sprintf("%.1f%% of distributions have no registration", 100 * share))Speaker notes
Track the number across rounds and it becomes a data quality trend that belongs in a monthly report — the same move as the validation-finding series in the cleaning course. A match rate that falls from 98% to 91% between two rounds is a registration process that changed, and it is far more useful to know that in March than to discover it in an audit.Normalise the key before you blame the join
- Type. A facility code read as an integer on one side and a string on the other.
1042never equals"1042", and… - Whitespace and case.
"FAC001 "and"fac001"are three different keys. - Dates against date-times.
2024-03-01and2024-03-01 00:00:00compare equal in some libraries and not others; a…
Speaker notes
A large share of "the join did not work" is a key that does not compare equal:- Type. A facility code read as an integer on one side and a string on the other.
Normalise the key before you blame the join — In Python
def key_clean(series): return series.astype("string").str.strip().str.upper() for frame in (registers, aggregates): frame["facility_id"] = key_clean(frame["facility_id"])Normalise the key before you blame the join — In R
key_clean <- function(x) toupper(stringr::str_squish(as.character(x))) registers <- registers |> mutate(facility_id = key_clean(facility_id)) aggregates <- aggregates |> mutate(facility_id = key_clean(facility_id))Normalise the key before you blame the join
- Do this to both sides in the same function — so they cannot drift
Speaker notes
Do this to both sides in the same function, so they cannot drift. Two separate cleaning expressions is the same defect as two separate indicator calculations.The suffix collision — In Python
merged = registers.merge(aggregates, on="facility_id", suffixes=("_register", "_dhis2"))Speaker notes
Both tables have a column calledupdated_at, ordistrict, ornotes. The join does not fail; it renames.The suffix collision — In R
merged <- registers |> left_join(aggregates, by = "facility_id", suffix = c("_register", "_dhis2"))The suffix collision — In Python
overlap = (set(registers.columns) & set(aggregates.columns)) - {"facility_id"} print("columns in both:", sorted(overlap))Speaker notes
Both libraries default to something unhelpful —_xand_yin pandas,.xand.yin dplyr — and three joins later nobody can say whetherdistrict_xcame from the register or the aggregate. Name the suffixes after the source, every time. It costs one argument and it is the difference between a traceable table and a guess. The worse version is a shared column you did not know about, silently carried through the join and then used. Check before joining:The suffix collision — In R
setdiff(intersect(names(registers), names(aggregates)), "facility_id")Keys with different names — In Python
merged = cases.merge( facilities, left_on="site_code", right_on="facility_id", how="left", validate="many_to_one", )Speaker notes
Say so explicitly rather than renaming a column to make the join work — the rename outlives the join and confuses the next reader.Keys with different names — In R
merged <- cases |> left_join(facilities, by = c("site_code" = "facility_id"), relationship = "many-to-one")What comes next
- Every check so far has assumed you know what one row of each table is.
Speaker notes
Every check so far has assumed you know what one row of each table is. That assumption is where the remaining join failures live: a table of households joined to a table of people, or a monthly aggregate joined to a daily register. The next lesson is about naming the grain — and aggregating to it before the join, rather than discovering afterwards that you multiplied.