If you sat down and logged what you do in pandas over a month, two operations would dominate almost everything else: splitting data into groups and summarising it, and sticking two tables together.
Everything else is decoration. Get these two right and you can do most analysis work.
The problem is that both of them behave in ways that feel slightly odd until you understand the model underneath. This article explains that model, and then goes through the patterns you will use constantly.
Where it helps, I have shown the SQL equivalent, because a lot of people come to pandas already knowing SQL and the translation makes things click faster.
Part 1: GroupBy
The mental model: split, apply, combine
Every groupby does three things.
Split. The rows are divided into groups based on the values of one or more columns.
Apply. A function runs on each group independently.
Combine. The results are stitched back into a single object.
Once you see it this way, the confusing parts stop being confusing. The reason groupby returns something strange when you print it is that it has only done the split, and it is waiting to be told what to apply.
python
import pandas as pd
df.groupby("region")
# <pandas.core.groupby.generic.DataFrameGroupBy object at 0x...>
That is not an error. It is a set of groups with no instructions yet.
The basic aggregation
python
df.groupby("region")["net_amount"].sum()
sql
SELECT region, SUM(net_amount)
FROM orders
GROUP BY region;
Same operation. The pandas version returns a Series with region as the index, which is the first thing that trips people up. If you want a normal DataFrame with region as a column:
python
df.groupby("region", as_index=False)["net_amount"].sum()
I use as_index=False almost everywhere, because it keeps the output shaped like a table and saves a reset_index() later.
Multiple metrics at once with agg
This is where groupby gets genuinely useful.
python
summary = df.groupby("region", as_index=False).agg(
total_sales = ("net_amount", "sum"),
order_count = ("order_id", "count"),
customers = ("customer_id", "nunique"),
avg_order = ("net_amount", "mean"),
largest_order = ("net_amount", "max"),
)
This is called named aggregation, and it is the cleanest syntax pandas has. You get sensible column names immediately instead of a multi level column index you then have to flatten.
The SQL equivalent reads almost identically:
sql
SELECT
region,
SUM(net_amount) AS total_sales,
COUNT(order_id) AS order_count,
COUNT(DISTINCT customer_id) AS customers,
AVG(net_amount) AS avg_order,
MAX(net_amount) AS largest_order
FROM orders
GROUP BY region;
Note nunique maps to COUNT(DISTINCT ...). That one is worth memorising because it comes up constantly and people reach for count by mistake.
Grouping by more than one column
python
monthly = df.groupby(["region", "order_month"], as_index=False).agg(
total_sales = ("net_amount", "sum"),
)
Pass a list and you get one row per combination that exists in the data. Combinations with no rows simply do not appear, which matters if you are drawing a chart and want a zero line for empty months. To force them in:
python
monthly = (
df.groupby(["region", "order_month"])["net_amount"]
.sum()
.unstack(fill_value=0)
.stack()
.reset_index(name="total_sales")
)
The one that changes everything: transform
agg collapses each group down to one row. transform returns a result the same length as the original data, with the group value repeated on every row of that group.
That sounds abstract, so here is why it matters. Say you want each order’s share of its region’s total:
python
df["region_total"] = df.groupby("region")["net_amount"].transform("sum")
df["share_of_region"] = df["net_amount"] / df["region_total"]
No merge needed. The group total lands on every row automatically.
This is the pandas answer to a SQL window function:
sql
SELECT
order_id,
net_amount,
SUM(net_amount) OVER (PARTITION BY region) AS region_total
FROM orders;
Once you know transform, a whole category of clumsy “aggregate then merge back” code disappears. Common uses:
python
# flag orders above their region's average
df["above_region_avg"] = df["net_amount"] > df.groupby("region")["net_amount"].transform("mean")
# number each customer's orders in date order
df = df.sort_values("order_date")
df["order_seq"] = df.groupby("customer_id").cumcount() + 1
# days since that customer's previous order
df["prev_order"] = df.groupby("customer_id")["order_date"].shift(1)
df["days_since_prev"] = (df["order_date"] - df["prev_order"]).dt.days
Those last two are the pandas versions of ROW_NUMBER and LAG.
Filtering groups
To keep only groups meeting a condition, which is SQL’s HAVING:
python
big_regions = df.groupby("region").filter(lambda g: g["net_amount"].sum() > 100000)
That returns the original rows belonging to qualifying groups, not the summary. If you want the summary filtered instead, aggregate first and filter the result:
python
summary = df.groupby("region", as_index=False)["net_amount"].sum()
summary = summary[summary["net_amount"] > 100000]
The second version is faster and easier to read. Use filter only when you genuinely need the underlying rows.
The NaN trap
By default, groupby drops rows where the grouping key is NaN. Silently.
python
df.groupby("region")["net_amount"].sum() # missing regions vanish
df.groupby("region", dropna=False)["net_amount"].sum() # they get their own group
If your group totals do not add up to the grand total, this is the first thing to check. It catches everyone at least once.
Part 2: Merge
The mental model: keys and multiplication
A merge matches rows from two tables on one or more key columns. The output row count is not the input row count. It is the number of key matches, and that distinction is the source of nearly every merge bug.
python
result = pd.merge(
orders,
customers,
on="customer_id",
how="left",
)
The four join types, in plain terms
how="inner": keep only rows where the key exists in both. Default. Silently drops non matching rows.how="left": keep every row from the left table, fill missing right values with NaN. What you want most of the time when enriching a main table with lookups.how="right": the mirror of left. Rarely used, because you can just swap the tables and read it more easily.how="outer": keep everything from both sides. Useful for reconciliation, when you want to see what exists on one side and not the other.
The default being inner is worth flagging. If you merge a customer lookup onto orders and forget to set how="left", every order with a missing or unmatched customer ID quietly disappears from your analysis.
Always check the row count
Make this a reflex:
python
before = len(orders)
result = pd.merge(orders, customers, on="customer_id", how="left")
after = len(result)
print(f"before: {before}, after: {after}")
If after is larger than before, your right hand table has duplicate keys and rows have multiplied. Every numeric total you calculate from this point is inflated.
Find the culprit:
python
print(customers["customer_id"].duplicated().sum())
customers[customers["customer_id"].duplicated(keep=False)].sort_values("customer_id").head()
Usually the fix is to deduplicate the lookup table first:
python
customers_unique = customers.drop_duplicates(subset=["customer_id"], keep="last")
Pandas can also police this for you, which is better than remembering to check:
python
result = pd.merge(
orders,
customers,
on="customer_id",
how="left",
validate="many_to_one",
)
That raises an error if the right side is not unique on the key. Use it. It turns a silent wrong answer into a loud failure, which is always the better outcome.
Finding out what did not match
The indicator argument adds a column showing where each row came from:
python
check = pd.merge(
orders,
customers,
on="customer_id",
how="outer",
indicator=True,
)
print(check["_merge"].value_counts())
You get counts for both, left_only and right_only. This is the fastest way to answer “how many orders have no matching customer record”, which is a data quality question you will be asked often.
When the key columns have different names
python
result = pd.merge(
orders,
customers,
left_on="cust_id",
right_on="customer_id",
how="left",
)
You now have both columns in the output. Drop the redundant one.
Merging on multiple keys
python
result = pd.merge(
sales_actual,
sales_target,
on=["region", "order_month"],
how="left",
)
Both key columns must match in type. Merging a string "2026-03" against a datetime will produce zero matches with no warning, which feels like a bug and is one of the most common ones people hit.
Overlapping column names
When both tables have a name column, pandas adds suffixes:
python
result = pd.merge(
orders,
customers,
on="customer_id",
how="left",
suffixes=("_order", "_customer"),
)
Set them explicitly. The defaults of _x and _y are meaningless two weeks later.
merge vs join vs concat
Quick guide, because the three get confused:
pd.merge: match rows on key columns. Use this for anything SQL would call a join.df.join: same idea but matches on the index by default. Convenient when your data is genuinely indexed, confusing otherwise.pd.concat: stack tables on top of each other or side by side without matching keys. This is SQL’sUNION ALL, not a join.
python
all_months = pd.concat([jan_df, feb_df, mar_df], ignore_index=True)
ignore_index=True rebuilds a clean index. Leave it out and you get repeated index values, which cause strange problems later.
Putting both together
A typical end to end task uses them in sequence:
python
# 1. summarise at the grain you want
monthly = df.groupby(["region", "order_month"], as_index=False).agg(
total_sales = ("net_amount", "sum"),
orders = ("order_id", "nunique"),
)
# 2. bring in targets
monthly = pd.merge(
monthly,
targets,
on=["region", "order_month"],
how="left",
validate="one_to_one",
)
# 3. derive the comparison
monthly["achievement"] = monthly["total_sales"] / monthly["target_sales"]
# 4. add a group level context column
monthly["region_share"] = (
monthly["total_sales"] / monthly.groupby("order_month")["total_sales"].transform("sum")
)
Four steps, and between them they cover a very large share of real analysis.
The habits that prevent bugs
- Print the row count before and after every merge
- Use
validate=so bad joins raise instead of multiplying - Use
how="left"when enriching, not the default inner - Use
dropna=Falsein groupby when totals need to reconcile - Reach for
transforminstead of aggregating and merging back - Check key column data types before blaming the merge
None of these are clever. They are just the difference between a number you can defend and a number you cannot.