EduFlow

Section 4 · Analysis: Group, Join, Reshape

Joining Tables with merge

20 min3 min readPython exercise

Lesson at a glance

Combine tables with merge, choose the right join type, protect yourself from duplicated rows with validate= and row counts, and use indicator=True to find records with no match.

  1. 1A first merge
  2. 2Join types
  3. 3Finding what did not match
  4. 4Same column name on both sides

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The head of growth’s question needs columns from four tables. Revenue lives in order_items. Whether an order was cancelled, and when it was placed, lives in orders. The product’s category lives in products, and the customer’s country in customers. In a spreadsheet you would write lookup formulas; in pandas you merge. Merges are where most silent errors in analysis happen, so this lesson spends as much time on checking a merge as on writing one.

A first merge

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])

lines = items.merge(orders, on="order_id", how="left", validate="many_to_one")
print(len(items), "->", len(lines))
print(lines[["order_item_id", "order_id", "revenue", "order_date", "status"]].head(3))

For each row of the left table, pandas looks up the row of orders with the same order_id and copies its columns across. Many item lines point to one order, so the relationship is many to one, and validate="many_to_one" tells pandas to check that: if orders had two rows for one id, the merge would raise MergeError instead of quietly doubling those items. The row count before and after is the second check. A left merge onto a unique key never changes it.

Now revenue can finally respect the business rule you postponed in Lesson 2.5: cancelled orders are not revenue.

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])
lines = items.merge(orders, on="order_id", how="left", validate="many_to_one")

kept = lines[lines["status"] != "cancelled"]
print(round(kept["revenue"].sum(), 2), "revenue excluding cancelled orders")

446,666.32 dollars. This is the revenue definition for the rest of the course: line revenue after discounts, excluding shipping fees and cancelled orders. Returned orders stay in, because their refunds are tracked separately in returns. Write definitions like this down where readers will see them; a number without its definition invites an argument.

When the key has a different name on each side, name both: left_on="customer_id", right_on="referred_by" in a merge of customers with itself matches each customer to the people they referred. And pick the key that identifies what you are matching. Returns belong to order lines, so returns joins order_items on order_item_id; joining it on order_id instead would attach each refund to every line of the order.

Join types

how decides what happens to keys that exist on only one side:

Left table keys A, B, C and right table keys B, C, D. Inner keeps B and C. Left keeps A, B, C with A's right columns empty. Right keeps B, C, D. Outer keeps A, B, C, D. left A B C right B C D inner BC left A + NaNBC right BCNaN + D outer A + NaNBCNaN + D missing partners are filled with NaN
Four join types, by which keys survive

inner (the default) keeps only keys found on both sides. left keeps every left row, filling the right-hand columns with NaN where there is no partner. right is the mirror image, and outer keeps everything. For adding information to a table of facts, such as orders, items or sessions, use how="left" with the fact table on the left: you never lose a fact because a lookup table is incomplete, and the NaNs show you exactly where it is.

Finding what did not match

indicator=True adds a _merge column recording where each row came from:

Python
import pandas as pd

customers = pd.read_csv("customers.csv")
orders = pd.read_csv("orders.csv")
buyers = orders[["customer_id"]].drop_duplicates()

check = customers.merge(buyers, on="customer_id", how="left", indicator=True)
print(check["_merge"].value_counts())
never_ordered = check[check["_merge"] == "left_only"]
print(len(never_ordered), "customers never ordered")

85 customers signed up and never bought anything, the same number you found with nunique in Lesson 2.2, now as a list of names you could hand to the marketing team. indicator=True is also the fastest audit of any merge: right_only rows in a left join are impossible, and a surprising number of left_only rows means keys that should match do not, often because of types (int on one side, text on the other) or untrimmed spaces.

Same column name on both sides

order_items and products both have a unit_price column: the price charged and the list price. pandas adds suffixes to tell them apart, and you can choose them:

Python
import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
products = pd.read_csv("products.csv")

lines = (
    items.merge(orders[["order_id", "order_date"]], on="order_id", validate="many_to_one")
    .merge(products[["product_id", "name", "unit_price"]], on="product_id",
           suffixes=("_charged", "_list"), validate="many_to_one")
)
lines["price_ratio"] = (lines["unit_price_charged"] / lines["unit_price_list"]).round(2)
print(lines.groupby(lines["order_date"] >= "2025-03-01")["price_ratio"].agg(["min", "max"]))

Before March 2025, every line was charged exactly the list price; from 1 March, every line was charged 5% more. The merge has just confirmed the price rise from the data alone, and it is one of the suspects for the Q4 revenue jump.

In the exercise you build the full line-level table that the rest of the course analyses. Next you reshape tables between wide and long formats with pivot_table and melt.

Key takeaways

  • left.merge(right, on="key", how=...) combines tables by matching key values; how decides what happens to rows without a match.
  • how="left" keeps every row of the left table and is the safe default for adding columns to a fact table.
  • Pass validate="many_to_one" (or "one_to_one") so pandas raises MergeError if the right-hand keys are not unique, instead of silently multiplying rows.
  • Compare len() before and after every merge; a left merge onto a unique key never changes the row count.
  • indicator=True adds a _merge column (both, left_only, right_only) that shows which rows found a partner.

Further reading

Check your understanding

3 questions

Question 1 of 3

You left-merge order_items (3,991 rows) with orders on order_id. How many rows should the result have?

Question 2 of 3

Which merge finds the customers who never placed an order?

Question 3 of 3

orders.merge(tickets, on="customer_id") returns 6,624 rows from 2,066 orders and 873 tickets. What happened?

Practice

Python exercise

Build the sales table

Build sales: every row of items, plus its order’s columns from orders, the product’s name renamed to product, and the customer’s country. Use left merges with validate="many_to_one", then remove the lines of cancelled orders. Then set revenue_by_country to total revenue per country as a Series, sorted from highest to lowest.

Data files: order_items.csv, orders.csv, products.csv, customers.csv

import pandas as pd

items = pd.read_csv("order_items.csv")
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
products = pd.read_csv("products.csv")
customers = pd.read_csv("customers.csv")
items["revenue"] = items["quantity"] * items["unit_price"] * (1 - items["discount"])

# 1. sales: every item line with its order's columns, the product name (as "product"),
#    and the customer's country. Use left merges with validate="many_to_one".
#    Then drop the lines of cancelled orders.
sales = None

# 2. Revenue by country, a Series sorted from highest to lowest
revenue_by_country = None
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

(no output)

Checks

  • sales has revenue, order, product and country columns
  • No cancelled orders remain
  • Every non-cancelled item line is present exactly once
  • Total revenue is 446,666.32
  • revenue_by_country is sorted, with the UAE first at 74,531.80