import numpy as np
import pandas as pd
monday = pd.DataFrame({"day": ["mon"] * 4,
"station": ["coast", "hill", "valley", "ridge"],
"temp": [21, 19, 26, 17]})
stations = pd.DataFrame({"station": ["coast", "hill", "valley", "lake"],
"elevation_m": [5, 450, 120, 300],
"zone": ["lowland", "upland", "lowland", "upland"]})Joining tables on a key: merge
Before this lesson: P02.5.3 Wide to long and back: melt and pivot
What you will be able to do
- Join two tables by hand with a dict from key to row and one pass over the left table.
- Use pd.merge(left, right, on=…, how=…) and predict the number of rows of an inner, left, right and outer join.
- Read the _merge column that indicator=True adds to tell where each row came from.
- Explain why a key repeated on the right multiplies rows, and catch it with validate=‘many_to_one’.
The question
The readings say which station each temperature came from; a second table says how high each station is and in which zone. To ask “are the upland stations colder?”, every reading needs its station’s details on the same row. How do you match rows across two tables, and what happens to a reading from a station the second table has never heard of?
From the last lesson
1. State the three rules of tidy data.
Show answer
Each variable forms a column, each observation forms a row, and each type of observational unit forms a table.
2. In what order does pivot put the day labels, and how do you restore the week?
Show answer
Sorted as text, mon, thu, tue, wed; .reindex(["mon", "tue", "wed", "thu"]) restores it.
Intuition
The third rule of tidy data says that each kind of thing gets its own table. Readings are one kind (one row per day and station); stations are another (one row per station). The look-up table stations has four rows: coast at 5 m in the lowland, hill at 450 m in the upland, valley at 120 m in the lowland, and lake at 300 m in the upland. Lake has no readings yet. Like every number in the course, these are invented for teaching.
A join puts the two together. The column they share, station, is the key: a reading and a station belong together when their keys are equal.
A join matches rows by key, not by position: ridge and lake are both in row 3, and they do not match. In a left join every reading is kept, and a reading with no partner gets NaN.
Monday’s three readings plus one from ridge, a station that was never registered, are the left table. Three keys have a partner on each side. Ridge has none on the right, and lake has none on the left. What to do with those two is the whole difference between the four kinds of join.
This is how a training table is assembled in practice. The features of one example are spread over several tables, a reading here, the station’s elevation there, and joins bring them onto one row. It is the same operation as JOIN in SQL (P03.3).
Build it up
Step 1. A look-up. Turn the right table into a dict from key to row (P01.6.3): coast maps to (5, 'lowland'), and so on. Building it is one pass over the right table.
Step 2. One pass over the left. For each reading, look its key up. lookup.get(station) gives the matching row, or None.
Step 3. Decide what to keep. If there is a match, append the reading joined to it. If not, an inner join appends nothing, and a left join appends the reading with NaN in the columns that would have come from the right.
A join by hand. One pass builds the look-up, a second pass looks every reading up, and the kind of join decides what to do with a miss.
Step 4. The other side. A right join is the same with the tables’ roles swapped: every station is kept, and lake gets NaN for the reading. An outer join keeps every key of either table.
Step 5. A key that repeats. Many readings share one key, which is fine: four readings from coast all find the one coast row. If the right table lists a key twice, the look-up must hold a list of rows, and every reading with that key is joined to each of them.
The formal statement
Which keys each join keeps, for Monday’s readings plus ridge against the four stations. When no key repeats, each kept key makes one row.
Counting the rows. For a key \(k\), let \(a_k\) be the number of left rows and \(b_k\) the number of right rows with that key. Every left row with key \(k\) is paired with every right row with key \(k\), so an inner join makes \(a_k b_k\) rows for that key, and
\[ n_{\text{inner}} = \sum_k a_k\, b_k . \]
The other joins keep the rows that have no partner. A key that is missing on one side (\(b_k = 0\), say) still gives its \(a_k\) rows, once each. Writing \(\max(b_k, 1)\) for “the partners, or one row of NaN if there are none”, the outer join, which keeps every key of either table, makes
\[ n_{\text{outer}} = \sum_k \max(a_k, 1)\,\max(b_k, 1), \]
summed over the keys of both tables. A left join sums only over the keys of the left table, and a right join only over those of the right.
Worked example
Join Monday’s four readings (coast, hill, valley, ridge) to the four stations (coast, hill, valley, lake) all four ways, and count the rows.
Every key appears once on each side where it appears at all: \(a_k\) and \(b_k\) are 1 or 0.
- Inner: coast, hill and valley have \(a_k b_k = 1\); ridge and lake have \(1 \cdot 0 = 0\). Three rows.
- Left: the left keys coast, hill, valley and ridge each give one row; ridge’s has
NaNfor elevation and zone. Four rows. - Right: coast, hill, valley and lake; lake’s row has
NaNfor day and temp. Four rows. - Outer: all five keys, one row each, in sorted order: coast, hill, lake, ridge, valley. Five rows.
The four joins with indicator=True. A column that gains a NaN turns from int64 to float64, which is why 21 becomes 21.0 and 5 becomes 5.0 in some of them.
Now the repeated key. Suppose stations lists coast twice. All 12 readings against it: coast has \(a = 4\) readings and \(b = 2\) station rows, hill and valley have \(a = 4\), \(b = 1\):
\[ \begin{aligned} n_{\text{left}} &= 4 \cdot 2 + 4 \cdot 1 + 4 \cdot 1 \\ &= 16 . \end{aligned} \]
Twelve readings went in and sixteen rows came out. Each coast reading now appears twice.
Code
The two tables of the worked example.
A join by hand: a dict from key to a list of rows (a list, so that a key may repeat), then one pass over the left table.
def join_by_hand(left, right, key, how="inner"):
"""Inner or left join of two lists of dicts on one key."""
lookup = {}
for row in right:
lookup.setdefault(row[key], []).append(row)
others = [c for c in right[0] if c != key]
out = []
for row in left:
matches = lookup.get(row[key], [])
for match in matches:
out.append({**row, **{c: match[c] for c in others}})
if not matches and how == "left":
out.append({**row, **{c: np.nan for c in others}})
return out
left_rows = monday.to_dict("records")
right_rows = stations.to_dict("records")
for how in ["inner", "left"]:
rows = join_by_hand(left_rows, right_rows, "station", how)
print(how, len(rows), [r["station"] for r in rows])inner 3 ['coast', 'hill', 'valley']
left 4 ['coast', 'hill', 'valley', 'ridge']
The same with pd.merge, all four ways, with the indicator.
for how in ["inner", "left", "right", "outer"]:
joined = pd.merge(monday, stations, on="station", how=how, indicator=True)
print(how, len(joined), joined["station"].tolist(), joined["_merge"].tolist())
hand = pd.DataFrame(join_by_hand(left_rows, right_rows, "station", "left"))
merged = pd.merge(monday, stations, on="station", how="left")
print(merged)inner 3 ['coast', 'hill', 'valley'] ['both', 'both', 'both']
left 4 ['coast', 'hill', 'valley', 'ridge'] ['both', 'both', 'both', 'left_only']
right 4 ['coast', 'hill', 'valley', 'lake'] ['both', 'both', 'both', 'right_only']
outer 5 ['coast', 'hill', 'lake', 'ridge', 'valley'] ['both', 'both', 'right_only', 'left_only', 'both']
day station temp elevation_m zone
0 mon coast 21 5.0 lowland
1 mon hill 19 450.0 upland
2 mon valley 26 120.0 lowland
3 mon ridge 17 NaN NaN
All twelve readings, joined to their stations. The joined table answers a question neither table could answer alone: the mean temperature in each zone, with a mask (P02.4.3).
temps = np.array([[21, 19, 26], [27, 23, 28], [27, 18, 30], [29, 20, 32]])
readings = pd.DataFrame({"day": np.repeat(["mon", "tue", "wed", "thu"], 3),
"station": ["coast", "hill", "valley"] * 4,
"temp": temps.ravel()})
full = pd.merge(readings, stations, on="station", how="left", validate="many_to_one")
print(full.shape)
print(full[full["zone"] == "lowland"]["temp"].mean(), full[full["zone"] == "upland"]["temp"].mean())(12, 5)
27.5 20.0
A key listed twice: the rows multiply, and validate catches it before it reaches a model.
doubled = pd.concat([stations, stations.iloc[[0]]], ignore_index=True)
print(len(doubled), len(pd.merge(readings, doubled, on="station", how="left")))
try:
pd.merge(readings, doubled, on="station", how="left", validate="many_to_one")
except pd.errors.MergeError:
print("MergeError: the keys of the right table are not unique")
fixed = pd.merge(readings, doubled.drop_duplicates(), on="station", how="left", validate="many_to_one")
print(len(fixed))5 16
MergeError: the keys of the right table are not unique
12
Explore
Join matcher
Choose a join, then press Step to watch the result grow one match at a time. Change a key on the right to coast twice and step through a left join: count how many times each coast reading appears, and compare the row count with the formula.
Pitfalls
Wrong beside right. A key listed twice on the right turns 12 readings into 16 rows without a warning. validate="many_to_one" stops the merge instead; removing the repeat first gives 12.
“A left join keeps the left table’s rows, so it has as many rows as the left table.”
Only when the keys of the right table are unique. Each left row appears once per partner, so a station listed twice doubles its readings: 12 became 16. Pass validate="many_to_one" whenever each left row should find at most one partner.
“merge lines rows up like NumPy adds arrays.”
It matches keys, not positions. Ridge and lake both sit in row 3 of their tables and do not match; coast would match coast wherever it sat. The order of the rows in either table does not change which rows are paired.
“The NaN after a left join is just a gap.”
It is a finding: a reading from a station that is not registered. Count it (_merge == "left_only") and find out why before you drop or fill it. Keys that differ only in spelling, such as "coast " with a space, also fail to match; cleaning text keys is P02.7.3.
Check yourself
Recall
1. Which keys does each join use: inner, left, right, outer?
Show answer
Inner: keys in both tables. Left: the left table’s keys. Right: the right table’s keys. Outer: the keys of either table.
2. How many rows do the four joins of Monday’s readings (with ridge) and the four stations give?
Show answer
3 (inner), 4 (left), 4 (right) and 5 (outer).
3. What are the three values of the _merge column?
Show answer
both, left_only and right_only.
4. A key appears \(a\) times on the left and \(b\) times on the right. How many rows does it give in an inner join?
Show answer
\(a \cdot b\): every left row with the key is paired with every right row with it.
5. What does validate="many_to_one" check?
Show answer
That the keys of the right table are unique. If one repeats, merge raises MergeError.
Exercises
1. ★ Join Tuesday’s three readings (coast 27, hill 23, valley 28) to stations with each of the four joins. How many rows does each give, and which row has a NaN?
Show solution
Every reading’s key is in stations, so inner and left both give 3 rows with no NaN. Right and outer give 4: the extra row is lake, with NaN for day and temp, marked right_only.
2. ★★ With coast listed twice in stations, count the rows of all four joins of the 12 readings, using \(a_k\) and \(b_k\).
Show solution
For coast \(a = 4\), \(b = 2\); for hill and valley \(a = 4\), \(b = 1\); for lake \(a = 0\), \(b = 1\). Inner: \(8 + 4 + 4 = 16\). Left (the left keys coast, hill, valley): 16 as well. Right adds lake once: \(16 + 1 = 17\). Outer, every key of either table: also 17.
3. ★★★ Show that if the keys of the right table are unique, a left join has exactly as many rows as the left table. Then say what validate="one_to_one" does with readings and stations, and why "many_to_one" is the right check here.
Show solution
Unique keys on the right mean \(b_k \le 1\) for every key, so \(\max(b_k, 1) = 1\). The left join’s count is
\[ \begin{aligned} \sum_{k \in \text{left}} a_k \max(b_k, 1) &= \sum_{k \in \text{left}} a_k \\ &= n_{\text{left}} . \end{aligned} \]
"one_to_one" also demands unique keys on the left, and readings has four rows for each station, so it raises MergeError. Many readings per station but one row per station is exactly the many-to-one shape, which "many_to_one" checks.
Summary card
A join pairs every row with every row of equal key; how decides what happens to the rest.
Match by key, not by position.
pd.merge(left, right, on="station", how="inner" | "left" | "right" | "outer").- Rows for key \(k\): \(a_k b_k\) when matched; unmatched rows kept once with
NaN. indicator=Trueadds_merge:both,left_only,right_only.validate="many_to_one"catches a repeated key on the right before it multiplies rows.
Sources and next steps
Sources. Each was opened and checked against this lesson on 15 November 2026.
- The pandas development team, Merge, join, concatenate and compare, pandas 3.1.1 User Guide, section merge: the table of
howvalues with their SQL names (left outer, right outer, full outer and inner join), the_mergevaluesleft_only,right_onlyandbothofindicator, thevalidateargument, and the warning that merging on duplicate keys can greatly increase the size of the result. - The pandas development team, pandas.merge, pandas 3.1.1 API reference: inner keeps the order of the left keys, left and right keep their table’s key order, outer sorts the keys lexicographically, and
"many_to_one"checks that the merge keys are unique in the right dataset.
Next. This closes the module. Course P02 continues with P02.6, group-by and aggregation, which splits the long, joined table into groups and summarises each one; SQL’s version of today’s join is P03.3.
This lesson unlocks
P02.6 Group-by and aggregation P03.3 Joins (planned)