Skip to content

pandas dt.month

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.

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.

Part of the Pandas Methods Index. See also practice by skill.