Fix pandas merge Duplicating Rows
Enriching orders with customer details doubles the data:
>>> orders.shape
(4012, 8)
>>> enriched = orders.merge(customers, on="customer_ref", how="left")
>>> enriched.shape
(9617, 14)
>>> enriched["amount"].sum(), orders["amount"].sum()
(41822190.4, 18904221.55)
Revenue is now 2.2 times the real figure, the customer count in the report is inflated, and the sales dashboard built on the merged frame shows a record month.
Root Cause
merge produces one output row for every matching pair of input rows. When the lookup side has more than one row per key — two records for the same customer because the address changed, a contacts table with one row per contact person, a price list with one row per validity period — every order matching that key is repeated once per lookup row. pandas does not warn: a left join promises "all left rows", not "exactly one output row per left row", so duplication is normal join behaviour rather than an error. The result is doubly damaging because it looks right: the columns are correct, the rows look like real orders, and only aggregate figures reveal the problem. Keys that are almost-but-not-quite unique — a customer table with a stale duplicate for 12 of 40,000 customers — inflate totals slightly, which is harder to notice than a doubling.
Minimal Diagnostic
Check key uniqueness on both sides before merging, and predict the output size from the key multiplicities.
# pip install "pandas>=2.2"
import pandas as pd
def merge_preview(left: pd.DataFrame, right: pd.DataFrame, on: str | list[str]) -> None:
keys = [on] if isinstance(on, str) else list(on)
left_counts = left.groupby(keys, dropna=False).size()
right_counts = right.groupby(keys, dropna=False).size()
left_dupes = left_counts[left_counts > 1]
right_dupes = right_counts[right_counts > 1]
matched = left_counts.index.intersection(right_counts.index)
predicted = int((left_counts.reindex(matched) * right_counts.reindex(matched)).sum())
unmatched_left = int(left_counts.drop(matched, errors="ignore").sum())
print(f"left rows {len(left):,}, right rows {len(right):,}")
print(f"left keys duplicated : {len(left_dupes):,} (max {int(left_dupes.max()) if len(left_dupes) else 1})")
print(f"right keys duplicated: {len(right_dupes):,} (max {int(right_dupes.max()) if len(right_dupes) else 1})")
print(f"predicted inner rows : {predicted:,}; left-join rows: {predicted + unmatched_left:,}")
if len(right_dupes):
worst = right_dupes.sort_values(ascending=False).head(3)
print("worst duplicated lookup keys:")
print(right[right[keys[0]].isin(worst.index)].sort_values(keys).head(6).to_string(index=False))
if __name__ == "__main__":
orders = pd.read_csv("in/orders.csv", dtype={"customer_ref": "string"})
customers = pd.read_csv("in/customers.csv", dtype={"customer_ref": "string"})
merge_preview(orders, customers, "customer_ref")
left rows 4,012, right rows 4,190
left keys duplicated : 0 (max 1)
right keys duplicated: 812 (max 4)
predicted inner rows : 9,605; left-join rows: 9,617
customer_ref name address_line valid_from
C-1042 Contoso Ltd 1 Old Road 2019-04-01
C-1042 Contoso Ltd 7 New Street 2024-11-01
C-1180 Fabrikam AG 12 Hauptstr. 2021-01-01
C-1180 Fabrikam AG 14 Hauptstr. 2023-06-15
The customer table holds one row per address version. The predicted row count matches what the merge produced, which confirms the diagnosis before any code changes.
Fix: Declare the Expected Cardinality, Then Make It True
validate makes pandas raise when the join is not the shape you expect, turning a silent inflation into an immediate error. Then reduce the lookup table to one row per key, deliberately. Changed lines carry comments.
# pip install "pandas>=2.2"
from pathlib import Path
import pandas as pd
def current_customers(customers: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
"""One row per customer: the version in force at `as_of`."""
valid = customers.assign(valid_from=pd.to_datetime(customers["valid_from"], errors="coerce"))
valid = valid[valid["valid_from"] <= as_of] # changed: ignore future versions
latest = (valid.sort_values(["customer_ref", "valid_from"])
.groupby("customer_ref", as_index=False)
.tail(1)) # changed: one row per key
return latest
def enrich(orders: pd.DataFrame, customers: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame:
lookup = current_customers(customers, as_of)
duplicated = lookup["customer_ref"].duplicated().sum()
if duplicated:
raise ValueError(f"lookup still has {duplicated} duplicate key(s) after de-duplication")
return orders.merge(
lookup, on="customer_ref", how="left",
validate="many_to_one", # changed: raises on 1-to-many
suffixes=("", "_customer"),
)
if __name__ == "__main__":
orders = pd.read_csv(Path("in/orders.csv"), dtype={"customer_ref": "string"})
customers = pd.read_csv(Path("in/customers.csv"), dtype={"customer_ref": "string"})
enriched = enrich(orders, customers, pd.Timestamp("2026-09-30"))
print(enriched.shape, enriched["amount"].sum() == orders["amount"].sum())
(4012, 13) True
validate="many_to_one" states the intent: many orders may share a customer, but each customer must appear once in the lookup. If the lookup ever regains a duplicate — a data load that inserts rather than updates — the merge raises MergeError: Merge keys are not unique in right dataset; not a many-to-one merge instead of inflating the report. Row count and revenue are unchanged from the source, which is the property an enrichment step should always have.
Choosing which duplicate to keep is a business decision, not a technical one. "The version in force at the report date" suits addresses; "the most recently updated row" suits records with an updated_at; "the row with the fewest missing fields" suits merged customer databases. Write the rule explicitly, as current_customers does, rather than relying on drop_duplicates() keeping whichever row happened to be first.
Variant Fix 1: The Duplicates Are Legitimate — Aggregate First
Sometimes the lookup genuinely has several rows per key and all of them matter: contacts per customer, tags per product. Joining them to a fact table is the wrong shape; aggregate them into one row per key first:
# pip install "pandas>=2.2"
import pandas as pd
def contacts_summary(contacts: pd.DataFrame) -> pd.DataFrame:
return (contacts.sort_values(["customer_ref", "is_primary"], ascending=[True, False])
.groupby("customer_ref", as_index=False)
.agg(primary_contact=("name", "first"), # the primary, by sort order
contact_count=("name", "size"),
contact_emails=("email", lambda s: "; ".join(sorted(s.dropna().unique())))))
One row per customer, with the detail collapsed into columns that answer the questions a report asks. If individual contacts are genuinely needed alongside orders, the result is a legitimately larger table — but then it is no longer an orders table, and any revenue figure computed from it must divide by the number of contacts or aggregate before summing. Keeping fact tables at one row per fact avoids that entirely.
Variant Fix 2: Nearly Unique Keys and Hidden Mismatches
A lookup that is unique after cleaning but duplicated before it is the most common real case: C-1042 and c-1042 are two rows. De-duplicate on the normalised key and keep a report of what was collapsed:
# pip install "pandas>=2.2"
import pandas as pd
def dedupe_on_normalised_key(frame: pd.DataFrame, key: str) -> tuple[pd.DataFrame, pd.DataFrame]:
work = frame.copy()
work["_key"] = (work[key].astype("string").str.strip().str.upper()
.str.replace(r"\s+", "", regex=True))
collapsed = (work.groupby("_key")[key].nunique().loc[lambda s: s > 1])
report = work[work["_key"].isin(collapsed.index)].sort_values("_key")
deduped = work.drop_duplicates("_key", keep="first").drop(columns="_key")
return deduped, report
The report is the deliverable as much as the deduplicated frame: two customer records that differ only by whitespace are a master-data problem someone should fix at the source, and a silent merge hides it. The same normalisation applies on the fact side before joining, as covered in fix merge indicator unexpected left_only rows.
Making the Check Automatic
Every enrichment step in a pipeline benefits from the same guard, so wrap it once:
# pip install "pandas>=2.2"
import pandas as pd
def enrich_safely(fact: pd.DataFrame, lookup: pd.DataFrame, on, value_cols=("amount",)) -> pd.DataFrame:
before_rows = len(fact)
before_sums = {c: float(fact[c].sum()) for c in value_cols if c in fact.columns}
merged = fact.merge(lookup, on=on, how="left", validate="many_to_one")
assert len(merged) == before_rows, f"{len(merged)} rows after merge, {before_rows} before"
for col, total in before_sums.items():
assert abs(float(merged[col].sum()) - total) < 0.01, f"{col} total changed during enrichment"
return merged
Three lines of assertion turn every join in the pipeline into a checkpoint. They cost microseconds and catch the class of bug that is otherwise found by an accountant. Log the number of unmatched rows too — a left join that suddenly matches nothing is a different failure with the same silent character.
Verification
Assert on shape and totals, and confirm no key lost or gained rows relative to the fact table.
# pip install "pandas>=2.2"
import pandas as pd
def verify_enrichment(fact: pd.DataFrame, enriched: pd.DataFrame, key: str,
value_col: str = "amount") -> None:
assert len(enriched) == len(fact), f"{len(enriched)} rows, expected {len(fact)}"
assert abs(enriched[value_col].sum() - fact[value_col].sum()) < 0.01, "value total changed"
counts_before = fact.groupby(key, dropna=False).size().sort_index()
counts_after = enriched.groupby(key, dropna=False).size().sort_index()
pd.testing.assert_series_equal(counts_before, counts_after, check_names=False)
unmatched = enriched[enriched.filter(like="_customer").isna().all(axis=1)] \
if any(c.endswith("_customer") for c in enriched.columns) else enriched.iloc[0:0]
share = len(unmatched) / max(len(enriched), 1)
assert share < 0.05, f"{share:.1%} of rows found no lookup match"
print(f"enrichment verified: {len(enriched):,} rows, totals unchanged, {len(unmatched)} unmatched")
Comparing per-key row counts before and after is stronger than comparing totals alone: it catches the case where one key gained rows and another lost them, leaving the sum coincidentally unchanged. The unmatched-share check guards the opposite failure — a join that silently matches nothing after a key format change upstream.
FAQ
What does validate="one_to_one" do?
It requires uniqueness on both sides and raises otherwise. Use it for joins between two reference tables; use many_to_one for enriching facts from a lookup.
Does how="left" prevent duplication?
No. The join type controls which rows are kept, not how many times each is repeated.
Should I just call drop_duplicates() after merging?
Rarely. By then the duplicated rows may differ in the lookup columns, so dropping keeps an arbitrary one. Fix the lookup before the join.
Can merge_asof help for versioned lookups?
Yes — for a lookup with validity dates, pd.merge_asof(orders.sort_values("order_date"), versions.sort_values("valid_from"), left_on="order_date", right_on="valid_from", by="customer_ref") picks the version in force at each order's date, one row per order.
Related
- Merging Multiple Spreadsheets — join types and alignment
- Fix pandas merge Overlapping Columns — suffixes and column collisions
- Remove Duplicate Rows in pandas — deliberate de-duplication rules
- Comparing and Reconciling Spreadsheets — validating joins as part of a reconciliation
Part of Merging Multiple Spreadsheets.