Skip to main content
Pandas intermediate Lesson 6 of 11

Pandas Joins and Merges

Combine DataFrames with merge, join, and concat — covering inner, outer, left, right joins, and handling key conflicts.

Real-World Scenario

A data engineer builds a customer analytics report by combining data from three different sources: an orders table, a customers table, and a products table — all from different systems with different schemas. This is a join problem. Knowing which join type to use, how to handle mismatched keys, and how to debug unexpected row counts prevents silent data quality issues in production.

Merge (SQL-Style Joins)

import pandas as pd

customers = pd.DataFrame({
    "customer_id": [1, 2, 3, 4, 5],
    "name":        ["Alice", "Bob", "Carol", "Dave", "Eve"],
    "country":     ["US", "UK", "US", "DE", "US"],
})

orders = pd.DataFrame({
    "order_id":    [101, 102, 103, 104, 105, 106],
    "customer_id": [1, 2, 1, 3, 6, 4],   # customer 6 doesn't exist; customer 5 has no orders
    "amount":      [250, 180, 90, 420, 310, 150],
})

# INNER JOIN — only rows where key exists in both DataFrames
inner = pd.merge(customers, orders, on="customer_id", how="inner")
print(f"Inner: {len(inner)} rows")   # 5 (customer 5 dropped, customer 6 dropped)
print(inner)

# LEFT JOIN — all customers, NaN for customers with no orders
left = pd.merge(customers, orders, on="customer_id", how="left")
print(f"Left: {len(left)} rows")     # 6 (Eve has NaN order columns)

# RIGHT JOIN — all orders, NaN for orders with unknown customers
right = pd.merge(customers, orders, on="customer_id", how="right")
print(f"Right: {len(right)} rows")   # 6 (order 105 has NaN customer columns)

# OUTER JOIN — all rows from both, NaN where no match
outer = pd.merge(customers, orders, on="customer_id", how="outer")
print(f"Outer: {len(outer)} rows")   # 7

Joining on Different Column Names

import pandas as pd

users = pd.DataFrame({
    "user_id": [1, 2, 3],
    "name":    ["Alice", "Bob", "Carol"],
})

events = pd.DataFrame({
    "uid":      [1, 1, 2, 3, 3, 3],
    "event":    ["login", "purchase", "login", "login", "purchase", "logout"],
    "amount":   [0, 120, 0, 0, 340, 0],
})

# left_on / right_on when key column names differ
result = pd.merge(users, events, left_on="user_id", right_on="uid", how="left")
result.drop(columns=["uid"], inplace=True)   # remove duplicate key column
print(result)

Multi-Key Joins

import pandas as pd

inventory = pd.DataFrame({
    "warehouse": ["A", "A", "B", "B"],
    "product":   ["Widget", "Gadget", "Widget", "Gadget"],
    "stock":     [100, 50, 200, 80],
})

prices = pd.DataFrame({
    "warehouse": ["A", "A", "B"],
    "product":   ["Widget", "Gadget", "Widget"],
    "unit_price":[9.99, 24.99, 9.99],
})

# Join on both warehouse and product columns
merged = pd.merge(inventory, prices, on=["warehouse", "product"], how="left")
print(merged)
# B/Gadget has no price — NaN in unit_price

Validating Join Assumptions

import pandas as pd

products = pd.DataFrame({
    "product_id": [1, 2, 3],
    "name":       ["Widget", "Gadget", "Gizmo"],
})

order_lines = pd.DataFrame({
    "line_id":    [1, 2, 3, 4],
    "product_id": [1, 2, 1, 3],
    "quantity":   [2, 1, 5, 3],
})

# Validate: products is unique, orders has many rows per product → one_to_many
try:
    result = pd.merge(
        products, order_lines,
        on="product_id",
        how="inner",
        validate="one_to_many",   # raises if products has duplicate product_ids
    )
    print(f"Validated merge: {len(result)} rows")
except pd.errors.MergeError as e:
    print(f"Merge validation failed: {e}")

Concatenating DataFrames

import pandas as pd

# Stack DataFrames vertically (add rows)
q1 = pd.DataFrame({"date": ["2024-01-01", "2024-02-01"], "revenue": [10000, 12000]})
q2 = pd.DataFrame({"date": ["2024-04-01", "2024-05-01"], "revenue": [15000, 13000]})

full_year = pd.concat([q1, q2], ignore_index=True)  # reset index to 0,1,2,3
print(full_year)

# Concatenate with a key to identify the source
combined = pd.concat(
    {"Q1": q1, "Q2": q2},
    names=["quarter", "original_idx"]
)
print(combined)

# Concatenate horizontally (add columns) — use merge for key-aligned joins
df1 = pd.DataFrame({"a": [1, 2, 3]})
df2 = pd.DataFrame({"b": [4, 5, 6]})
side_by_side = pd.concat([df1, df2], axis=1)
print(side_by_side)

Real-World: Building an Analytics Dataset from Three Tables

import pandas as pd
import numpy as np

rng = np.random.default_rng(42)

# Three source tables — as if loaded from a database
customers = pd.DataFrame({
    "customer_id": range(1, 101),
    "name":        [f"Customer_{i}" for i in range(1, 101)],
    "country":     rng.choice(["US", "UK", "DE", "FR"], 100),
    "tier":        rng.choice(["Bronze", "Silver", "Gold", "Platinum"], 100),
})

orders = pd.DataFrame({
    "order_id":    range(1001, 1501),
    "customer_id": rng.integers(1, 101, 500),
    "product_id":  rng.integers(1, 21, 500),
    "quantity":    rng.integers(1, 10, 500),
    "order_date":  pd.date_range("2024-01-01", periods=500, freq="h").date,
})

products = pd.DataFrame({
    "product_id": range(1, 21),
    "name":       [f"Product_{i}" for i in range(1, 21)],
    "category":   rng.choice(["Electronics", "Accessories", "Software"], 20),
    "unit_price": rng.uniform(10, 500, 20).round(2),
})

# Step 1: Join orders → products to get price per order line
orders_with_price = pd.merge(
    orders, products[["product_id", "unit_price", "category"]],
    on="product_id", how="left", validate="many_to_one"
)
orders_with_price["revenue"] = orders_with_price["quantity"] * orders_with_price["unit_price"]

# Step 2: Join → customers to get customer attributes
full = pd.merge(
    orders_with_price,
    customers[["customer_id", "country", "tier"]],
    on="customer_id", how="left", validate="many_to_one"
)

# Step 3: Aggregate to customer-level metrics
customer_summary = full.groupby("customer_id").agg(
    total_orders  = ("order_id",  "count"),
    total_revenue = ("revenue",   "sum"),
    avg_order_val = ("revenue",   "mean"),
    categories    = ("category",  "nunique"),
).reset_index()

# Step 4: Join summary back to customer table
result = pd.merge(customers, customer_summary, on="customer_id", how="left")
result["total_orders"].fillna(0, inplace=True)

print(result.head(10))
print(f"\nTotal revenue: ${result['total_revenue'].sum():,.2f}")

Frequently Asked Questions

What is the difference between merge and join in Pandas?
merge() joins on column values (like SQL JOIN) and is the general-purpose function. join() joins on index values and is shorthand for a common merge pattern. For most work, use merge() — it's more explicit about which columns are the keys.
How do I detect and handle duplicate keys in a merge?
A many-to-many merge happens silently if both DataFrames have duplicate keys — the result can be larger than expected. Use validate='one_to_one', 'one_to_many', or 'many_to_one' in merge() to raise an error if the key relationship isn't what you expect.