Time buckets, without rearranging the data frame.
How do you group by month when the dates are in a column? You could move them into the index and call resample, then move them back. You could pull out .dt.year and .dt.month and group by the pair, which works until you want a chart with real dates on the axis. Or you can hand groupby a pd.Grouper and say what you meant: bucket this column, this often.
df.groupby(pd.Grouper(key='date', freq='YE'))['vix'].mean()
Grouper is a description of a grouping rather than a grouping itself. It goes where a column name would go, and because it goes there, it can sit in a list alongside ordinary column names — which is the thing resample cannot do.
Official documentation: pandas.Grouper.
The arguments that earn their keep
pd.Grouper(key='date', freq='ME') # month end
pd.Grouper(key='date', freq='QE') # quarter end
pd.Grouper(key='date', freq='YE') # year end
pd.Grouper(key='date', freq='5YE') # every five years
pd.Grouper(key='date', freq='W') # weekly
pd.Grouper(level='date', freq='ME') # when the dates are in the INDEX
pd.Grouper(freq='ME') # same, index assumed
key names a column; level names an index level. That pair is the whole interface, plus freq, which takes the same offset aliases as resample and date_range. The multiplier prefix is worth knowing — 5YE and 10YE and 1QE all work, and buckets wider than a year are one of the few things Grouper does that nothing else does as cleanly.
A caution on the aliases themselves: Pandas renamed them. M, Q, Y and A became ME, QE, YE and YE in Pandas 2.2, and the old spellings now raise rather than warn. Code written before 2024 will need updating.
A worked example, on real data
The VIX from FRED — daily closes, with gaps for weekends and holidays, and the dates in a column rather than the index:
import pandas as pd
url = 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=VIXCLS'
df = pd.read_csv(url, parse_dates=['observation_date'])
df.columns = ['date', 'vix']
df.groupby(pd.Grouper(key='date', freq='YE'))['vix'].mean().tail(6)
date
2021-12-31 19.66
2022-12-31 25.64
2023-12-31 16.85
2024-12-31 15.55
2025-12-31 18.93
2026-12-31 18.54
Freq: YE-DEC
The index that comes back holds real timestamps, not year numbers — which is what makes it plot correctly and sort correctly, and is the main advantage over grouping by .dt.year.
Now the part that justifies the class. Suppose you want the count of calm and stressed days per five-year period. That is a grouping by time and by a category, and resample cannot express it:
(df
.assign(regime=lambda df_: (df_['vix'] > 20).map({True: 'stressed',
False: 'calm'}))
.groupby([pd.Grouper(key='date', freq='5YE'), 'regime'])['vix']
.count()
.unstack()
.tail(4))
regime calm stressed
date
2015-12-31 1022 236
2020-12-31 925 334
2025-12-31 826 456
2030-12-31 134 38
A Grouper and a plain column name, side by side in one list. That is the whole point of the class, and it is why it survives alongside resample.
Three mistakes people make
Reaching for it when resample would do. If the dates are already the index and time is the only thing you are grouping by, df.resample('ME').mean() says the same thing in fewer words. Grouper earns its keep when the dates are in a column, or when time is one grouping key among several.
Using the old frequency aliases. freq='M' was month-end for years and is now an error. The current spellings end in E for the period end — ME, QE, YE — and MS, QS, YS for the start.
Being surprised by the trailing bucket. The last group runs to the end of its period, not to the end of your data, so a 5YE grouping on data ending in 2026 produces a bucket labeled 2030 holding a few months. The 134 and 38 in the table above are a partial period, not a collapse in trading days.
Where it shows up in Bamboo Weekly
Bamboo Weekly #70: Moon missions counts launches in five-year blocks with groupby(pd.Grouper(key='Launch date', freq='5YE'))['Outcome'].count(), then plots the result. Half a decade is a natural unit for spaceflight and an awkward one for every other tool.
Bamboo Weekly #69: Election participation goes wider still — freq='10YE' on compulsory-voting laws, then pct_change across the decades.
Bamboo Weekly #66: Pittsburgh puts a Grouper somewhere unexpected: inside pivot_table, as index=pd.Grouper(key='CREATED_ON', freq='1QE'). Anywhere a grouping key is accepted, a Grouper is accepted.
Bamboo Weekly #59: Long COVID uses freq='1YE' to average survey values into years before charting them.
Practice it
Work through a Grouper exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/grouper/
Go deeper
resample is the shorter path when the dates are in the index and time is the only key. groupby is what Grouper plugs into, and unstack is what turns its two-level result into a table. to_datetime has to have run first — Grouper needs a real datetime column, not strings.
More Pandas videos on Python and Pandas with Reuven Lerner.
Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.
See it on real data
Below are the 7 Bamboo Weekly exercises that use Grouper on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #119: Python conferences
- Bamboo Weekly #117: Electricity
- Bamboo Weekly #106: Flu season
- Bamboo Weekly #70: Moon missions
- Bamboo Weekly #69: Election participation
- Bamboo Weekly #66: Pittsburgh
- Bamboo Weekly #59: Long covid
Part of the Pandas Methods Index. See also practice by skill.