GroupBy and Merge¶
Two of the most powerful operations in Pandas. groupby does for DataFrames what GROUP BY does in SQL — aggregates rows into groups. merge does what SQL JOIN does — combines two DataFrames on a shared key. Together they handle 80% of real-world data transformation work.
Learning Objectives¶
- Use
.groupby()with aggregation functions - Apply multiple aggregations with
.agg() - Transform groups without collapsing rows with
.transform() - Merge DataFrames with
pd.merge()(INNER, LEFT, RIGHT, OUTER) - Concatenate DataFrames with
pd.concat()
GroupBy — Aggregation¶
import pandas as pd
df = pd.read_csv("orders.csv", parse_dates=["order_date"])
# Count orders per status
print(df.groupby("status")["order_id"].count())
# status
# cancelled 3
# completed 12
# pending 5
# dtype: int64
# Total revenue per customer
revenue_by_customer = df[df["status"] == "completed"].groupby("customer_id")["total"].sum()
print(revenue_by_customer.sort_values(ascending=False).head(5))
Common aggregations¶
# Single aggregation
df.groupby("status")["total"].mean()
df.groupby("category")["total"].sum()
df.groupby("customer_id")["order_id"].count()
# `.size()` vs `.count()`
df.groupby("status").size() # counts ALL rows including NaN
df.groupby("status")["total"].count() # counts non-NaN values in "total"
# Unique count per group
df.groupby("customer_id")["product"].nunique() # unique products per customer
Multiple Aggregations with .agg()¶
# Multiple aggregations on one column
customer_stats = df[df["status"] == "completed"].groupby("customer_id")["total"].agg(
orders="count",
total_revenue="sum",
avg_order="mean",
max_order="max",
min_order="min",
)
print(customer_stats.sort_values("total_revenue", ascending=False))
Sample output:
| customer_id | orders | total_revenue | avg_order | max_order | min_order |
|---|---|---|---|---|---|
| C003 | 6 | 1638.95 | 273.16 | 389.00 | 179.98 |
| C001 | 5 | 924.97 | 184.99 | 298.00 | 35.99 |
# Different aggregations on different columns
summary = df.groupby("status").agg(
order_count=("order_id", "count"),
total_revenue=("total", "sum"),
avg_total=("total", "mean"),
unique_customers=("customer_id", "nunique"),
)
Custom aggregation functions¶
# Custom function in agg
def range_value(x):
return x.max() - x.min()
df.groupby("category")["total"].agg(
revenue="sum",
spread=range_value, # custom function
)
Grouping by Multiple Columns¶
# Monthly revenue by category
monthly_category = (
df[df["status"] == "completed"]
.assign(month=df["order_date"].dt.to_period("M"))
.groupby(["month", "category"])["total"]
.sum()
.reset_index()
)
print(monthly_category.head(8))
.reset_index() — flatten the grouped result¶
After .groupby(), the group columns become the index. Use .reset_index() to convert them back to regular columns.
# Without reset_index: customer_id is the index
result = df.groupby("customer_id")["total"].sum()
print(result.index) # customer_id is the index
# With reset_index: customer_id is a regular column
result = df.groupby("customer_id")["total"].sum().reset_index()
print(result.columns) # ['customer_id', 'total']
.transform() — Group Aggregates Without Collapsing¶
transform returns a Series of the same length as the original DataFrame, making it perfect for adding group-level statistics as a column.
# Add each customer's total spend as a column on every order row
df["customer_total"] = df.groupby("customer_id")["total"].transform("sum")
# Add group average — useful for comparing individual vs group
df["category_avg"] = df.groupby("category")["total"].transform("mean")
df["vs_category_avg"] = df["total"] / df["category_avg"] - 1 # % above/below avg
# Rank within group
df["rank_in_category"] = df.groupby("category")["total"].transform(
lambda x: x.rank(ascending=False)
)
For mid-level analysts
transform is the Pandas equivalent of SQL window functions with PARTITION BY. It's how you get "each customer's contribution to total revenue" without losing the row-level detail.
Merge — Joining DataFrames¶
pd.merge() is the Pandas equivalent of SQL JOIN.
orders = pd.read_csv("orders.csv")
customers = pd.read_csv("customers.csv")
products = pd.read_csv("products.csv")
INNER JOIN (default)¶
# Only rows with a match in both DataFrames
result = pd.merge(orders, customers, on="customer_id")
# Or with different column names:
result = pd.merge(orders, customers, left_on="cust_id", right_on="customer_id")
LEFT JOIN¶
# All orders, plus customer info where it exists
result = pd.merge(orders, customers, on="customer_id", how="left")
# Rows with no matching customer get NaN for customer columns
RIGHT and OUTER JOIN¶
# All customers, even those without orders
result = pd.merge(orders, customers, on="customer_id", how="right")
# All rows from both tables
result = pd.merge(orders, customers, on="customer_id", how="outer")
Joining three tables¶
full = (
orders
.merge(customers, on="customer_id", how="left")
.merge(products, on="product_id", how="left")
)
print(full.shape)
print(full.columns.tolist())
Detecting duplicates after merge¶
before = len(orders)
after = len(result)
if after > before:
print(f"Warning: row count increased from {before} to {after} — possible one-to-many join")
Merge can silently multiply rows
If customers has multiple rows per customer_id (a data quality issue), joining on that column will multiply every order by the number of customer rows. Always check row counts before and after.
pd.concat() — Stacking DataFrames¶
Use concat to combine DataFrames vertically (adding more rows) or horizontally (adding more columns).
# Vertical stack — add more rows (same columns)
jan_df = pd.read_csv("orders_jan.csv")
feb_df = pd.read_csv("orders_feb.csv")
mar_df = pd.read_csv("orders_mar.csv")
q1 = pd.concat([jan_df, feb_df, mar_df], ignore_index=True)
print(q1.shape) # combined row count
# All CSV files in a directory
from pathlib import Path
import pandas as pd
all_dfs = [pd.read_csv(f) for f in Path("monthly_exports").glob("*.csv")]
combined = pd.concat(all_dfs, ignore_index=True)
concat requires matching column names
If two DataFrames have different column names, mismatched columns fill with NaN. Check df.columns for each before concatenating, and rename if needed.
Practical Example — Customer Revenue Report¶
import pandas as pd
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
customers = pd.read_csv("customers.csv")
# Step 1: calculate order totals and filter
completed = orders[orders["status"] == "completed"].copy()
completed["total"] = completed["quantity"] * completed["unit_price"]
# Step 2: aggregate by customer
customer_summary = (
completed
.groupby("customer_id")
.agg(
total_orders=("order_id", "count"),
total_revenue=("total", "sum"),
avg_order=("total", "mean"),
last_order=("order_date", "max"),
)
.reset_index()
)
# Step 3: join customer details
report = customer_summary.merge(
customers[["customer_id", "first_name", "last_name", "segment"]],
on="customer_id",
how="left"
)
# Step 4: rank by revenue
report["revenue_rank"] = report["total_revenue"].rank(ascending=False, method="dense")
# Step 5: sort and display
report = report.sort_values("total_revenue", ascending=False)
print(report.head(10))
Practice Exercises¶
Warm-up
1. Group orders by status and calculate the count, total revenue, and mean revenue for each status.
2. Find the top 3 customers by total spend (completed orders only).
3. Merge orders and customers on customer_id (LEFT JOIN). Check the shape before and after.
Main
4. Calculate monthly revenue by category. Show month, category, and total revenue sorted by month ascending.
5. Use .transform() to add a column showing each order's percentage of its customer's total spend.
6. Find all customers who have placed orders but don't exist in the customers table (use an OUTER JOIN + NaN check).
Stretch 7. Build a full customer report: orders count, total spend, average order value, days since last order, and a segment label (Champion if spend > £1000, Regular if > £200, New otherwise). 8. Load 3 monthly CSV files, concatenate them, then group by category and month to produce a quarterly pivot.
Interview Questions¶
[Beginner] What does .groupby() do in Pandas?
[Beginner] What is the difference between pd.merge() and pd.concat()?
[Mid-level] What is the difference between .agg() and .transform() after a groupby?
[Mid-level] After a LEFT JOIN, how do you find rows that had no match in the right table?
[Senior] A groupby on a 100M-row DataFrame is taking 3 minutes. What are your options to speed it up?