concat stacks tables. merge is a spreadsheet VLOOKUP / SQL JOIN: match rows on a shared key.
Stack compatible frames, inner/left/outer join on a key, and notice extra rows from duplicate keys.
concat rows
jan = pd.DataFrame({"city": ["Nairobi", "Mombasa"], "units": [12, 9]})
feb = pd.DataFrame({"city": ["Nairobi", "Kisumu"], "units": [5, 11]})
print(pd.concat([jan, feb], ignore_index=True))ignore_index=True rebuilds 0..n. Without it you get duplicate index labels.
concat columns
left = pd.DataFrame({"units": [12, 7]}, index=["A", "B"])
right = pd.DataFrame({"price": [10.5, 22.0]}, index=["A", "B"])
print(pd.concat([left, right], axis=1))Alignment is on the index.
merge inner (default)
orders = pd.DataFrame(
{
"order_id": [1, 2, 3, 4],
"user_id": [10, 11, 10, 12],
"total": [24.0, 18.5, 9.0, 40.0],
}
)
users = pd.DataFrame(
{
"user_id": [10, 11, 12, 99],
"name": ["Ada", "Alan", "Grace", "Nia"],
}
)
print(orders.merge(users, on="user_id"))Inner keeps keys present in both. User 99 disappears; orders for 10 appear twice (two orders).
Left, right, outer
orders = pd.DataFrame(
{
"order_id": [1, 2, 3, 4],
"user_id": [10, 11, 10, 12],
"total": [24.0, 18.5, 9.0, 40.0],
}
)
users = pd.DataFrame(
{
"user_id": [10, 11, 12, 99],
"name": ["Ada", "Alan", "Grace", "Nia"],
}
)
print("left — keep all orders")
print(orders.merge(users, on="user_id", how="left"))
print()
print("right — keep all users")
print(orders.merge(users, on="user_id", how="right"))
print()
print("outer — keep everything")
print(orders.merge(users, on="user_id", how="outer", indicator=True))indicator=True adds _merge with left_only / right_only / both — the fastest way to debug a join.
Different column names
cities = pd.DataFrame({"town": ["Nairobi", "Mombasa"], "region": ["Central", "Coast"]})
sales = pd.DataFrame({"city": ["Nairobi", "Mombasa", "Kisumu"], "units": [12, 9, 11]})
print(sales.merge(cities, left_on="city", right_on="town", how="left"))Kisumu has no region — NaN.
Overlapping column names
a = pd.DataFrame({"id": [1, 2], "score": [10, 20]})
b = pd.DataFrame({"id": [1, 2], "score": [11, 18]})
print(a.merge(b, on="id", suffixes=("_old", "_new")))Duplicate keys explode rows
sales = pd.DataFrame({"city": ["Nairobi", "Nairobi"], "units": [12, 5]})
lookup = pd.DataFrame({"city": ["Nairobi", "Nairobi"], "note": ["HQ", "branch"]})
print(sales.merge(lookup, on="city"))Two × two = four rows. drop_duplicates on the lookup before merge if the key should be unique.
Practice joins sales_log.csv to products.csv and regions.csv. Validate with indicator=True and print(out["_merge"].value_counts()).