Getting the month number is one keystroke. Keeping the year attached to it is the whole job.
What they do
Have you ever grouped four years of data by month, gotten twelve tidy rows, and published a chart that turned out to be measuring nothing? That is the month's particular hazard, and it is why this page exists.
.dt.month returns the month of the year as an int32, 1 through 12. It is an attribute, so it takes no parentheses. .dt.month_name() returns the name as a string, and it is a method, so it does need them. Numbers are attributes, names are methods — that rule holds across the whole .dt accessor.
Both are covered in depth elsewhere: .dt.month alongside its numeric siblings on dt.year, and .dt.month_name() alongside the ordering problem on dt.day_name. What follows is the part that belongs only to months — what happens after you group by one.
Official documentation: Series.dt.month, Series.dt.month_name and Series.dt.to_period.
A worked example, on real data
US Customs and Border Protection publishes every border encounter since fiscal year 2021, one row per month per category, from its nationwide encounters page. It is the file behind Bamboo Weekly #52, and it has the two month problems in one place. There is no date column — just a fiscal year and a three-letter month — so I assemble one:
import pandas as pd
url = ('https://www.cbp.gov/sites/default/files/assets/documents/'
'2024-Jan/nationwide-encounters-fy21-fy24-dec-aor.csv')
df = (pd
.read_csv(url, storage_options={'User-Agent': 'Mozilla/5.0'})
.replace({'Fiscal Year': {'2024 (FYTD)': '2024'}})
.assign(fy_date=lambda df_: pd.to_datetime(
df_['Fiscal Year'].astype(str) + '-' + df_['Month (abbv)'],
format='%Y-%b'))
)
df[['Fiscal Year', 'Month (abbv)', 'Encounter Count', 'fy_date']].head(3)
Fiscal Year Month (abbv) Encounter Count fy_date
0 2021 DEC 2 2021-12-01
1 2021 DEC 2 2021-12-01
2 2021 DEC 3 2021-12-01
%Y-%b is the sort of format string I never recall correctly — %b for DEC against %B for December, %m for 12. I build these at strfti.me, which shows the result as you type.
Fiscal years are not calendar years
Resample that to month totals and something impossible happens:
df.set_index('fy_date').resample('1ME')['Encounter Count'].sum().tail(6)
fy_date
2024-07-31 0
2024-08-31 0
2024-09-30 0
2024-10-31 309114
2024-11-30 308669
2024-12-31 371036
Freq: ME, Name: Encounter Count, dtype: int64
Three empty months, then a jump — in a file published in January 2024, reporting October through December of 2024. The US federal fiscal year starts on October 1, so FY2024's October is calendar October 2023. Nothing is missing; every October, November and December row is labeled a year later than it happened.
The fix is the one place on this page where .dt.month does real work, as a test rather than as a grouping key. Convert to a monthly Period and subtract twelve months from the rows in October, November and December:
df = df.assign(date=lambda df_: df_['fy_date'].dt.to_period('M')
- 12 * (df_['fy_date'].dt.month >= 10))
df[['Month (abbv)', 'fy_date', 'date']].head(3)
Month (abbv) fy_date date
0 DEC 2021-12-01 2020-12
1 DEC 2021-12-01 2020-12
2 DEC 2021-12-01 2020-12
Period arithmetic counts in the period's own unit, so subtracting 12 from a monthly Period moves it back a year, and multiplying the boolean by 12 leaves January through September alone. FY2021's December lands in December 2020, where it belongs.
Going the other way — calendar dates in, fiscal years out — needs no arithmetic at all. A period anchored on September ends the year there:
monthly = df.groupby('date')['Encounter Count'].sum()
monthly.groupby(monthly.index.asfreq('Y-SEP')).sum()
date
2021 1956519
2022 2766582
2023 3201144
2024 988819
Freq: Y-SEP, Name: Encounter Count, dtype: int64
'Y-SEP' means "a year that ends in September," which is the US federal fiscal year written down. Australia would want 'Y-JUN', and the UK tax year is close enough to 'Y-MAR' for most purposes. The same anchoring works on quarters: 'Q-SEP' puts October through December in Q1.
The month that ate the year
Now the trap. With the dates finally correct, group by the month number:
df.groupby(df['date'].dt.month)['Encounter Count'].sum()
date
1 491235
2 520048
3 701900
4 734335
5 748617
6 666803
7 718002
8 786837
9 827352
10 865152
11 880918
12 971865
Name: Encounter Count, dtype: int64
Twelve rows, rising almost monotonically to a December peak. Plot that as a bar chart and you have published a claim about seasonality: encounters climb all year and top out in December.
They do not. This file covers 39 months, from October 2020 through December 2023, and 39 does not divide by 12:
df['date'].drop_duplicates().dt.month.value_counts().sort_index()
date
1 3
2 3
3 3
4 3
5 3
6 3
7 3
8 3
9 3
10 4
11 4
12 4
Name: count, dtype: int64
October, November and December each got four turns; every other month got three. Since encounters rose sharply across these years — FY2021 totaled 1.96 million and FY2023 totaled 3.20 million — those three months were each summing an extra, and busier, year. Divide it out and the ranking changes:
monthly.groupby(monthly.index.month).mean().round().astype(int)
date
1 163745
2 173349
3 233967
4 244778
5 249539
6 222268
7 239334
8 262279
9 275784
10 216288
11 220230
12 242966
Name: Encounter Count, dtype: int64
September is the busiest month and December is mid-pack. The December peak was an artifact of the calendar, not a finding, and nothing in the first output hints at it.
Grouping by the month alone is right when you genuinely want a seasonal profile pooled across years — that is what Bamboo Weekly #45 does below — and it is wrong every other time. When you want a time series, group by the whole month:
monthly.loc['2021-11':'2022-02']
date
2021-11 198553
2021-12 205691
2022-01 186808
2022-02 190578
Freq: M, Name: Encounter Count, dtype: int64
39 rows instead of 12, each one a real month, and the index is a PeriodIndex that slices by string, sorts correctly, and prints as 2022-01 rather than as a timestamp you have to squint at. It also makes month-over-month change mean something:
monthly.pct_change().mul(100).round(1).loc['2021-11':'2022-02']
date
2021-11 6.1
2021-12 3.6
2022-01 -9.2
2022-02 2.0
Freq: M, Name: Encounter Count, dtype: float64
Run pct_change on the twelve-row version and you get the change from all Januaries to all Februaries, which is not a quantity anyone needs.
Month end or month start
If the index is already datetime rather than Period, resample does the same job, and you have to choose which end of the month to label it with:
ts = df.assign(ts=lambda df_: df_['date'].dt.to_timestamp()).set_index('ts')
ts.resample('1ME')['Encounter Count'].sum().loc['2021-12':'2022-01']
ts.resample('1MS')['Encounter Count'].sum().loc['2021-12':'2022-01']
2021-12-31 205691
2022-01-31 186808
Freq: ME, Name: Encounter Count, dtype: int64
2021-12-01 205691
2022-01-01 186808
Freq: MS, Name: Encounter Count, dtype: int64
Identical totals, different labels. 'ME' is month end and 'MS' is month start; I reach for '1ME' by default and switch to '1MS' when the labels are going onto a chart axis, where a bar sitting on January 31 reads as February to too many people. Note that Periods sidestep the question entirely — 2022-01 is the month, not one of its edges.
The plain 'M' that older code and older Bamboo Weekly issues use is gone:
ts.resample('1M')['Encounter Count'].sum()
ValueError: Invalid frequency: 1M. Failed to parse with error message:
ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")
This one confuses people because the letter is not banned everywhere. to_period('M') still takes 'M' and always will — period aliases and offset aliases are separate vocabularies, and only the offsets were tightened up. 'Q' and 'Y' went the same way as 'M', to 'QE' and 'YE'.
Four mistakes people make
Sorting month names alphabetically. Group on .dt.month_name() instead of .dt.month and the year opens with April, August, December, February. It is the identical problem that weekday names have, and it has the identical fix — an ordered Categorical — which I work through on dt.day_name rather than twice here. The short version: type the twelve names out in order and make the column a CategoricalDtype.
Parentheses on the wrong one. .dt.month() raises TypeError: 'Series' object is not callable, which is at least loud. .dt.month_name without them does not raise at all — you get a column full of bound method objects, and a groupby on it puts every row in one group.
Expecting .dt.month_name() on a Period column. Once you convert with to_period('M'), the numbers survive but the names do not:
df['date'].dt.month_name()
AttributeError: 'PeriodProperties' object has no attribute 'month_name'
df['date'].dt.month still works, and so does anything else that returns a number. For a name, step back to timestamps first: df['date'].dt.to_timestamp().dt.month_name().
Treating a fiscal year as a calendar year because the column says "Year". The CBP file above is not unusual; government and corporate exports label the fiscal year and leave you to notice. A quick check: resample by month and look for zeros in the middle, or for months that have not happened yet.
Where it shows up in Bamboo Weekly
.dt.month appears in 17 of the 185 Bamboo Weekly solutions. Four are worth reading, and all four are free.
Bamboo Weekly #52: Border encounters is the fiscal-year post, using the file above. It is also where I explain why '1ME' replaced '1M', and it solves the fiscal shift with a list comprehension over the index — a fair alternative to the Period arithmetic here, and a good comparison of the two styles.
Bamboo Weekly #5: Ukrainian exports catches the collapse in the act. I start with groupby(df['Departure'].dt.month), realize the program will outlive its first year, and switch to grouping on year and month together so the analysis keeps working. That is the whole lesson of this page, in one paragraph, written before I knew I would need a page for it.
Bamboo Weekly #79: Cyber attacks is the two-key idiom at full stretch: groupby([dt.year, dt.month]) feeding pct_change, then the same pair as a pivot_table index with countries across the columns.
Bamboo Weekly #45: Netflix is the legitimate use of the bare month number. Asking which month Netflix releases the most titles in is a question about seasons, not about time, so pooling every October together is exactly right.
Practice it
Work through a dt.month exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/dt-month/
Go deeper
The numeric siblings and the parentheses rule live on dt.year, the naming and ordering half on dt.day_name, and none of it works until the column is a real datetime, which is to_datetime. Once you have a monthly series, resample and pct_change are where it usually goes next. The Pandas user guide's time series chapter lists every offset and period alias, anchored ones included.
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.
Related methods
.dt.year()— when you want the year, quarter or hour rather than the month.dt.day_name()— when the weekday is the cycle you are looking at rather than the month.resample()— when the series should be re-bucketed into regular periods instead
See it on real data
Below are the 17 Bamboo Weekly exercises that use dt.month on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #184: Parmesan cheese
- Bamboo Weekly #178: Harmful algal bloom
- Bamboo Weekly #152: Congestion pricing
- Bamboo Weekly #151: PyPI in 2025
- Bamboo Weekly #128: Extreme heat
- Bamboo Weekly #125: Shrinking dollars
- Bamboo Weekly #113: US airport traffic
- Bamboo Weekly #86: FEMA
- Bamboo Weekly #79: Cyber attacks
- Bamboo Weekly #73: Avocado hand
- Bamboo Weekly #71: Holidays
- Bamboo Weekly #66: Pittsburgh
- Bamboo Weekly #45: Netflix
- Bamboo Weekly #33: Fracking
- Bamboo Weekly #16: Consumer oil prices
- Bamboo Weekly #5: Ukrainian exports
- Bamboo Weekly #2: Egg prices
Part of the Pandas Methods Index. See also practice by skill.