🐼 Section 2 · Pandas 🟡 Intermediate MODULE 12

Merging, Joining & Concatenating DataFrames

⏱️ 27 min read
📖 Theory + Code
🧩 5 Quiz Questions
🏗️ 1 Challenge
Your progress in Section 283%
🎯 What you'll learn: How to stack DataFrames together with 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.

concat_rows.py
PYTHON
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
⚠️
concat() keeps each DataFrame's original index
Notice the index labels 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.
concat_ignore_index.py
PYTHON
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.

concat_columns.py
PYTHON
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
📝
concat() has no "key" — it just glues
With 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.

merge_setup.py
PYTHON
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.

merge_inner.py
PYTHON
# how="inner" is the default — you can omit it
inner = pd.merge(customers, orders, on="customer_id", how="inner")
print(inner)
pd.merge(customers, orders, on="customer_id", how="inner")
customer_idnameorder_idamount
01Ana101250
12Ben10280
22Ben103150
Inner join = only what matches on both sides
Customers 3 and 4 vanished (no orders to match), and the customer_id-5 order vanished too (no matching customer). Customer 2 appears twice, once per matching order — a merge can multiply rows when the key isn't unique on one side.

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.

merge_left.py
PYTHON
# Keep EVERY row from customers (the "left" DataFrame)
left = pd.merge(customers, orders, on="customer_id", how="left")
print(left)
pd.merge(customers, orders, on="customer_id", how="left")
customer_idnameorder_idamount
01Ana101.0250.0
12Ben102.080.0
22Ben103.0150.0
33CyNaNNaN
44DebNaNNaN

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.

merge_right.py
PYTHON
# Keep EVERY row from orders (the "right" DataFrame)
right = pd.merge(customers, orders, on="customer_id", how="right")
print(right)
pd.merge(customers, orders, on="customer_id", how="right")
customer_idnameorder_idamount
01Ana101250
12Ben10280
22Ben103150
35NaN10460
📝
"left" and "right" refer to argument position
In 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.

merge_outer.py
PYTHON
outer = pd.merge(customers, orders, on="customer_id", how="outer")
print(outer)
pd.merge(customers, orders, on="customer_id", how="outer")
customer_idnameorder_idamount
01Ana101.0250.0
12Ben102.080.0
22Ben103.0150.0
33CyNaNNaN
44DebNaNNaN
55NaN104.060.0
⚠️
Add indicator=True to see where each row came from
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.

join_on_index.py
PYTHON
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)
customers_idx.join(totals)
customer_idnametotal_spent
1Ana250.0
2Ben230.0
3CyNaN
4DebNaN

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:

join_with_on.py
PYTHON
# 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() vs. merge() — same engine, different defaults
Under the hood, .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.concat()
Same-shaped pieces that just need to be glued together — monthly exports, regional files, or a quick side-by-side. No key matching involved.
🔗
pd.merge()
Relational, SQL-style combining on one or more key columns, with full control over inner/left/right/outer behaviour. The general-purpose tool.
📇
.join()
A shortcut for merge() when the key you're matching on is already (or easily becomes) the index of one or both DataFrames.
🎯
Rule of thumb
Stacking similar rows/columns → concat(). Matching on a column of IDs → merge(). Already indexed by the match key → join().
You'll use pd.merge() the most in real projects
Most real datasets are spread across multiple tables — a sales table, a customers table, a products table — connected by ID columns. 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.
Rows with no match get filled with NaN in left/right/outer joins.
.join() is the shortcut for combining on the index, defaulting to a left join.
🧩 Knowledge Check — Lesson 12
Answer all 5 questions to test your understanding. Instant feedback on every answer.
1. What does pd.concat([df1, df2]) do by default?
2. Why pass ignore_index=True to pd.concat()?
3. Which merge type keeps only rows where the key exists in both DataFrames?
4. In pd.merge(customers, orders, on="customer_id", how="left"), what happens to a customer row with no matching order?
5. What does .join() match on by default?
💪
Coding Challenge — Lesson 12
Apply what you learned · Intermediate Level

Using the customers and orders DataFrames from Section 2, practice choosing the right combine tool for each question.

Challenge: Customer Order Report 🧾

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"), then customers.set_index("customer_id").join(totals).fillna(0)
Finished this lesson?
Mark it complete to track your progress.
🎉

Lesson 12 Complete!

You can now combine DataFrames with concat, merge, and join. Next up: putting all of Section 2 together in a real exploratory data analysis project.

Module 12 of 13 Section 2 — Pandas: Data Analysis Powerhouse