All 57 lessons
Lesson 33 of 57 · Data Analysis with Pandas
Pandas merge, join and concat: Combining Tables
By Skillancy Editorial Team
Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).
Real data lives in several tables. Orders know the customer id and the store id, but not the customer's city or the store's manager. Order items know the product id, but not the product's category. To answer a question like "which product category sells best in which city to Prime customers?" you must join the tables together using the columns they share. If you know SQL, merge is JOIN, and concat is UNION ALL.
Joins are also the number one source of silent errors in analysis. A join on a key with duplicates can multiply your rows and inflate your totals without any error message. A join on the wrong key can drop half your data. So in this lesson we not only learn the mechanics (inner, left, right, outer) but also the habits that protect you: checking row counts before and after, using validate, and using indicator to see what failed to match.
Worked examples
Example 1: Know your tables
Question: How do the FreshBasket tables connect?
import pandas as pd
import numpy as np
orders = pd.read_csv("orders.csv")
items = pd.read_csv("order_items.csv")
products = pd.read_csv("products.csv")
customers = pd.read_csv("customers.csv")
stores = pd.read_csv("stores.csv")
for name, t in [("orders", orders), ("items", items), ("products", products), ("customers", customers), ("stores", stores)]:
print(f"{name:<10}{t.shape[0]:>7,} rows {list(t.columns)[:5]}")
print(orders["order_id"].is_unique, items["order_id"].is_unique)
print(products["product_id"].is_unique, customers["customer_id"].is_unique)Output
orders 6,000 rows ['order_id', 'customer_id', 'store_id', 'order_date', 'delivery_slot']
items 19,962 rows ['order_id', 'product_id', 'quantity', 'unit_price']
products 50 rows ['product_id', 'product_name', 'category', 'brand', 'unit_price']
customers 600 rows ['customer_id', 'name', 'email', 'city', 'signup_date']
stores 11 rows ['store_id', 'city', 'store_name', 'manager', 'opened_on']
True False
True TrueHow it works: Each table has a key. orders.order_id, products.product_id, customers.customer_id and stores.store_id are unique (one row per entity). order_items repeats order_id (many lines per order) and refers to product_id. The shape of these relationships decides what a join does to the row count.
Reading the result: Orders have a unique id (True), but order items do not (False), because an order has several lines. Joining orders to items is therefore one-to-many: the result will have as many rows as items.
Example 2: Inner join
Question: Add the store's city and manager to each order.
joined = orders.merge(stores, on="store_id", how="inner")
print(orders.shape, joined.shape)
print(joined[["order_id", "store_id", "city", "manager"]].head(3).to_string(index=False))
print(joined["city"].value_counts().head(3).to_dict())Output
(6000, 9) (6000, 13)
order_id store_id city manager
O02299 S03 Mumbai Meera Verma
O04533 S07 Hyderabad Karan Nair
O00778 S09 Pune Rohan Kumar
{'Bengaluru': 1301, 'Delhi': 1291, 'Mumbai': 945}How it works: merge(right, on=key, how="inner") keeps only the rows whose key exists in both tables. Here every order has a valid store, so no rows are lost, and since stores has unique store_id, the row count stays the same. This is a many-to-one join (many orders per store), the safest and most common type.
Reading the result: All 6,000 orders kept their place, and each now carries the store's city and manager. The busiest city by orders is Bengaluru.
Example 3: Left join and missing matches
Question: What happens when keys do not match?
small_products = products.head(30)
left = items.merge(small_products[["product_id", "product_name"]], on="product_id", how="left")
inner = items.merge(small_products[["product_id", "product_name"]], on="product_id", how="inner")
print(len(items), len(left), len(inner))
print(left["product_name"].isna().sum())
print(left[left["product_name"].isna()]["product_id"].nunique())Output
19962 19962 13056
6906
20How it works: A left join keeps every row of the left table; where the right table has no match, its columns are filled with NaN. An inner join drops unmatched rows. With only the first 30 products on the right side, a left join keeps all 19962 lines but leaves names blank for the others, while an inner join silently shrinks the table.
Reading the result: The left join kept 19,962 rows with 6,906 missing names; the inner join kept only 13,056. When you want to preserve all the left-hand data, use how="left".
Example 4: Right and outer joins
Question: Which products have never been sold, and what does a full outer join show?
sold = items.groupby("product_id", as_index=False)["quantity"].sum()
right = sold.merge(products, on="product_id", how="right")
print(right["quantity"].isna().sum())
outer = sold.merge(small_products, on="product_id", how="outer", indicator=True)
print(outer["_merge"].value_counts().to_dict())
print(len(sold), len(products))Output
0
{'both': 30, 'left_only': 20, 'right_only': 0}
50 50How it works: A right join keeps every row from the right table, and a full outer join keeps rows from both. The indicator=True option adds a _merge column showing whether each row came from left_only, right_only or both. That column is the quickest way to audit a join.
Reading the result: All 50 products have sold something (0 with no sales), and the outer join against only 30 products reveals 20 sold products that are missing from the smaller table.
Example 5: Different key names and several keys
Question: Join on columns with different names, and on two columns.
cust = customers.rename(columns={"customer_id": "cust_id"})
a = orders.merge(cust[["cust_id", "city", "is_prime"]], left_on="customer_id", right_on="cust_id", how="left")
print(a.shape, a["city"].isna().sum())
print(a.columns.tolist()[-4:])
a = a.drop(columns="cust_id")
daily = orders.groupby(["store_id", "order_date"]).size().rename("orders").reset_index()
targets = daily.head(5).assign(target=10)
two_keys = daily.merge(targets[["store_id", "order_date", "target"]], on=["store_id", "order_date"], how="left")
print(two_keys["target"].notna().sum())Output
(6000, 12) 0
['discount_pct', 'cust_id', 'city', 'is_prime']
5How it works: left_on and right_on name the key in each table when the names differ; both key columns survive, so drop one. Passing a list to on matches rows only when all keys agree, as in SQL's composite join conditions.
Reading the result: Every order found its customer (0 unmatched), and the two-key join attached targets to exactly 5 store-days.
Example 6: Overlapping column names
Question: Both tables have a city column. What happens?
cust_small = customers[["customer_id", "city"]]
store_city = stores[["store_id", "city"]]
j = orders.merge(cust_small, on="customer_id").merge(store_city, on="store_id", suffixes=("_customer", "_store"))
print(j.columns.tolist())
print(j[["customer_id", "city_customer", "store_id", "city_store"]].head(3).to_string(index=False))
same = (j["city_customer"] == j["city_store"]).mean()
print(round(same, 3))Output
['order_id', 'customer_id', 'store_id', 'order_date', 'delivery_slot', 'payment_method', 'order_status', 'delivery_minutes', 'discount_pct', 'city_customer', 'city_store']
customer_id city_customer store_id city_store
C0078 Mumbai S03 Mumbai
C0230 Hyderabad S07 Hyderabad
C0328 Pune S09 Pune
1.0How it works: When the two sides share non-key column names, pandas appends _x and _y, or the suffixes you provide. Be explicit: meaningful names such as city_customer and city_store prevent confusion. Note the first merge had only one city, so no suffix was needed there; the clash appeared on the second merge.
Reading the result: A customer and the store serving them are in the same city 100.0% of the time, so the two columns agree here. Keeping both under clear suffixes is what lets you run a check like this.
Example 7: validate and the row explosion
Question: How can a join accidentally multiply rows, and how do you prevent it?
dup_products = pd.concat([products, products.head(5)], ignore_index=True)
print(dup_products["product_id"].is_unique)
before = len(items)
bad = items.merge(dup_products[["product_id", "category"]], on="product_id", how="left")
print(before, len(bad), len(bad) - before)
try:
items.merge(dup_products[["product_id", "category"]], on="product_id", how="left", validate="many_to_one")
except pd.errors.MergeError as e:
print("MergeError:", str(e)[:70])
good = items.merge(products[["product_id", "category"]], on="product_id", how="left", validate="many_to_one")
print(len(good) == before)Output
False
19962 20998 1036
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
D
TrueHow it works: When the key is duplicated on the right side, each matching left row is repeated once per duplicate, so five duplicate products inflated the table. Revenue totals computed from it would be wrong without any warning. validate="many_to_one" (also one_to_one, one_to_many, many_to_many) makes pandas raise a MergeError when the data does not have the shape you expect. Use it routinely.
Reading the result: The duplicated key added 1036 extra rows (19962 became 20998), but the validated merge raised an error and the clean merge preserved all 19,962 rows. Always compare row counts before and after a join.
Example 8: Anti-join
Question: Which customers never placed an order?
m = customers.merge(orders[["customer_id"]].drop_duplicates(), on="customer_id", how="left", indicator=True)
never = m[m["_merge"] == "left_only"]
print(len(never), len(customers))
alt = customers[~customers["customer_id"].isin(orders["customer_id"])]
print(len(alt), set(never["customer_id"]) == set(alt["customer_id"]))
print(never[["customer_id", "signup_date", "acquisition_channel"]].head(3).to_string(index=False))Output
12 600
12 True
customer_id signup_date acquisition_channel
C0010 2024-08-26 Referral
C0037 2025-11-27 Offline Campaign
C0088 2025-01-04 ReferralHow it works: An anti-join returns rows from one table that have no match in another. With pandas, do a left join with indicator=True and keep left_only, or use ~isin. Both give the same answer. In SQL this is LEFT JOIN ... WHERE right.key IS NULL or NOT EXISTS.
Reading the result: 12 of the 600 customers have never ordered, and the two methods agree. These are candidates for an activation campaign.
Example 9: join on the index
Question: Use the index for joining and combining Series.
store_orders = orders.groupby("store_id").size().rename("orders")
store_avg = orders.groupby("store_id")["delivery_minutes"].mean().round(1).rename("avg_minutes")
combined = store_orders.to_frame().join(store_avg).join(stores.set_index("store_id")[["city"]])
print(combined.head(4))
table = pd.concat([store_orders, store_avg], axis=1)
print(table.shape, table.equals(combined[["orders", "avg_minutes"]]))Output
orders avg_minutes city
store_id
S01 682 23.6 Bengaluru
S02 619 24.0 Bengaluru
S03 457 23.5 Mumbai
S04 488 23.6 Mumbai
(11, 2) TrueHow it works: DataFrame.join merges on the index by default, which is concise when the grouped results already have the key as their index. pd.concat(..., axis=1) lines up pieces side by side on their index. Choose whichever reads better; merge is more explicit.
Reading the result: The combined table has one row per store (11) with orders, average minutes and city, built without writing a single key name.
Example 10: concat to stack tables
Question: Combine monthly files into one table.
orders["order_date"] = pd.to_datetime(orders["order_date"])
parts = {m: g for m, g in orders.groupby(orders["order_date"].dt.to_period("M"))}
print(len(parts))
stacked = pd.concat(parts.values(), ignore_index=True)
print(len(stacked) == len(orders), stacked.shape)
jan = parts[pd.Period("2025-01")]
feb = parts[pd.Period("2025-02")]
labelled = pd.concat([jan, feb], keys=["jan", "feb"])
print(labelled.groupby(level=0).size().to_dict())
mismatch = pd.concat([jan[["order_id"]], feb[["order_id", "store_id"]]])
print(mismatch.shape, mismatch["store_id"].isna().sum() == len(jan))Output
18
True (6000, 9)
{'feb': 67, 'jan': 66}
(133, 2) TrueHow it works: concat stacks tables vertically (default axis=0). ignore_index=True renumbers rows, and keys= adds an outer index level to remember where each piece came from. Columns are aligned by name: columns missing from one piece are filled with NaN, as the last example shows. Concatenating many files into a list first and calling concat once is much faster than appending in a loop.
Reading the result: The 18 monthly pieces stack back into the full 6,000 rows. The labelled January and February pieces contain 66 and 67 orders respectively.
Common mistakes
1. Not checking row counts before and after a join.
2. Joining on a non-unique key without validate, inflating totals.
3. Using inner when you meant left, silently dropping data.
4. Forgetting that key dtypes must match (a string id will not match an integer id).
5. Appending to a DataFrame in a loop instead of collecting pieces and calling concat once.
orders.merge(stores, on="no_such_key")Output
Traceback (most recent call last):
File "example.py", line 1, in <module>
orders.merge(stores, on="no_such_key")
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "example.py", line 12902, in merge
return merge(
self,
...<10 lines>...
validate=validate,
)
File "example.py", line 385, in merge
op = _MergeOperation(
left_df,
...<10 lines>...
validate=validate,
)
File "example.py", line 1018, in __init__
) = self._get_merge_keys()
~~~~~~~~~~~~~~~~~~~~^^
File "example.py", line 1620, in _get_merge_keys
right_keys.append(right._get_label_or_level_values(rk))
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^
File "example.py", line 1764, in _get_label_or_level_values
raise KeyError(key)
KeyError: 'no_such_key'Practice questions
Practice 1 (Easy): Add the category to the items
Merge items with products to add category, and count the lines per category.
Show solution
with_cat = items.merge(products[["product_id", "category"]], on="product_id", validate="many_to_one")
print(with_cat["category"].value_counts())Output
category
Snacks 4125
Dairy 4036
Fruits & Veg 3060
Staples 2663
Household 2382
Beverages 1944
Personal Care 1752
Name: count, dtype: int64The merge keeps 19,962 rows. The most common category is Snacks.
Practice 2 (Medium): Revenue by city
Compute the revenue (quantity × unit price) of delivered orders per city, ranked highest first.
Show solution
d = orders[orders["order_status"] == "Delivered"][["order_id", "store_id"]]
rev = (
items.merge(d, on="order_id", validate="many_to_one")
.merge(stores[["store_id", "city"]], on="store_id", validate="many_to_one")
)
rev["value"] = rev["quantity"] * rev["unit_price"]
by_city = rev.groupby("city")["value"].sum().sort_values(ascending=False)
print(by_city.round(0))Output
city
Delhi 746553.0
Bengaluru 742990.0
Mumbai 585420.0
Hyderabad 429685.0
Pune 304967.0
Chennai 261780.0
Kolkata 239062.0
Ahmedabad 223377.0
Name: value, dtype: float64The inner joins keep only items of delivered orders. Delhi leads with ₹746,553.
Practice 3 (Hard): Customers acquired but inactive in 2026
List the customers who ordered in 2025 but placed no order in 2026, with their city and acquisition channel, and count them by channel.
Show solution
y = orders["order_date"].dt.year
in25 = orders.loc[y == 2025, "customer_id"].drop_duplicates()
in26 = orders.loc[y == 2026, "customer_id"].drop_duplicates()
lapsed = in25.to_frame().merge(in26.to_frame(), on="customer_id", how="left", indicator=True)
lapsed = lapsed[lapsed["_merge"] == "left_only"].drop(columns="_merge")
lapsed = lapsed.merge(customers[["customer_id", "city", "acquisition_channel"]], on="customer_id")
print(len(lapsed))
print(lapsed["acquisition_channel"].value_counts())Output
33
acquisition_channel
Instagram 10
App Store 9
Referral 7
Google Ads 4
Offline Campaign 3
Name: count, dtype: int64An anti-join finds the 2025 customers missing from 2026, and a normal join adds their attributes. 33 customers lapsed; the biggest channel among them is Instagram.
Key takeaways
mergejoins tables on key columns; chooseinner,left,rightorouterdeliberately.- Many-to-one joins keep the row count; duplicate keys on the right side multiply rows.
- Protect yourself with
validate=andindicator=True, and compare row counts before and after. - An anti-join (
left_only) finds records with no match. - Use
suffixesfor clashing names andleft_on/right_onfor different key names. concatstacks tables vertically (or side by side), aligning on column names.
Frequently asked questions
What is the difference between merge and join?
merge joins on columns (or index) with explicit keys and is the general tool. join is a convenience that joins on the index by default.
Which join type should I use?
Use left when one table is your "base" and you want to add information without losing rows. Use inner when you only want matched rows. outer is for reconciliation.
How do I join on a date range?
Pandas has merge_asof for nearest-time joins and you can cross-merge then filter for small tables. For large range joins, consider a database.
Why do I get _x and _y columns?
Both tables contained a non-key column with the same name. Rename columns beforehand or pass suffixes.
What does how="cross" do?
It returns every combination of rows from both tables (a Cartesian product), useful for generating grids such as every store × every date.
Can I merge on columns with different types?
No; cast them first (for example, astype(str)), otherwise pandas raises an error or, worse, matches nothing.
Keep learning Python
One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Ready to practise? Try the Python interview questions