GroupBy transform & filter
Reviewed & published by Brayan K
GroupBy transform broadcasts a per-group statistic back to every original row so you can add it as a column, while GroupBy filter keeps or discards whole groups based on a test — two tools that go beyond the usual collapse-to-a-summary aggregation.
Part of the free Pandas course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
You will use groupby().transform() to add group means and z-scores, groupby().filter() to drop small or low-volume groups, and see exactly how both differ from .agg().
What You'll Learn in This Lesson
- How transform keeps the original shape
- Add a group mean back as a new column
- Compute a within-group z-score
- How filter keeps or drops whole groups
- Write a group predicate with len() or sum()
- Contrast transform and filter with agg
1 transform() — Same Shape, Group Stat Broadcast
A normal .agg() collapses each group to one row. .transform() does the opposite: it computes the group statistic, then spreads that one value back across every row in the group. The result has the same length and index as the input, so it slots straight into a new column.
import pandas as pd
df = pd.DataFrame({"team": ["A", "A", "B", "B"],
"pts": [10, 20, 5, 15]})
# agg -> one row per group (shape shrinks)
df.groupby("team")["pts"].mean()
# A 15.0
# B 10.0
# transform -> one value per ORIGINAL row (shape kept)
df.groupby("team")["pts"].transform("mean")
# 0 15.0
# 1 15.0
# 2 10.0
# 3 10.0import pandas as pd
df = pd.DataFrame({"team": ["A", "A", "B", "B", "B"],
"pts": [10, 20, 5, 15, 25]})
# Add each team's average as a column next to the raw points
df["team_mean"] = df.groupby("team")["pts"].transform("mean")
print(df)
# ✅ Expected output:
# team pts team_mean
# 0 A 10 15.0
# 1 A 20 15.0
# 2 B 5 15.0
# 3 B 15 15.0
# 4 B 25 15.02 A Within-Group Z-Score
Because transform keeps shapes aligned, you can build per-row standardised scores. A z-score measures how far a value sits from its group's mean, in units of the group's standard deviation. Pass a small custom function that returns a Series the same length as each group.
def zscore(x):
return (x - x.mean()) / x.std()
df["z"] = df.groupby("team")["pts"].transform(zscore)
# each value standardised WITHIN its own teamimport pandas as pd
df = pd.DataFrame({"team": ["A", "A", "B", "B"],
"pts": [10, 30, 40, 60]})
def zscore(x):
return (x - x.mean()) / x.std()
df["z"] = df.groupby("team")["pts"].transform(zscore)
print(df)
# ✅ Expected output:
# team pts z
# 0 A 10 -0.707107
# 1 A 30 0.707107
# 2 B 40 -0.707107
# 3 B 60 0.7071073 filter() — Keep or Drop Whole Groups
groupby().filter(func) hands each group to your function as a sub-DataFrame. If the function returns True, every row of that group survives; if False, the whole group is dropped. It is row selection at the group level — useful for removing rare categories or low-volume segments.
df = pd.DataFrame({"team": ["A", "A", "B", "C", "C", "C"],
"pts": [1, 2, 9, 3, 4, 5]})
# keep only teams with more than 2 rows
df.groupby("team").filter(lambda g: len(g) > 2)
# only team C survives (it has 3 rows)import pandas as pd
df = pd.DataFrame({"team": ["A", "A", "B", "C", "C", "C"],
"pts": [1, 2, 9, 3, 4, 5]})
# Keep only teams that have more than 2 members
big = df.groupby("team").filter(lambda g: len(g) > 2)
print(big)
# ✅ Expected output:
# team pts
# 3 C 3
# 4 C 4
# 5 C 5import pandas as pd
df = pd.DataFrame({"store": ["x", "x", "y", "y", "z"],
"sales": [100, 300, 50, 50, 800]})
# 🎯 YOUR TURN:
# Keep only stores whose TOTAL sales exceed 200.
# Replace ___ with the method that keeps/drops whole groups.
result = df.groupby("store").___(lambda g: g["sales"].sum() > 200)
print(result)
# Expected output:
# store sales
# 0 x 100
# 1 x 300
# 4 z 800! Common Errors (And How to Fix Them)
❌ Using agg when you wanted a column
agg shrinks the frame, so this misaligns when assigned back:
df["m"] = df.groupby("team")["pts"].mean() # ❌ wrong length / NaNs✅ Fix: transform keeps the original shape:
df["m"] = df.groupby("team")["pts"].transform("mean") # ✅❌ filter function returns per-row booleans
df.groupby("team").filter(lambda g: g["pts"] > 5) # ❌ returns a Series✅ Fix: return ONE boolean per group:
df.groupby("team").filter(lambda g: g["pts"].mean() > 5) # ✅🎯 Mini Challenge: Normalise Then Prune
Combine both tools on one sales table.
- Add a region_mean column with transform
- Add a vs_mean column = sales minus that mean
- Keep only regions whose total sales exceed 150
import pandas as pd
df = pd.DataFrame({"region": ["N", "N", "S", "E", "E"],
"sales": [100, 120, 80, 60, 110]})
df["region_mean"] = df.groupby("region")["sales"].transform("mean")
df["vs_mean"] = df["sales"] - df["region_mean"]
big = df.groupby("region").filter(lambda g: g["sales"].sum() > 150)
print(big)
# ✅ Expected output:
# region sales region_mean vs_mean
# 0 N 100 110.0 -10.0
# 1 N 120 110.0 10.0
# 3 E 60 85.0 -25.0
# 4 E 110 85.0 25.0❓ Frequently Asked Questions
Lesson complete — group stats now ride along!
You can broadcast a group statistic onto every row with transform, build within-group z-scores, and keep or drop whole groups with filter — all without collapsing your data the way agg does.
Practice quiz
How does transform differ from agg?
- transform sorts the groups
- transform deletes groups
- transform keeps the original shape, agg collapses to one row per group
- They are identical
Answer: transform keeps the original shape, agg collapses to one row per group. transform broadcasts a group stat back to every row; agg shrinks.
What does df.groupby('team')['pts'].transform('mean') return?
- One value per ORIGINAL row, the group mean broadcast back
- One row per group
- A single number
- A boolean Series
Answer: One value per ORIGINAL row, the group mean broadcast back. transform returns the group mean repeated across each row of the group.
Why can you assign a transform result directly as a new column?
- It sorts the index
- It drops duplicates
- It returns a scalar
- It has the same length and index as the input
Answer: It has the same length and index as the input. transform preserves shape and index, so it aligns for assignment.
What does groupby().filter(func) do?
- Modifies individual values
- Keeps or drops whole groups based on a True/False test
- Collapses each group to a summary
- Sorts within groups
Answer: Keeps or drops whole groups based on a True/False test. filter keeps every row of groups whose predicate returns True.
What must the function passed to filter return?
- A single boolean per group
- A new DataFrame
- A list of indices
- A scalar mean
Answer: A single boolean per group. filter expects one True/False per group to keep or drop it.
What does the function passed to filter receive?
- A single row
- A column name
- Each group as a sub-DataFrame
- The group's mean only
Answer: Each group as a sub-DataFrame. Each group is handed in as a sub-DataFrame to test.
What does a within-group z-score with transform measure?
- The group's total
- The number of rows
- The maximum value
- How far each value is from its group mean in std-dev units
Answer: How far each value is from its group mean in std-dev units. (x - x.mean()) / x.std() standardises each value within its group.
Why does df['m'] = df.groupby('team')['pts'].mean() misalign?
- mean is deprecated
- agg-style mean returns one row per group, not per original row
- It returns strings
- It sorts descending
Answer: agg-style mean returns one row per group, not per original row. The aggregated result is shorter, so assigning it back misaligns.
Which expression keeps groups whose total sales exceed 200?
- g.filter(g['sales'] > 200)
- g.agg('sum')
- g.transform('sum')
- g.filter(lambda x: x['sales'].sum() > 200)
Answer: g.filter(lambda x: x['sales'].sum() > 200). filter with a lambda testing the group sum keeps qualifying groups.
Which best captures the mental model of transform vs filter?
- transform adds a per-row column; filter chooses which rows to keep
- Both collapse the data
- transform drops groups; filter adds columns
- Both return a single number
Answer: transform adds a per-row column; filter chooses which rows to keep. transform enriches columns; filter selects whole groups.
Continue this course
- Previous: Rolling & Expanding Windows
- Next: Time Series Ops: shift, diff & pct_change — Lag, difference, and compute returns over time with shift, diff, and pct_change
- Quick reference: Pandas cheat sheet
Frequently asked questions
How is transform different from agg?
agg collapses each group to a single summary row, so the result is shorter than the input. transform keeps the original shape — it computes a group statistic and then broadcasts it back to every row in that group, so the result lines up perfectly for assigning as a new column.
When would I use transform instead of agg plus merge?
Whenever you want a group statistic alongside the raw rows — like each row's value minus its group mean, or a group z-score. transform does in one step what would otherwise need agg followed by a merge back onto the original frame, and it preserves the index automatically.
What does groupby().filter() return?
filter keeps or drops entire groups based on a predicate that returns True or False for each group. The output is a subset of the original rows — every row of the surviving groups, and none of the rows of the rejected ones. It never changes individual values, only which groups remain.
Can the function passed to filter look at group size or sums?
Yes. The function receives each group as a sub-DataFrame, so you can test len(g) > 2, g['sales'].sum() > 1000, or any condition you like. Return a single boolean and pandas keeps the group when it is True.