Merging and Joining Data (merge, join, concat)
Why merges matter
Most real analytics uses multiple tables:
- Customers table
- Orders table
- Products table
You typically need to combine them to answer questions.
Example tables
import pandas as pd
customers = pd.DataFrame({
"customer_id": [1, 2, 3],
"name": ["Asha", "Ravi", "Meera"],
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"customer_id": [1, 2, 2, 4],
"amount": [250, 180, 90, 300],
})
print(customers)
print(orders)import pandas as pd
customers = pd.DataFrame({
"customer_id": [1, 2, 3],
"name": ["Asha", "Ravi", "Meera"],
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"customer_id": [1, 2, 2, 4],
"amount": [250, 180, 90, 300],
})
print(customers)
print(orders)mergemerge: SQL-style joins
Inner join (only matching keys)
inner = customers.merge(orders, on="customer_id", how="inner")
print(inner)inner = customers.merge(orders, on="customer_id", how="inner")
print(inner)Left join (keep all left rows)
left = customers.merge(orders, on="customer_id", how="left")
print(left)left = customers.merge(orders, on="customer_id", how="left")
print(left)Outer join (keep everything)
outer = customers.merge(orders, on="customer_id", how="outer", indicator=True)
print(outer)outer = customers.merge(orders, on="customer_id", how="outer", indicator=True)
print(outer)indicator=Trueindicator=True adds a _merge_merge column so you can see where each row came from.
joinjoin: align by index
joinjoin is convenient when keys are indices.
customers_idx = customers.set_index("customer_id")
orders_idx = orders.set_index("customer_id")
joined = customers_idx.join(orders_idx, how="left")
print(joined)customers_idx = customers.set_index("customer_id")
orders_idx = orders.set_index("customer_id")
joined = customers_idx.join(orders_idx, how="left")
print(joined)concatconcat: stack DataFrames
Append rows (same columns)
jan = pd.DataFrame({"customer_id": [1, 2], "amount": [100, 200]})
feb = pd.DataFrame({"customer_id": [2, 3], "amount": [150, 50]})
all_orders = pd.concat([jan, feb], ignore_index=True)
print(all_orders)jan = pd.DataFrame({"customer_id": [1, 2], "amount": [100, 200]})
feb = pd.DataFrame({"customer_id": [2, 3], "amount": [150, 50]})
all_orders = pd.concat([jan, feb], ignore_index=True)
print(all_orders)Add columns side-by-side
df1 = pd.DataFrame({"A": [1, 2]})
df2 = pd.DataFrame({"B": [10, 20]})
combined = pd.concat([df1, df2], axis=1)
print(combined)df1 = pd.DataFrame({"A": [1, 2]})
df2 = pd.DataFrame({"B": [10, 20]})
combined = pd.concat([df1, df2], axis=1)
print(combined)Merge tips
- Use
validate=validate=to enforce expected relationships (e.g., one-to-many). - Watch for duplicate column names; use
suffixes=("_left", "_right")suffixes=("_left", "_right")when needed. - After a left join, missing matches become
NaNNaN—handle them deliberately.
Patching data with combine_first
mergemerge, joinjoin, and concatconcat all combine different rows or columns from separate
tables. Sometimes you instead have two versions of the same table, and you want
to patch holes in one using values from the other — wherever the first is missing,
fall back to the second.
numpy.where(pd.isna(a), b, a)numpy.where(pd.isna(a), b, a) can do this, but it ignores index labels entirely: it
just lines values up by position, so it silently produces the wrong answer if the two
objects aren’t already sorted the same way. combine_firstcombine_first does the same “fall back to
the other value” logic, but it aligns by label first, the same way arithmetic between
two Series does.
import pandas as pd
import numpy as np
primary = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"email": ["asha@mail.com", np.nan, "meera@mail.com", np.nan],
"phone": [np.nan, "9876500001", np.nan, "9876500004"],
}).set_index("customer_id")
backup = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"email": ["asha@old.com", "ravi@mail.com", "meera@old.com", "kabir@mail.com"],
"phone": ["9876500011", "9876500002", "9876500003", "9876500004"],
}).set_index("customer_id")
patched = primary.combine_first(backup)
print(patched)import pandas as pd
import numpy as np
primary = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"email": ["asha@mail.com", np.nan, "meera@mail.com", np.nan],
"phone": [np.nan, "9876500001", np.nan, "9876500004"],
}).set_index("customer_id")
backup = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"email": ["asha@old.com", "ravi@mail.com", "meera@old.com", "kabir@mail.com"],
"phone": ["9876500011", "9876500002", "9876500003", "9876500004"],
}).set_index("customer_id")
patched = primary.combine_first(backup)
print(patched)Every NaNNaN cell in primaryprimary gets filled in from backupbackup’s matching row and column —
customer 1 keeps their own email (it wasn’t missing) but borrows a phone number from
backupbackup, while customer 2 does the opposite. Where both sides have a value, primaryprimary’s
value always wins.
With DataFrames, combine_firstcombine_first works column by column, and the result has the
union of both objects’ columns — think of it as “patching,” not merging: it fills
gaps in your main table using a secondary source, rather than joining rows on a key.
Visualize it
An inner join keeps only matching keys; a left join keeps every row from the left table
and fills in NaNNaN where there’s no match on the right.
flowchart LR
A["customers"] --> M{"merge on customer_id"}
B["orders"] --> M
M -->|"how='inner'"| I["Only matching keys"]
M -->|"how='left'"| L["All of customers + matches"]
M -->|"how='outer'"| O["Everything, NaN where missing"]
🧪 Try It Yourself
Exercise 1 – Inner Join Two Tables
Exercise 2 – Left Join Keeps Everything on the Left
Exercise 3 – Stack Two DataFrames With concat
Next
With tables combined, you’re ready to work with time-based data — continue to Working with Dates and Times (to_datetime, dt accessor).
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
