Pandas merge / join
Inner, outer, left, right, and the merge keys that decide whether your join row-explodes.
TL;DR
df1.merge(df2, on="key") joins two DataFrames on a shared key the
same way SQL does. Get the join type right (inner, left, right,
outer) and the key cardinalities right, and you have a clean joined
DataFrame. Get them wrong and you have either silently-dropped rows or
a row-exploded DataFrame that’s larger than either input.
The two questions to answer before every merge:
- What’s the cardinality on each side? One-to-one? One-to-many? Many-to-many? The last one is usually a bug.
- What’s the right join type?
inner(only rows with the key in both),left(keep all left rows, NaN where right is missing), etc.
Use validate="..." to assert your assumption — Pandas will raise if
the data violates it. This single argument prevents most merge bugs.
joined = users.merge(orders, on="user_id", how="left", validate="one_to_many")
That validate says “I expect each user_id to appear at most once in
users and many times in orders.” If it doesn’t, you get an error
instead of silent corruption.
The picture in your head
A merge is a SQL join. Pick the rows from each side based on the key, glue together the matching ones, decide what to do with non-matches.
users: orders:
user_id name user_id amount
1 alice 1 10
2 bob 1 20
3 carol 2 5
4 99 # no matching user
users.merge(orders, on="user_id", how="inner"):
user_id name amount
1 alice 10
1 alice 20 # alice's row duplicated for each of her orders
2 bob 5
users.merge(orders, on="user_id", how="left"):
user_id name amount
1 alice 10
1 alice 20
2 bob 5
3 carol NaN # carol kept; no orders -> NaN
users.merge(orders, on="user_id", how="outer"):
user_id name amount
1 alice 10
1 alice 20
2 bob 5
3 carol NaN
4 NaN 99 # order kept; no user -> NaN
users.merge(orders, on="user_id", how="right"):
user_id name amount
1 alice 10
1 alice 20
2 bob 5
4 NaN 99 # order 4 kept, no user
The four join types
how= | Keeps | Use when |
|---|---|---|
"inner" | Rows with the key on both sides. | ”Only users I have orders for.” |
"left" | All rows from df1, matched from df2 where possible. | ”All users, plus their orders if any.” (Most common in ML.) |
"right" | All rows from df2. | Same as left with sides swapped — usually rewrite as left. |
"outer" | All rows from both. | Union of keys. Used for audits, full reconciliation. |
left is the most common in ML pipelines. You have a primary
table (users, examples, products), and you want to attach features
without dropping any primary rows. Missing-on-the-right becomes NaN,
which you handle downstream.
Cardinality — the source of all merge bugs
The four cases:
validate= | What it checks |
|---|---|
"one_to_one" | Both sides have unique keys. Result has same rowcount as smaller side. |
"one_to_many" | Left key is unique, right key may repeat. Each left row matched 0..N times. |
"many_to_one" | Left key may repeat, right key is unique. |
"many_to_many" | No assumption. Watch out for row explosion. |
Many-to-many is the dangerous one. If df1 has 10 rows with key X
and df2 has 5 rows with key X, the merge produces 50 rows for that
key alone. With many keys this multiplies into a DataFrame that
doesn’t fit in memory.
The defensive habit: always pass validate=. If you don’t know which
to use, run df1["key"].duplicated().sum() and
df2["key"].duplicated().sum() first.
# Catch the bug at merge time, not three steps later
result = features.merge(
labels,
on="example_id",
how="left",
validate="many_to_one", # one label per example_id
)
Multiple keys, different names
# Same key name on both sides
df1.merge(df2, on=["user_id", "date"])
# Different names
df1.merge(df2, left_on="customer", right_on="user_id")
# Join on the index of one side
df1.merge(df2, left_on="user_id", right_index=True)
df1.join(df2, on="user_id") # short-hand for index-on-right joins
The df1.join(df2) shortcut joins on indices by default. Useful when
you’ve already set the index but more error-prone in pipelines —
explicit merge is usually clearer.
Suffixes — when both sides have the same column name
df1.columns = ["user_id", "score"]
df2.columns = ["user_id", "score"]
merged = df1.merge(df2, on="user_id", suffixes=("_train", "_test"))
# columns: user_id, score_train, score_test
Set sensible suffixes; the default ("_x", "_y") is unreadable.
concat vs merge — different operations
pd.concat | pd.merge |
|---|---|
| Stack DataFrames vertically (or horizontally) | Join DataFrames on a shared key |
| No key needed | Key required |
| Matches by column name (vertical) or index (horizontal) | Matches by value of the key |
# Vertical concat — append rows
all_data = pd.concat([df_2024, df_2025, df_2026], ignore_index=True)
# Horizontal concat — paste columns side by side, aligned on index
combined = pd.concat([numeric_features, text_features], axis=1)
For “stack a list of DataFrames into one big one” — that’s concat.
For “match rows from one to another by key” — that’s merge.
Worked ML example — feature join with leak protection
A typical ML feature pipeline: a label table, a behaviour table, an item-metadata table. Join them all together to build training rows.
import pandas as pd
labels = pd.read_parquet("labels.parquet") # (example_id, user_id, item_id, label)
behaviour = pd.read_parquet("user_behaviour.parquet") # (user_id, n_orders, avg_spend, ...)
items = pd.read_parquet("items.parquet") # (item_id, category, price, ...)
# Behaviour features should be one row per user — assert it
assert behaviour["user_id"].is_unique
# Item features should be one row per item — assert it
assert items["item_id"].is_unique
# Now join with confidence
training = (
labels
.merge(behaviour, on="user_id", how="left", validate="many_to_one")
.merge(items, on="item_id", how="left", validate="many_to_one")
)
# Audit
print("rows in:", len(labels))
print("rows out:", len(training))
print("missing user features:", training["n_orders"].isna().sum())
print("missing item features:", training["category"].isna().sum())
The validate="many_to_one" on each merge guarantees we never
row-explode. The audit prints catch missing-feature issues
immediately — usually a sign of stale snapshots or a join-key mismatch.
A common bug — implicit row explosion
# Suspicious data: the same user_id appears multiple times in `users`
users = pd.DataFrame({"user_id": [1, 1, 2], "name": ["a", "a-dup", "b"]})
orders = pd.DataFrame({"user_id": [1, 1, 2], "amount": [10, 20, 5]})
result = users.merge(orders, on="user_id")
# 4 rows for user_id=1 (2 users × 2 orders), 1 row for user_id=2 — total 5
If users is supposed to have unique user_ids and doesn’t, the
merge silently produces a row-exploded DataFrame. validate="one_to_many"
would have raised. Always pass it.
Performance tips
- Sort by the key first if you’ll do many joins on the same key — Pandas can use a faster sort-merge algorithm for sorted inputs.
- Set the index to the join key for repeated joins.
mergewithright_index=Trueskips the indexing step. - Use categorical dtypes for low-cardinality keys. Memory and hash-table performance both improve.
- Polars is much faster on large joins. If your DataFrames are > hundreds of MB, consider porting.
- DuckDB is excellent for ad-hoc joins on parquet files —
duckdb.sql("select ... join ... ")against multiple parquet files with no in-memory load.
Common gotchas
- No
validate=. The single most preventable Pandas bug. - Joining on a float key. Floats compare poorly across processing steps. Use ints or strings.
- Mixed dtypes for the key column between sides — silently no matches. Cast both sides first.
- Different sentinel for missing.
0on one side,NaNon the other; matches go missing. Normalise before merging. how="outer"on huge data — produces every key from both sides; can dwarf either input. Use rarely.- Time-series joins. For “as-of” / nearest-time joins, use
pd.merge_asof, not regular merge.
Where this shows up in real ML codebases
- Building training rows. Label table + features tables.
- Train/test contamination audits.
set(train_ids) & set(test_ids)is one approach;train.merge(test, on="user_id", how="inner", indicator=True)is the more thorough one — shows how much overlap. - Score-vs-truth joins.
predictions.merge(ground_truth, on="example_id")before computing metrics. - Slowly-changing-dimension joins for entity history.
- Pipeline orchestration tables — joining experiment runs to metrics to artifacts.
The defensive habit: every merge gets how=, on=, and validate=
specified explicitly. Defaults silently drop or duplicate rows; explicit
arguments make the contract obvious to the reader (and the linter).
Resources
- Pandas user guide — Merge, join, concatenate — pandas.pydata.org — canonical.
pd.merge_asof— pandas.pydata.org — for time-series “as of” joins.- DuckDB Python — duckdb.org — SQL joins on Pandas / parquet without leaving Python.
- Polars joins — pola.rs — faster alternative.