Combining tables with merge
Join on keys safely, stack tables, and divide by the right denominator.
Create a free account Try part of it here
Why it matters
Real data lives in several tables: orders in one, products in another, stores in a third. Almost every useful question needs columns from more than one of them, so joining is the step between "I have data" and "I have an answer". It's also where silent errors creep in: a duplicate key that doubles your revenue, or unmatched rows that quietly vanish. This lesson covers the join and the checks that keep it honest.
What you'll be able to do
- Join two tables on a key with merge, and choose inner, left or outer on purpose
- Use validate= and indicator= to catch duplicate keys and unmatched rows before they mislead you
- Stack tables with the same columns using concat
- Turn totals into fair comparisons by joining the right denominator
What it covers
- The brief
- merge, and the four kinds of join
- Two checks that make joins safe
- Same names in both tables
- Stacking tables with concat
- Units by product line
- Rates need the right denominator
- Worked example: profit by product line
The brief
The shop wants two things: profit by product line, and how much each store earns per visit. Neither table has both pieces. Orders know the SKU and the price paid but not the cost. The catalog knows each SKU's cost and product line ID, but not the line's name. Only the product-lines table has the names, and only the stores table knows how many people visit each store.
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders, catalog, lines, stores = dataset("orders"), dataset("catalog"), dataset("product-lines"), dataset("stores") for name, table in [("orders", orders), ("catalog", catalog), ("product-lines", lines), ("stores", stores)]: print(f"{name:14} {table.shape} {list(table.columns)[:6]}")
The columns the tables share are their keys: sku links orders to the catalog, line_id links the catalog to product lines, and store links orders to stores. A key in the "one" table, like sku in the catalog, should appear exactly once. In the "many" table, like sku in orders, it can appear any number of times.
merge, and the four kinds of join
left.merge(right, on="key", how=...) matches rows whose keys are equal and puts their columns side by side. how says what to do with rows that find no partner:
import pandas as pd orders = pd.DataFrame({"sku": ["A", "B", "B", "X"], "qty": [1, 2, 1, 5]}) catalog = pd.DataFrame({"sku": ["A", "B", "C"], "cost": [10, 4, 7]}) for how in ["inner", "left", "right", "outer"]: joined = orders.merge(catalog, on="sku", how=how) print(how, len(joined), joined["sku"].tolist())
- inner keeps only rows whose key is in both tables. X and C disappear.
- left keeps every row of the left table. X stays, with
NaNfor its cost. - right keeps every row of the right table, so C stays, with no quantity.
- outer keeps everything from both.
For "add details to my main table", how="left" is almost always right. Your main table is the left one, and it should come out with the same number of rows it went in with. An inner join that quietly drops unmatched orders makes revenue look smaller, and nothing warns you.
import pandas as pd a = pd.DataFrame({"k": [1, 2, 2, 3]}) b = pd.DataFrame({"k": [2, 3, 4], "v": ["x", "y", "z"]}) print(len(a.merge(b, on="k")), len(a.merge(b, on="k", how="left")), len(a.merge(b, on="k", how="outer")))
Show the answer
3 4 5
Inner: the two 2s and the 3 match (3 rows). Left keeps all 4 rows of a. Outer adds b's unmatched 4: 5 rows.
Pick the join from what should happen to rows without a match. If you can't say, look at the unmatched rows first with indicator=True.
Two checks that make joins safe
indicator=True adds a _merge column saying where each row came from: both, left_only or right_only. Count it and you know straight away whether anything failed to match:
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() catalog = dataset("catalog") check = orders.merge(catalog[["sku", "cost"]], on="sku", how="left", indicator=True) print(check["_merge"].value_counts()) print(check[check["_merge"] == "left_only"]["sku"].unique()) doubled = pd.concat([catalog, catalog[catalog["sku"] == "TE-101"]]) try: orders.merge(doubled, on="sku", how="left", validate="many_to_one") except pd.errors.MergeError as err: print("Refused:", err)
The six unmatched lines are the staff test orders with SKU TEST-000, which isn't a product. The indicator found them without your knowing to look.
validate="many_to_one" promises that every key appears at most once on the right. If the catalog accidentally lists TE-101 twice, every TE-101 order line would match both copies and come out twice. That doubles its revenue without a single error message. With validate, pandas checks the promise and refuses to join. The other options are "one_to_one", "one_to_many" and "many_to_many".
Same names in both tables
The catalog and the product-lines table both have columns called name and category. Merge them and pandas can't keep both under the same name, so it adds suffixes: name_x and name_y. Code that later asks for name breaks, or worse, picks up the wrong one. Avoid it by choosing columns and renaming before the join:
named = lines.rename(columns={"name": "line"})[["line_id", "line"]]
catalog = catalog.merge(named, on="line_id", how="left", validate="many_to_one")
Now the catalog has a clear line column ("Ridgeline", "Ember" and so on) next to the product's own name. Bring only the columns you need into a join. It keeps the result readable and avoids clashes.
When the key has different names in the two tables, say sku in one and product_code in the other, use left_on="sku", right_on="product_code" instead of on.
Stacking tables with concat
merge puts tables side by side. pd.concat stacks tables that have the same columns on top of each other: this month's export under last month's, or one file per store into one table.
import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv") y2024 = orders[orders["date"] < "2025"] y2025 = orders[orders["date"] >= "2025"] print(len(y2024), len(y2025)) both = pd.concat([y2024, y2025], ignore_index=True) print(len(both), both.index[-1])
ignore_index=True numbers the stacked rows afresh, 0 to the end. Without it, each piece keeps its old row labels, and .loc can then match two rows at once. If the pieces' columns don't match exactly, concat still runs, but it fills the gaps with NaN. So check both.isna().sum() after stacking files from different sources.
Units by product line
Join the cleaned order lines to the catalog (for line_id) and to the product lines (for the line's name, renamed to line). Set units to a dictionary of total qty per line name. Use validate="many_to_one" on both joins.
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"] catalog, lines = dataset("catalog"), dataset("product-lines") units = {}
Rates need the right denominator
Portland earned more than twice what Seattle did. Is Portland's store better? Not necessarily: more people walk into it. Totals mostly measure size. To compare performance, divide by what each store had to work with, here the number of visits. That number is the denominator, and it usually lives in another table, so a rate is a join followed by a division:
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() orders["revenue"] = orders["qty"] * orders["unit_price"] stores = dataset("stores") totals = orders.groupby("store", as_index=False)["revenue"].sum() rates = totals.merge(stores, on="store", how="left", validate="one_to_one") rates["per_visit"] = (rates["revenue"] / (rates["yearly_visits"] * 2)).round(2) print(rates[["store", "revenue", "yearly_visits", "per_visit"]].sort_values("per_visit", ascending=False))
The orders cover two years and the visits are per year, so the denominator is visits × 2. Matching the time periods is part of choosing the right denominator. Per visit, Portland still leads, but Denver beats Seattle, and the web shop, the giant by total revenue, is last. Lots of people browse online without buying. The same total can tell opposite stories once it's divided by the right thing.
Worked example: profit by product line
Chain the joins with checks at every step, then answer the first half of the brief.
import pandas as pd def dataset(name): return pd.read_csv(f"https://api.gradient.test/v1/datasets/{name}.csv") orders = dataset("orders").drop_duplicates() orders = orders[orders["sku"] != "TEST-000"].copy() catalog, lines = dataset("catalog"), dataset("product-lines") orders["revenue"] = orders["qty"] * orders["unit_price"] before = len(orders) full = orders.merge(catalog[["sku", "cost", "line_id"]], on="sku", how="left", validate="many_to_one") full = full.merge(lines.rename(columns={"name": "line"})[["line_id", "line"]], on="line_id", how="left", validate="many_to_one") print(before, len(full), full["line"].isna().sum()) full["profit"] = full["revenue"] - full["qty"] * full["cost"] report = full.groupby("line").agg(revenue=("revenue", "sum"), profit=("profit", "sum")) report["margin_pct"] = (report["profit"] / report["revenue"] * 100).round(1) print(report.sort_values("profit", ascending=False).round(1))
The first print is the safety check: the row count before and after joining is the same, and no row is missing its line name. So nothing was dropped or doubled, and every SKU found its product line. Profit uses the price actually paid (unit_price, after member discounts) minus the catalog cost per unit. Ridgeline makes by far the most profit, because tents are expensive. Ember, the camp-kitchen line, has the best margin, but on a much smaller total. "Which line matters most?" depends on which of those two numbers the question is really about.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
You join order lines to a product table with how="inner" and the row count drops from 5,671 to 5,665. What's the most likely problem?