Merging, Joining & Concatenating DataFrames
pd.concat(), how to combine them SQL-style with pd.merge() using inner, left, right, and outer joins, how .join() offers a shortcut for combining on the index, and a clear rule of thumb for picking the right tool.
pd.concat() — Stacking DataFrames
Real data rarely arrives as one tidy file. You might get a separate CSV export for every month, every region, or every data source — and the first job is often just gluing them into one DataFrame. pd.concat() is the tool for that: it stacks DataFrames together along an axis, with no key-matching logic involved.
import pandas as pd jan_sales = pd.DataFrame({ "product": ["Laptop", "Mouse", "Keyboard"], "sales": [1200, 45, 65], }) feb_sales = pd.DataFrame({ "product": ["Laptop", "Monitor"], "sales": [980, 910], }) # Default: axis=0, stacks rows on top of each other combined = pd.concat([jan_sales, feb_sales]) print(combined) # product sales # 0 Laptop 1200 # 1 Mouse 45 # 2 Keyboard 65 # 0 Laptop 980 ← index restarted from feb_sales, now duplicated # 1 Monitor 910
0 and 1 appear twice in the result above — each piece brought its own index along. That's rarely what you want when the row labels don't carry meaning. Pass ignore_index=True to have Pandas build a fresh, continuous index for the combined result.combined = pd.concat([jan_sales, feb_sales], ignore_index=True) print(combined) # product sales # 0 Laptop 1200 # 1 Mouse 45 # 2 Keyboard 65 # 3 Laptop 980 # 4 Monitor 910
You can also concatenate side by side instead of stacking, by setting axis=1. In that mode, Pandas aligns the pieces by their index rather than gluing rows on top of each other.
names = pd.DataFrame({"name": ["Ana", "Ben", "Cy"]})
scores = pd.DataFrame({"score": [88, 92, 79]})
side_by_side = pd.concat([names, scores], axis=1)
print(side_by_side)
# name score
# 0 Ana 88
# 1 Ben 92
# 2 Cy 79
axis=1, rows line up purely by matching index position/label — not by any shared column value. If the two DataFrames' indexes don't line up cleanly, you'll get NaNs where labels don't match. When you need to combine data based on a shared column of IDs, that's a job for pd.merge(), coming up next.pd.merge() — Inner Join
A merge combines two DataFrames based on matching values in one or more key columns — exactly like a SQL JOIN. We'll use the same pair of small DataFrames for every join type in this lesson, so you can see clearly how each one changes the result.
customers = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"name": ["Ana", "Ben", "Cy", "Deb"],
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"customer_id": [1, 2, 2, 5], # note: customer 5 doesn't exist in "customers"
"amount": [250, 80, 150, 60],
})
Notice on purpose: customers 3 and 4 have never placed an order, and order 104 belongs to a customer_id (5) that doesn't exist in customers at all. This lets each join type show a different result.
# how="inner" is the default — you can omit it inner = pd.merge(customers, orders, on="customer_id", how="inner") print(inner)
| customer_id | name | order_id | amount | |
|---|---|---|---|---|
| 0 | 1 | Ana | 101 | 250 |
| 1 | 2 | Ben | 102 | 80 |
| 2 | 2 | Ben | 103 | 150 |
Left Join & Right Join
Inner join drops anything that doesn't match. Often you want to keep every row from one side regardless of whether a match exists — that's what how="left" and how="right" are for.
# Keep EVERY row from customers (the "left" DataFrame) left = pd.merge(customers, orders, on="customer_id", how="left") print(left)
| customer_id | name | order_id | amount | |
|---|---|---|---|---|
| 0 | 1 | Ana | 101.0 | 250.0 |
| 1 | 2 | Ben | 102.0 | 80.0 |
| 2 | 2 | Ben | 103.0 | 150.0 |
| 3 | 3 | Cy | NaN | NaN |
| 4 | 4 | Deb | NaN | NaN |
Every customer survives the left join — customers 3 and 4 just get NaN in the order columns, since Pandas has nothing to fill them with. A right join is the mirror image: keep every row from the second DataFrame instead.
# Keep EVERY row from orders (the "right" DataFrame) right = pd.merge(customers, orders, on="customer_id", how="right") print(right)
| customer_id | name | order_id | amount | |
|---|---|---|---|---|
| 0 | 1 | Ana | 101 | 250 |
| 1 | 2 | Ben | 102 | 80 |
| 2 | 2 | Ben | 103 | 150 |
| 3 | 5 | NaN | 104 | 60 |
pd.merge(customers, orders, ...), customers is the "left" DataFrame (first argument) and orders is the "right" one (second argument) — that's all how="left" / how="right" means. It has nothing to do with column order in the output.Outer Join
An outer join keeps everything from both sides — the union of the two keys. Anywhere a match doesn't exist, Pandas fills with NaN. It's the safest choice when you want to make sure you don't silently lose any rows while exploring how two datasets relate.
outer = pd.merge(customers, orders, on="customer_id", how="outer") print(outer)
| customer_id | name | order_id | amount | |
|---|---|---|---|---|
| 0 | 1 | Ana | 101.0 | 250.0 |
| 1 | 2 | Ben | 102.0 | 80.0 |
| 2 | 2 | Ben | 103.0 | 150.0 |
| 3 | 3 | Cy | NaN | NaN |
| 4 | 4 | Deb | NaN | NaN |
| 5 | 5 | NaN | 104.0 | 60.0 |
pd.merge(customers, orders, on="customer_id", how="outer", indicator=True) adds a _merge column labelling every row "left_only", "right_only", or "both" — genuinely useful when you're auditing exactly which rows failed to match on either side..join() — Combining on the Index
.join() is a convenience method for the common case where you're combining DataFrames using their index rather than a shared column. It's shorter to write than the equivalent pd.merge() call once your data is indexed the right way.
customers_idx = customers.set_index("customer_id") # Total amount spent per customer, indexed by customer_id totals = orders.groupby("customer_id")["amount"].sum().rename("total_spent") # join() matches customers_idx's index against totals' index joined = customers_idx.join(totals) print(joined)
| customer_id | name | total_spent |
|---|---|---|
| 1 | Ana | 250.0 |
| 2 | Ben | 230.0 |
| 3 | Cy | NaN |
| 4 | Deb | NaN |
By default, .join() behaves like a left join (how="left"), keeping every row of customers_idx — which is why customer 5 from totals is dropped, and customers 3/4 get NaN. You can also skip the set_index() step entirely with the on parameter, matching a plain column in the caller against the other object's index:
# customers (not re-indexed) joined against totals' index, matched via customer_id result = customers.join(totals, on="customer_id") print(result) # You can override the join direction, just like with merge() customers_idx.join(totals, how="inner") # only customers who actually ordered customers_idx.join(totals, how="outer") # every customer AND every order id, union style
.join() is largely a thin, index-friendly wrapper around pd.merge(). The main differences that matter day to day: .join() defaults to matching on the index (and defaults to how="left"), while pd.merge() defaults to matching on shared column names (and defaults to how="inner").When to Use Which
All three tools combine DataFrames, but they solve different problems. Here's the decision that matters:
pd.merge() with the right how is how you turn that scattered data into one analyzable DataFrame, which is exactly what you'll practice in the Project 1 lesson next.Lesson Summary
Let's recap everything you learned in this lesson:
pd.concat() stacks DataFrames along an axis — rows by default (axis=0), columns with axis=1 — with no key matching.ignore_index=True gives the combined result a fresh, non-duplicated index.pd.merge() joins on key columns; inner keeps only matches, left/right keep all rows from one side, outer keeps everything from both.NaN in left/right/outer joins..join() is the shortcut for combining on the index, defaulting to a left join.pd.concat([df1, df2]) do by default?ignore_index=True to pd.concat()?pd.merge(customers, orders, on="customer_id", how="left"), what happens to a customer row with no matching order?.join() match on by default?Using the customers and orders DataFrames from Section 2, practice choosing the right combine tool for each question.
Write code that: (1) performs an inner merge of
customers and orders on customer_id and prints how many rows the result has, (2) performs a left merge and counts how many rows have a NaN in the amount column (i.e. customers who never ordered), and (3) builds a total_spent Series with groupby() + .sum() and attaches it to the customers table using .join(), filling any missing totals with 0 using .fillna().
Rules: Use
pd.merge() for steps 1–2 and .join() for step 3 — don't hand-build any of the results.
💡 Show hints if you're stuck
- Step 1:
pd.merge(customers, orders, on="customer_id", how="inner").shape[0] - Step 2:
pd.merge(customers, orders, on="customer_id", how="left")["amount"].isnull().sum() - Step 3:
totals = orders.groupby("customer_id")["amount"].sum().rename("total_spent"), thencustomers.set_index("customer_id").join(totals).fillna(0)