Combining Datasets - Concat and Merge
Context
Two tables combine in two different ways. Stacking places the rows of one under the rows of the other, for pieces of the same table, and a merge pairs each row of one table with the rows of another that hold the same value in a key column, so the facts of both end up on one row.
Data rarely arrives as one file. Listings come in monthly exports that have to become one training table, and the facts about a neighborhood, its region and its distance to the center, live once in a lookup table that every listing needs a copy of. Most model inputs built from more than one source pass through one of these two operations, and the model of how long a listing stays on the market gets its distance to the center this way.
The worst failure here makes the table bigger. A key that appears twice in the lookup returns every matching listing twice, each of them counts twice in every row count and every sum it enters, and nothing raises, because a bigger table is not an error. An inner merge drops the listings whose key has no match, a stack repeats row labels, and a column spelled differently in one export becomes a second, mostly empty column. Section 2.1 met the same join failures in SQL, and this lesson meets them in pandas, where one argument can refuse the bad join outright.
Goal
- Stack frames with
pd.concat, and resolve the repeated labels withignore_indexorkeys. - Merge a lookup onto a table with
merge, naming the key and the suffixes of overlapping columns. - Predict the rows
inner,left,rightandouterkeep, and audit a merge withindicator=True. - Detect a duplicated lookup key before merging, and guard every lookup merge with
validate="many_to_one".
Prerequisites
Optional, and the lesson stands without them. They are here if you want to see two tables become one before you join any.
- YouTube - "How do I merge DataFrames in pandas?" by Data School. What happens during a merge, and the four types of join compared.
- Official docs - pandas User Guide, "Merge, join, concatenate and compare", the "Merge types" section. The
left,right,outerandinnerjoins, each drawn as two small tables and their result.
Table of Contents
- concat - Stacking Tables
- merge - Joining on a Key
- Join Types and indicator
- Guarding the Join
Theory
This lesson's download is regions.csv, a lookup of the neighborhoods: each row holds a neighborhood, its region and its distance to the center in kilometers, the same seven rows as Section 2.1's regions table. Save it into ~/dev/bootcamp beside housing.csv.
Nothing to install. Every Theory block below goes into one script, combine.py, saved in ~/dev/bootcamp and run with python3 combine.py, each block added under the one before it.
concat - Stacking Tables
What. pd.concat([a, b]) stacks frames: the rows of b go under the rows of a, and with axis=1 the frames are placed side by side instead, rows paired by index label. It reads no key column.
Why. Data arrives in pieces of the same kind, one export per month or one file per source, and the analysis needs them as one table.
When. When the pieces hold the same kind of record with the same columns. Two tables that describe different things and share a key are a merge.
Two monthly exports built in code, each numbered from 0 the way a freshly read file is, show what a stack keeps.
import pandas as pd
march = pd.DataFrame({
"neighborhood": ["Downtown", "Riverside", "Lakeview"],
"price": [268_000, 281_000, 437_000],
})
april = pd.DataFrame({
"neighborhood": ["Hillcrest", "Southgate"],
"price": [150_000, 203_000],
})
both = pd.concat([march, april])
print(both)
print(both.loc[0]) neighborhood price
0 Downtown 268000
1 Riverside 281000
2 Lakeview 437000
0 Hillcrest 150000
1 Southgate 203000
neighborhood price
0 Downtown 268000
0 Hillcrest 150000The stack kept each row's original label, so 0 and 1 now appear twice and both.loc[0] returns two listings. ignore_index=True renumbers the result instead, and keys= labels each piece, which becomes the outer level of 2.6.2's two-level index, so one export stays reachable by name.
renumbered = pd.concat([march, april], ignore_index=True)
print(renumbered.index.tolist())
by_month = pd.concat([march, april], keys=["march", "april"])
print(by_month.loc["april"])[0, 1, 2, 3, 4]
neighborhood price
0 Hillcrest 150000
1 Southgate 203000A column present in only one piece is not an error. concat keeps every column any piece has and fills each cell a piece could not supply with a missing value, so a column renamed in one export becomes a second column, mostly empty.
may = pd.DataFrame({
"neighborhood": ["Eastgate"],
"list_price": [476_000],
})
spring = pd.concat([march, april, may], ignore_index=True)
print(spring)
print(spring.isna().sum().to_dict()) neighborhood price list_price
0 Downtown 268000.0 NaN
1 Riverside 281000.0 NaN
2 Lakeview 437000.0 NaN
3 Hillcrest 150000.0 NaN
4 Southgate 203000.0 NaN
5 Eastgate NaN 476000.0
{'neighborhood': 0, 'price': 1, 'list_price': 5}With axis=1 the frames sit side by side, and rows are paired by index label, 2.4.1's alignment, so a label one side lacks gets a missing value.
distance = pd.Series([1.2, 3.5], name="distance_km")
print(pd.concat([march, distance], axis=1)) neighborhood price distance_km
0 Downtown 268000 1.2
1 Riverside 281000 3.5
2 Lakeview 437000 NaNDON'T:
- DON'T reach into a stack with
.locbefore checking its labels,both.loc[0]returned two listings because each piece brought its own 0. - DON'T trust a stack's columns to line up, a column spelled differently in one piece becomes a new column without a warning, so count the columns and the gaps after every
concat. - DON'T use
concat(axis=1)to combine tables that share a key column, it pairs index labels, not keys, and pairing by key is amerge.
merge - Joining on a Key
What. listings.merge(regions, on="neighborhood", how="left") pairs each row of the left table with the rows of the right table that hold the same value in the key column, and returns their columns side by side.
Why. A fact about a neighborhood belongs in one small table, stored once and changed once, and merge attaches it to every listing that needs it, the reason Lesson 2.1.2 kept regions in their own table.
When. When two tables describe different things and share a key, a listing and its neighborhood.
regions.csv, the download saved at the start of Theory, is the lookup. The listings are loaded and cleaned as in Lesson 2.6.1.
listings = pd.read_csv("housing.csv").drop_duplicates()
extreme = (
(listings["price"] > 2_000_000)
| (listings["area_sqm"] > 1_000)
| (listings["rooms"] > 20)
)
listings = listings[~extreme].reset_index(drop=True)
regions = pd.read_csv("regions.csv")
print(regions) neighborhood region distance_to_center_km
0 Downtown Central 1.2
1 Riverside Central 3.5
2 Riverside Central 3.7
3 Hillcrest North 7.8
4 Eastgate East 5.1
5 Northend North 9.4
6 Lakeview West NaNmerge is Lesson 2.1.2's join written in pandas. on="neighborhood" names the column both tables share, the ON condition of that lesson, and how="left" is its LEFT JOIN.
merged = listings.merge(regions, on="neighborhood", how="left")
print(merged.columns.tolist())
print(merged[["neighborhood", "price", "region", "distance_to_center_km"]].head(4))['price', 'area_sqm', 'rooms', 'year_built', 'neighborhood', 'condition', 'listing_date', 'region', 'distance_to_center_km']
neighborhood price region distance_to_center_km
0 Lakeview 437000.0 West NaN
1 Downtown 268000.0 Central 1.2
2 Southgate 268000.0 NaN NaN
3 Hillcrest NaN North 7.8Every listing gained the two lookup columns. Row 2 is a Southgate listing, the lookup has no Southgate row, and a left merge keeps the listing with its new columns empty, as 2.1.2's LEFT JOIN left them NULL. Row 0 is a Lakeview listing that did match, and its distance is empty because the lookup's own Lakeview row has none.
When both tables hold a column of the same name that is not the key, merge keeps both and marks them _x for the left and _y for the right. A neighborhood median from 2.6.2, merged back onto the listings, shows it, and suffixes= names the two instead.
medians = listings.groupby("neighborhood")["price"].median().reset_index()
plain = listings.merge(medians, on="neighborhood", how="left")
print([c for c in plain.columns if c.startswith("price")])
named = listings.merge(
medians,
on="neighborhood",
how="left",
suffixes=("", "_nb_median"),
)
print(named[["neighborhood", "price", "price_nb_median"]].head(3))['price_x', 'price_y']
neighborhood price price_nb_median
0 Lakeview 437000.0 391000.0
1 Downtown 268000.0 254000.0
2 Southgate 268000.0 212000.0The empty left suffix kept the listing's own price under its name. Leaving out on= would not have been a shortcut: merge then joins on every column the two tables share, here neighborhood and price together, and with its default how="inner" it keeps only the listings whose price equals their neighborhood's median.
print(len(listings.merge(medians))) # 9DON'T:
- DON'T leave the key for
mergeto guess, it joins on every shared column, andlistings.merge(medians)kept 9 listings out of 200. - DON'T carry
_xand_ypast the line that made them, name the columns withsuffixes=so the result explains itself.
Join Types and indicator
What. how= decides which rows survive a merge. inner keeps only rows that matched on both sides, left keeps every left row, right every right row, and outer everything from both. indicator=True adds a _merge column saying where each row came from: both, left_only or right_only.
Why. The join type decides which listings are in every statistic computed afterwards, and the indicator is the record of what the join actually did.
When. left to add columns to a table that must keep every row, inner only when a row without a match is unusable, and outer with indicator=True to audit a join before trusting it.
for how in ["inner", "left", "right", "outer"]:
joined = listings.merge(regions, on="neighborhood", how=how)
southgate = (joined["neighborhood"] == "Southgate").sum()
print(how, len(joined), southgate)inner 191 0
left 227 36
right 191 0
outer 227 36Southgate's 36 listings are in the left and outer results and gone from the inner one, without a word, the loss 2.1.2's INNER JOIN produced. right keeps every lookup row and matches inner here, and outer matches left, because every neighborhood in the lookup has listings. The row counts above are wrong in a way no join type explains: a left merge should return each left row once, and 200 listings came back as 227. Guarding the Join finds the 27.
audit = listings.merge(regions, on="neighborhood", how="outer", indicator=True)
print(audit["_merge"].value_counts())_merge
both 191
left_only 36
right_only 0
Name: count, dtype: int64left_only counts the listings that found no lookup row, all of them Southgate, and right_only the lookup rows no listing used. The indicator also separates the two kinds of missing distance the first merge above showed, Southgate's and Lakeview's, 65 rows in all.
no_distance = audit[audit["distance_to_center_km"].isna()]
# {'left_only': 36, 'both': 29, 'right_only': 0}
print(no_distance["_merge"].value_counts().to_dict())DON'T:
- DON'T choose
innerbecause its result looks tidy, it removed Southgate's 36 listings and reported nothing. - DON'T audit a join by counting missing values in one new column, an unmatched key and a matched key with an empty lookup cell are both
NaNthere, and_mergetells them apart.
Guarding the Join
What. Guarding a join means three habits: count the rows before and after, check that the lookup's key is unique with duplicated() or .is_unique before merging, and pass validate="many_to_one", which makes pandas raise instead of multiplying rows.
Why. A key that appears twice in the lookup returns every matching row twice, so each of them counts twice in every row count and every sum it enters while every single row looks correct, the same silent failure 2.1.2 found in SQL.
When. On every merge of a lookup onto a table, before any number computed from the result is trusted.
The count before and after is the first check, and it finds the 27.
print(len(listings), len(merged)) # 200 227
before = (listings["neighborhood"] == "Riverside").sum()
after = (merged["neighborhood"] == "Riverside").sum()
print(before, after) # 27 54
# {'Central': 72, 'East': 32, 'North': 58, 'West': 29}
print(merged.groupby("region").size().to_dict())Riverside appears twice in the lookup, at 3.5 and at 3.7 kilometers, so each of its 27 listings matched both rows and came back twice. The table grew by exactly those 27, and Central, which holds Downtown's 18 listings and Riverside's 27, reports 72 instead of 45. Every extra row pairs a real listing with a real lookup row, which is why reading any one row never finds the problem and counting the rows always does.
The duplicate is visible in the lookup before any merge, with 2.5.4's duplicated, where keep=False marks every copy.
print(regions["neighborhood"].is_unique)
print(regions[regions["neighborhood"].duplicated(keep=False)])False
neighborhood region distance_to_center_km
1 Riverside Central 3.5
2 Riverside Central 3.7validate="many_to_one" writes the expectation into the call: many listings may share a key, and the lookup must hold each key once. pandas checks the lookup's key against that promise and raises MergeError, which lives in pd.errors, instead of returning the multiplied table, and its message names the duplicated key.
try:
listings.merge(
regions,
on="neighborhood",
how="left",
validate="many_to_one",
)
except pd.errors.MergeError as err:
print(f"MergeError: {err}")MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
Duplicates in right:
neighborhood
Riverside ...The fix belongs in the lookup, and it is a decision rather than a cleanup. Riverside is 3.5 or 3.7 kilometers from the center, or somewhere between, and the data cannot say which. Here the first row is kept, and the decision is written beside the code where the next reader will see it.
# Decision: Riverside keeps its first row, 3.5 km.
lookup = regions.drop_duplicates(subset="neighborhood", keep="first")
guarded = listings.merge(
lookup,
on="neighborhood",
how="left",
validate="many_to_one",
indicator=True,
)
print(len(guarded)) # 200
# {'both': 164, 'left_only': 36, 'right_only': 0}
print(guarded["_merge"].value_counts().to_dict())
# {'Central': 45, 'East': 32, 'North': 58, 'West': 29}
print(guarded.groupby("region").size().to_dict())The row count holds at 200 and Central is back to 45. both fell from the audit's 191 to 164, and the 27 it lost were the copies, the proof that _merge counts result rows and cannot tell a second match from a first.
DON'T:
- DON'T write a merge onto a lookup without
validate="many_to_one", it is the one argument that turns a multiplied table into an error. - DON'T fix a multiplied merge by deduplicating its result, the Riverside copies differ in their distance, so
merged.drop_duplicates()keeps all 227 rows. - DON'T read
_merge'sbothas a count of listings, it counts result rows, and a key matched twice is counted twice.
Lab Exercises
Lab Exercise 1: Three Exports and a Neighborhood Column
Start a new script in ~/dev/bootcamp.
- Build three exports in code,
jan,febandmar, two listings each with aneighborhoodand aprice, each numbered from 0. Stack them withkeys=["jan", "feb", "mar"], print the stack, and print February's rows reached by its key. - An April export arrives with the column spelled
Price. Stack all four withignore_index=True, and print the columns and the number of missing values in each. - Load and clean
housing.csvas the Theory does, compute each neighborhood's mean area rounded to one decimal, and merge it onto the listings so the listing's ownarea_sqmkeeps its name and the new column isarea_sqm_nb_mean. Print the first three rows ofneighborhood,area_sqmandarea_sqm_nb_mean, and the shape of the result.
Lab Exercise 2: Four Joins, One Audit, One Guard
Start another script that loads and cleans housing.csv and loads regions.csv as the Theory does.
- Merge the listings with the raw lookup once per join type, and print for each the row count and the number of distinct neighborhoods in the result.
- Run the left merge with
validate="many_to_one"inside atry, and print only the first line of the error message. - Deduplicate the lookup keeping the last Riverside row, 3.7 kilometers, with the decision written in a comment. Merge with
how="outer",validate="many_to_one"andindicator=True, and print the indicator counts and the neighborhoods markedleft_only. - Print the mean
distance_to_center_kmper region from the guarded merge and from the raw left merge, rounded to two decimals, and explain the difference for Central in a comment.
Solution 1: Three Exports and a Neighborhood Column
import pandas as pd
jan = pd.DataFrame({
"neighborhood": ["Downtown", "Eastgate"],
"price": [268_000, 476_000],
})
feb = pd.DataFrame({
"neighborhood": ["Riverside", "Hillcrest"],
"price": [281_000, 150_000],
})
mar = pd.DataFrame({
"neighborhood": ["Lakeview", "Northend"],
"price": [437_000, 173_000],
})
stacked = pd.concat([jan, feb, mar], keys=["jan", "feb", "mar"])
print(stacked)
print(stacked.loc["feb"])
apr = pd.DataFrame({"neighborhood": ["Southgate"], "Price": [203_000]})
year = pd.concat([jan, feb, mar, apr], ignore_index=True)
print(year.columns.tolist())
print(year.isna().sum().to_dict()) neighborhood price
jan 0 Downtown 268000
1 Eastgate 476000
feb 0 Riverside 281000
1 Hillcrest 150000
mar 0 Lakeview 437000
1 Northend 173000
neighborhood price
0 Riverside 281000
1 Hillcrest 150000
['neighborhood', 'price', 'Price']
{'neighborhood': 0, 'price': 1, 'Price': 6}listings = pd.read_csv("housing.csv").drop_duplicates()
extreme = (
(listings["price"] > 2_000_000)
| (listings["area_sqm"] > 1_000)
| (listings["rooms"] > 20)
)
listings = listings[~extreme].reset_index(drop=True)
area_means = (
listings.groupby("neighborhood")["area_sqm"].mean().round(1).reset_index()
)
with_area = listings.merge(
area_means,
on="neighborhood",
how="left",
suffixes=("", "_nb_mean"),
)
print(with_area[["neighborhood", "area_sqm", "area_sqm_nb_mean"]].head(3))
print(with_area.shape) neighborhood area_sqm area_sqm_nb_mean
0 Lakeview 131.8 100.5
1 Downtown 112.1 103.2
2 Southgate 102.2 102.0
(200, 8)The keys kept each export reachable by name while the row labels inside repeat. The April export turned one column into two, price missing once and Price missing six times, and nothing failed. The merge added exactly one column and kept all 200 rows, because area_means has one row per neighborhood.
DON'T:
- DON'T rename
Priceafter the stack, fix the column name in the export before stacking, or the two half-empty columns travel into every later step. - DON'T stack exports without
keys=orignore_index=True, the labels 0 and 1 would each name three listings.
Solution 2: Four Joins, One Audit, One Guard
import pandas as pd
listings = pd.read_csv("housing.csv").drop_duplicates()
extreme = (
(listings["price"] > 2_000_000)
| (listings["area_sqm"] > 1_000)
| (listings["rooms"] > 20)
)
listings = listings[~extreme].reset_index(drop=True)
regions = pd.read_csv("regions.csv")
for how in ["inner", "left", "right", "outer"]:
joined = listings.merge(regions, on="neighborhood", how=how)
print(how, len(joined), joined["neighborhood"].nunique())
try:
listings.merge(
regions,
on="neighborhood",
how="left",
validate="many_to_one",
)
except pd.errors.MergeError as err:
print(str(err).splitlines()[0])inner 191 6
left 227 7
right 191 6
outer 227 7
Merge keys are not unique in right dataset; not a many-to-one merge# Decision: Riverside keeps its last row, 3.7 km.
lookup = regions.drop_duplicates(subset="neighborhood", keep="last")
audit = listings.merge(
lookup,
on="neighborhood",
how="outer",
validate="many_to_one",
indicator=True,
)
# {'both': 164, 'left_only': 36, 'right_only': 0}
print(audit["_merge"].value_counts().to_dict())
# ['Southgate']
print(audit.loc[audit["_merge"] == "left_only", "neighborhood"].unique().tolist())
raw = listings.merge(regions, on="neighborhood", how="left")
# {'Central': 2.7, 'East': 5.1, 'North': 8.57, 'West': nan}
print(audit.groupby("region")["distance_to_center_km"].mean().round(2).to_dict())
# {'Central': 3.0, 'East': 5.1, 'North': 8.57, 'West': nan}
print(raw.groupby("region")["distance_to_center_km"].mean().round(2).to_dict())
# Central 2.7: 18 Downtown rows at 1.2 and 27 Riverside rows at 3.7.
# Central 3.0: the 27 Riverside rows counted twice, at 3.5 and at 3.7.The join types differ from each other by one neighborhood, Southgate, and every raw result carries the 27 Riverside copies on top. The distance means show what the copies do to a statistic: Riverside's listings weigh twice as much as Downtown's in the raw Central mean. West is nan because Lakeview, its only neighborhood, has no distance in the lookup at all.
DON'T:
- DON'T judge the join types on the raw lookup, every row count a join on it returned carries the 27 Riverside copies, so the guard comes before the choice of join.
- DON'T present the deduplication as cleaning, keeping 3.7 instead of 3.5 moved Central's mean distance, so it belongs in the report as a decision.
Theory Assessment
Q1: Two Rows Labeled 1
both = pd.concat([march, april]) stacks the Theory's two exports. Say what both.loc[1] returns and why, then say how you would reach April's second listing, Southgate, after ignore_index=True and after keys=["march", "april"].
Hint: Each export arrived numbered from 0. Ask which label each row carries after the stack, and what each argument replaces it with.
Q2: The Rows Each Join Keeps
With the deduplicated lookup, which holds one row per neighborhood and no Southgate row, predict the row count of inner, left, right and outer, name the neighborhood behind every difference, and say which join adds region facts to a training table without changing it.
Hint: The cleaned table has 36 Southgate listings, and every neighborhood in the lookup has listings.
Q3: Sixty-Five Missing Distances
After the guarded left merge, distance_to_center_km holds 65 missing values. Explain where they come from, why the count alone cannot tell you what to do about them, and how indicator=True splits them.
Hint: Two different neighborhoods are involved, and only one of them has a row in the lookup.
Q4: Two Fixes That Fix Nothing
A teammate merges the listings with the raw lookup and sees Central report 72 listings. Their first fix is merged = merged.drop_duplicates(). When the count stays at 72, they add a guard, it raises, and their second fix is to change it to validate="many_to_many". Predict the Central count after each fix, say what each fix actually did, and state what Central's mean price becomes once the lookup itself is repaired, compared with the 283259.0 the multiplied table reports.
Hint: Look at the two Riverside rows of the lookup and ask whether their copies are identical rows. Then ask what a declaration that permits every shape can ever refuse.
Answer 1:
both.loc[1] returns two rows, Riverside at 281000 from March and Southgate at 203000 from April. concat keeps each row's original label, both exports numbered their rows from 0, and .loc returns every row carrying the label it is given.
After ignore_index=True the labels run 0 to 4, so Southgate is the only row labeled 4, renumbered.loc[4]. After keys=["march", "april"] each row is reached by a tuple, the export first, by_month.loc[("april", 1)], 2.6.2's two-level index.
print(both.loc[1]["neighborhood"].tolist()) # ['Riverside', 'Southgate']
print(renumbered.loc[4, "neighborhood"]) # Southgate
# Southgate
print(by_month.loc[("april", 1), "neighborhood"])DON'T assume .loc[label] returns one row on a stacked frame, a repeated label returns every row that carries it.
Answer 2:
inner returns 164, left 200, right 164 and outer 200. Southgate is behind every difference: its 36 listings have no lookup row, so the joins that keep only matches, inner, and only the lookup's rows, right, drop them, and left and outer keep them with empty region columns. right equals inner and outer equals left only because every neighborhood in the lookup has listings, a lookup row with no listings would appear in right and outer alone.
left is the join for adding facts to a training table: every listing survives exactly once, provided the lookup holds each key once, so the merge can add columns and never remove or multiply rows.
for how in ["inner", "left", "right", "outer"]:
print(how, len(listings.merge(lookup, on="neighborhood", how=how)))inner 164
left 200
right 164
outer 200DON'T read right and inner agreeing as a rule, it is a fact about this lookup, which happens to have no neighborhood without listings.
Answer 3:
The 65 are the 36 Southgate listings and the 29 Lakeview listings. Southgate has no row in the lookup, so its listings matched nothing and every lookup column is empty. Lakeview has a row, and the row's distance itself is empty, so its listings matched and received the gap the lookup already held.
The count cannot tell you what to do because the two need different repairs. Southgate needs a lookup row, its region and distance, from whoever owns the lookup. Lakeview needs its distance filled in that lookup row, or a 2.5.1 fill if no one can supply it. indicator=True marks the Southgate rows left_only and the Lakeview rows both, which is exactly the split the Theory printed, 36 and 29.
DON'T fill in a distance for Southgate the way you might for Lakeview, there is no Southgate row to fill, only a missing one to add.
Answer 4:
Central stays at 72 after both fixes.
drop_duplicates() removes rows that are identical in every column, and the two copies of each Riverside listing are not: one carries 3.5 and the other 3.7 in distance_to_center_km. The method found nothing to drop, and all 227 rows stayed. validate="many_to_many" declares that keys may repeat on both sides, which is every shape a merge can have, so it never refuses anything: the call returned the same 227 rows and the error simply stopped appearing.
dedup = merged.drop_duplicates()
# 227 72
print(len(dedup), dedup.groupby("region").size()["Central"])
loose = listings.merge(regions, on="neighborhood", how="left", validate="many_to_many")
print(len(loose)) # 227
# {'Central': 274757.0, 'East': 420172.0, 'North': 189660.0, 'West': 391259.0}
print(guarded.groupby("region")["price"].mean().round(0).to_dict())With the lookup repaired, Central's mean price is 274757.0, not 283259.0. The multiplied table did not only inflate the count, it gave each Riverside listing twice the weight of a Downtown listing in the mean, and Riverside's prices sit above Downtown's. A mean is not protected from a multiplied join, it is moved by it.
DON'T silence a MergeError by loosening validate, the error was the only correct output the merge produced, and the repair is one row in the lookup.
Assignment
Assignment: A Training Table From Three Sources
The training table for the model of how long a listing stays on the market needs each listing's region and distance from regions.csv and a school rating that a second team delivers in two pieces, both built in code below, keyed by a column they call area. The listings' row count may not change, and every missing value the merges introduce must be traced to its source.
schools_a = pd.DataFrame({
"area": ["Downtown", "Riverside", "Hillcrest"],
"school_rating": [7.5, 6.8, 8.1],
})
schools_b = pd.DataFrame({
"area": ["Hillcrest", "Eastgate", "Northend", "Southgate", "Westfield"],
"school_rating": [8.3, 7.2, 6.4, 5.9, 8.8],
})Stack the two school pieces into one lookup and check it the way this lesson checked regions.csv. Resolve every duplicate as a stated decision: the second piece is the newer rating, so it wins, and Riverside's distance becomes the mean of its two values, computed with 2.6.2's groupby. Then merge both lookups onto the cleaned listings with left joins guarded by validate="many_to_one", assert after each merge that the table still has 200 rows, and print an audit of which neighborhoods each source failed to match.
The school lookup names its key area, so on= cannot be used. merge(left_on="neighborhood", right_on="area") pairs two key columns with different names and keeps both of them in the result. And because merge will not add its indicator under a name the table already holds, a second indicator=True merge on the same table raises a ValueError, so give each indicator its own name: indicator="region_match" names the column it adds. Once the audit has used them, table.drop(columns=[...]) returns the table without the helper columns.
Hints:
- Grouping the lookup by
neighborhoodandregiontogether keeps theregioncolumn through the mean, andreset_index()turns both keys back into columns. - In a left merge,
right_onlyis always 0, so Westfield, which has a rating and no listings, never appears in the result.
Solution:
import pandas as pd
listings = pd.read_csv("housing.csv").drop_duplicates()
extreme = (
(listings["price"] > 2_000_000)
| (listings["area_sqm"] > 1_000)
| (listings["rooms"] > 20)
)
listings = listings[~extreme].reset_index(drop=True)
regions = pd.read_csv("regions.csv")
schools_a = pd.DataFrame({
"area": ["Downtown", "Riverside", "Hillcrest"],
"school_rating": [7.5, 6.8, 8.1],
})
schools_b = pd.DataFrame({
"area": ["Hillcrest", "Eastgate", "Northend", "Southgate", "Westfield"],
"school_rating": [8.3, 7.2, 6.4, 5.9, 8.8],
})
schools = pd.concat([schools_a, schools_b], ignore_index=True)
print(schools["area"].is_unique)
print(schools[schools["area"].duplicated(keep=False)])
# Decision: the second piece is newer, so its Hillcrest rating wins.
schools = schools.drop_duplicates(subset="area", keep="last")
# Decision: Riverside's distance is the mean of its two values.
region_lookup = (
regions.groupby(["neighborhood", "region"])["distance_to_center_km"]
.mean()
.reset_index()
)
print(region_lookup)False
area school_rating
2 Hillcrest 8.1
3 Hillcrest 8.3
neighborhood region distance_to_center_km
0 Downtown Central 1.2
1 Eastgate East 5.1
2 Hillcrest North 7.8
3 Lakeview West NaN
4 Northend North 9.4
5 Riverside Central 3.6n = len(listings)
table = listings.merge(
region_lookup,
on="neighborhood",
how="left",
validate="many_to_one",
indicator="region_match",
)
assert len(table) == n, "the region merge changed the row count"
table = table.merge(
schools,
left_on="neighborhood",
right_on="area",
how="left",
validate="many_to_one",
indicator="school_match",
)
assert len(table) == n, "the school merge changed the row count"
for col in ["region_match", "school_match"]:
counts = table[col].value_counts().to_dict()
unmatched = table.loc[table[col] == "left_only", "neighborhood"].unique()
print(col, counts, unmatched.tolist())
table = table.drop(columns=["area", "region_match", "school_match"])
print(table.shape)
missing = table[["region", "distance_to_center_km", "school_rating"]].isna().sum()
print(missing.to_dict())region_match {'both': 164, 'left_only': 36, 'right_only': 0} ['Southgate']
school_match {'both': 171, 'left_only': 29, 'right_only': 0} ['Lakeview']
(200, 10)
{'region': 36, 'distance_to_center_km': 65, 'school_rating': 29}The stack produced the duplicate in the first place: each school piece was clean on its own, and Hillcrest became a repeated key only once they were combined. Unguarded, the school merge would have turned Hillcrest's 30 listings into 60, the way Riverside's two rows doubled its 27. Checking a lookup after every concat is the habit, not only after a download.
Both repairs are decisions written as comments, the newer rating and the mean distance, 3.6 for Riverside. With them in place both merges passed their guards, and the two assertions held, so the table still has one row per listing.
The audit accounts for every gap the merges added. region is missing on 36 rows, all Southgate, the neighborhood the region lookup lacks. distance_to_center_km is missing on 65, those 36 and Lakeview's 29, whose lookup row has no distance. school_rating is missing on 29, all Lakeview, the one neighborhood neither school piece rated. The area column that right_on brought in was empty on exactly those 29 rows and duplicated neighborhood everywhere else, so it was dropped with the two indicator columns once the audit had used them.
DON'T trust a lookup because each of its pieces was clean, a concat can create the duplicate key, and don't keep the redundant area column after a left_on and right_on merge, it repeats neighborhood and invites using the wrong one.
Additional Resources
Optional, and nothing later depends on them. Worth opening if you want the other ways pandas combines tables.
- Official docs - pandas API reference,
DataFrame.join. Joining on the index instead of a column, the shorter call when both keys are already indexes. - Official docs - pandas API reference,
merge_asof. Matching each row to the nearest key rather than an equal one, the join for readings taken at different times.
Summary
The two operations in this lesson look alike and answer different questions. A stack assumes its pieces are the same kind of record and checks nothing, not the labels, not the column names, so everything it gets wrong it gets wrong quietly: labels that name two rows, a column spelled two ways becoming two columns. A merge assumes its key means the same thing on both sides and that the lookup holds it once, and when the second assumption fails it does not complain, it multiplies.
What made the multiplication dangerous was not its size, 27 extra rows, but its invisibility. Every copied row looked correct, each one pairing a real listing with a real lookup row, and the merged table looked like any other. Arithmetic noticed where reading did not: the count before against the count after, a region holding 72 listings when its neighborhoods hold 45, a mean that moved because one neighborhood now counted twice. The tools that caught it, len before and after, duplicated and validate, all work by turning an assumption into something checked, and none of them is more than a few characters.
The decisions were the other half. Which Riverside distance is real, which school rating is current, whether Southgate's listings belong in the analysis at all, none of these has an answer in the data, and each one the lesson settled was written down beside the code that carried it out. A table built from several sources is a record of such choices as much as of the sources, and the next section starts describing and plotting the table these three lessons built.
Cheat Sheet
Part A - Reference Table
| Task | Code | What it keeps | Watch for |
|---|---|---|---|
| Stack rows | pd.concat([a, b]) |
every row, original labels | labels repeat |
| Renumber | pd.concat([a, b], ignore_index=True) |
labels 0 to n-1 | the source is lost |
| Label each piece | pd.concat([a, b], keys=["a", "b"]) |
a two-level index | reach a piece with .loc["a"] |
| Side by side | pd.concat([a, b], axis=1) |
rows paired by label | not by key |
| Join on a key | a.merge(b, on="key", how="left") |
every left row | name on= always |
| Name overlaps | suffixes=("", "_nb_median") |
readable column names | default _x and _y |
| Different key names | left_on="neighborhood", right_on="area" |
both key columns | drop the extra one |
| Matches only | how="inner" |
matched rows | unmatched rows vanish |
| Everything | how="outer" |
rows from both sides | an audit, not a result |
| Audit | indicator=True |
a _merge column |
counts rows, not listings |
| Name the audit | indicator="region_match" |
a named column | a name already taken raises |
| Check the lookup | lookup["key"].is_unique |
True or False |
before every merge |
| Guard | validate="many_to_one" |
raises MergeError |
never loosen it to pass |
| Repair the lookup | drop_duplicates(subset="key", keep=...) |
one row per key | a decision, say which |
Part B - Compact Code Block
import pandas as pd
both = pd.concat([march, april], ignore_index=True)
by_month = pd.concat([march, april], keys=["march", "april"])
regions = pd.read_csv("regions.csv")
print(regions["neighborhood"].is_unique) # check first
# Decision: Riverside keeps its first row.
lookup = regions.drop_duplicates(subset="neighborhood", keep="first")
merged = listings.merge(
lookup,
on="neighborhood",
how="left", # every listing once
validate="many_to_one", # raises on a repeated key
indicator=True, # adds '_merge'
)
assert len(merged) == len(listings)
merged["_merge"].value_counts() # both, left_only, right_only