Skip to content

pandas dt.year

Pull the calendar apart into numbers you can group by, filter on, and compare.

What they do

Have you ever had a perfectly good datetime column and wanted just one piece of it — the year, so you could total sales per year, or the hour, so you could see when the rush happens? That is what the numeric attributes of the .dt accessor are for. Each one reaches into every timestamp in the column and hands back a small integer.

.dt.year gives you 2026. .dt.month gives you 1 through 12. .dt.day is the day of the month, .dt.hour is 0 through 23, .dt.quarter is 1 through 4, and .dt.dayofweek is 0 through 6, starting on Monday. They all return an int32 Series the same length as the original, so they work everywhere an ordinary column works: inside groupby, inside a boolean filter, as the index of a pivot_table.

The one thing to fix in your fingers before anything else: these are properties, not methods. There are no parentheses. df['date'].dt.year is the year; df['date'].dt.year() is a TypeError. That trips people up because the rest of the .dt accessor — .dt.day_name(), .dt.month_name(), .dt.strftime(), .dt.round() — really are methods and really do need them. The rule that has never failed me: the ones that give you a number are attributes, and the ones that give you a string are methods.

Official documentation: Series.dt.year, Series.dt.month, Series.dt.hour, Series.dt.quarter and Series.dt.dayofweek.

The parts that earn their keep

s.dt.year           # 2026
s.dt.quarter        # 1–4
s.dt.month          # 1–12
s.dt.day            # 1–31, the day of the month
s.dt.hour           # 0–23
s.dt.dayofweek      # 0–6, Monday is 0
s.dt.days_in_month  # 28, 29, 30 or 31, for the month this date falls in

Some have aliases that Pandas treats as exactly the same thing: .dt.day_of_week and .dt.weekday are both .dt.dayofweek, and .dt.daysinmonth is .dt.days_in_month. I compared them element by element; there is no difference to weigh, so use whichever reads better to you.

.dt.days_in_month is the odd one out, because it does not answer "what does this date say?" but "how long is the month this date lives in?" — which is what you want when February keeps looking like a bad month for reasons that have nothing to do with your business.

All of these also live on a DatetimeIndex, where you drop the .dt entirely: df.index.year, not df.index.dt.year. The latter raises AttributeError: 'DatetimeIndex' object has no attribute 'dt'.

A worked example, on real data

Seattle has counted every bicycle crossing the Fremont Bridge since October 2012, one row per hour. About 120,000 rows, free, with a genuine daily, weekly and yearly shape — a good place to watch these attributes work. Its own column headers run to 60 characters, so I take the first two columns positionally and rename them. The file keeps growing, so your numbers will run past mine; I ran these in August 2026.

import pandas as pd

url = ('https://data.seattle.gov/api/views/65db-xm6k/'
       'rows.csv?accessType=DOWNLOAD')

df = pd.read_csv(url,
                 usecols=[0, 1],
                 names=['timestamp', 'riders'],
                 header=0,
                 parse_dates=['timestamp'],
                 date_format='%m/%d/%Y %I:%M:%S %p')

df.head(3)
            timestamp  riders
0 2012-10-02 13:00:00    55.0
1 2012-10-02 14:00:00   130.0
2 2012-10-02 15:00:00   152.0

That date_format string is the sort of thing I never get right from memory — %I for the 12-hour clock against %H for the 24-hour one, %p for AM and PM. When I need one I build it at strfti.me, which shows the formatted result as you type. Two seconds there beats a wrong parse.

Start with the hour, the one part that needs real timestamps rather than plain dates. What does the morning look like?

df.groupby(df['timestamp'].dt.hour)['riders'].mean().round().loc[6:9]
timestamp
6     77.0
7    192.0
8    280.0
9    174.0
Name: riders, dtype: float64

Eight in the morning carries almost four times what six does, and the drop-off at nine is nearly as sharp as the climb. That is a commute, visible in one line.

Note the shape of that call: I did not add a column first. df['timestamp'].dt.hour is a Series with the same index as df, so groupby takes it directly as the grouping key — which is also why the index in the output is named timestamp rather than hour. This is the idiom I use most often with these attributes.

Now the week. .dt.dayofweek numbers Monday as 0 and Sunday as 6, and the grouped result comes back in that order:

df.groupby(df['timestamp'].dt.dayofweek)['riders'].sum().astype(int)
timestamp
0    2035457
1    2297693
2    2282837
3    2179330
4    1914226
5    1271291
6    1172498
Name: riders, dtype: int64

That is Pandas' numbering, and it is not how I picture a week — mine starts on Sunday, as it does for many of the people I teach. The numbering is Pandas' business; the reading order is yours. Bring the names along and reindex:

(
    df
    .groupby([df['timestamp'].dt.dayofweek.rename('dayofweek'),
              df['timestamp'].dt.day_name().rename('day')])
    ['riders'].sum().astype(int)
    .reset_index('day')
    .reindex([6, 0, 1, 2, 3, 4, 5])
)
                 day   riders
dayofweek                    
6             Sunday  1172498
0             Monday  2035457
1            Tuesday  2297693
2          Wednesday  2282837
3           Thursday  2179330
4             Friday  1914226
5           Saturday  1271291

Sunday is the quietest day on the bridge and Tuesday the busiest, with both weekend days near half of midweek. Every total is identical in the two outputs; only the reading order changed. Make that order a deliberate choice, because nothing in the numbers tells the next reader which convention produced them.

Years show the other thing these attributes are for — a long trend:

df.groupby(df['timestamp'].dt.year)['riders'].sum().astype(int)
timestamp
2012     154649
2013     928279
2014    1006196
2015     986502
2016     982470
2017     963135
2018    1051880
2019    1187146
2020     772593
2021     715630
2022     797537
2023     902926
2024     933596
2025    1029510
2026     741283
Name: riders, dtype: int64

A steady million a year, a peak in 2019, the pandemic, and a climb back that took until 2025 to finish. Watch the ends: 2012 and 2026 are partial years.

The mistake that matters most

Now the same call with month in place of year:

df.groupby(df['timestamp'].dt.month)['riders'].sum().astype(int)
timestamp
1      743386
2      692253
3      932852
4     1108167
5     1473233
6     1490463
7     1627498
8     1413271
9     1179500
10    1106747
11     798964
12     586998
Name: riders, dtype: int64

Twelve rows. Not 166, which is how many months are actually in this file. Every January since 2013 has been added into a single number, and so has every July. Nothing warns you. The output looks like a time series and is not one.

Sometimes that is exactly what you want; the seasonal shape here is real, and pooling fourteen years is the right way to see it. But if you meant "how did ridership change month by month," this is silently the wrong answer, and it hides the most interesting thing in the data:

(
    df
    .assign(year=lambda df_: df_['timestamp'].dt.year,
            month=lambda df_: df_['timestamp'].dt.month)
    .pivot_table(index='year', columns='month', values='riders', aggfunc='sum')
    .loc[2018:2023, 3:6]
    .astype('Int64')
)
month      3      4       5       6
year                               
2018   77284  79947  129813  113145
2019   85457  87932  129123  132512
2020   57897  65375   72668   75787
2021   50200  69345   73033   77473
2022   56375  63188   73495   85259
2023   60118  60494  105039  102158

March 2020 is a third below March 2019, and May 2020 is not much more than half of May 2019. The month-only version averaged that away.

If you want a real monthly time series, group by the whole month rather than by its number. .dt.to_period('M') keeps the year attached:

df.groupby(df['timestamp'].dt.to_period('M'))['riders'].sum().loc['2019-12':'2020-05'].astype(int)
timestamp
2019-12    61377
2020-01    58986
2020-02    72457
2020-03    57897
2020-04    65375
2020-05    72668
Freq: M, Name: riders, dtype: int64

With a datetime index, df.set_index('timestamp')['riders'].resample('ME').sum() gives the same numbers with month-end labels. Or stay with the plain attributes and group by two of them at once, which is how .dt.quarter usually wants to be used:

(
    df
    .groupby([df['timestamp'].dt.year.rename('year'),
              df['timestamp'].dt.quarter.rename('quarter')])
    ['riders'].sum().astype(int)
    .loc[2019:2021]
)
year  quarter
2019  1          194439
      2          349567
      3          392302
      4          250838
2020  1          189340
      2          213830
      3          234671
      4          134752
2021  1          115295
      2          219851
      3          254714
      4          125770
Name: riders, dtype: int64

rename on each key is worth the keystrokes — without it both index levels are called timestamp, which is exactly as unhelpful as it sounds. That two-key shape is what Bamboo Weekly #63 uses, below.

The rule is short: .dt.month answers "which month of the year is this?" and never "which month is this?"

Three more mistakes people make

Calling them with parentheses. This one at least announces itself:

df['timestamp'].dt.year()
TypeError: 'Series' object is not callable

Pandas already computed the years and handed you a Series; the parentheses then try to call it. The message never mentions dates, which is why it takes a moment to place. Going the other way — .dt.day_name without parentheses — is worse, because it does not raise at all: you get a column of bound method objects and no complaint.

Counting the week from Sunday, the way most of us do. I start my week on Sunday, and so do the calendar on my phone, Excel's WEEKDAY, and plenty of SQL. Pandas does not: .dt.dayofweek is 0 for Monday, matching Python's own datetime.weekday(). That gap is easy to carry straight into a filter, where it buys you a well-formatted answer to a different question:

df.loc[df['timestamp'].dt.dayofweek.isin([0, 1]), 'riders'].sum()  # wanted the weekend; got Monday and Tuesday
df.loc[df['timestamp'].dt.dayofweek.isin([5, 6]), 'riders'].sum()  # Saturday and Sunday
4333150.0
2443789.0

Nearly double, with no error in sight. The cure is to stop using the numbers for this: df.loc[df['timestamp'].dt.day_name().isin(['Saturday', 'Sunday']), 'riders'].sum() returns the same 2,443,789 and stays correct whichever day your week starts on.

Reaching for .dt on a column of strings. The accessor exists only on datetime columns. Forget parse_dates and the failure is immediate:

pd.read_csv(url, usecols=[0, 1], names=['timestamp', 'riders'], header=0)['timestamp'].dt.year
AttributeError: Can only use .dt accessor with datetimelike values. Did you mean: 'at'?

The fix is upstream, not here: parse_dates= in read_csv, or to_datetime after the fact. Check df.dtypes when in doubt.

One more use for days_in_month

Monthly totals are not comparable until you divide by the length of the month. February is 10 percent shorter than January, enough on its own to invent a slump that is not there:

(
    df
    .assign(period=lambda df_: df_['timestamp'].dt.to_period('M'),
            days=lambda df_: df_['timestamp'].dt.days_in_month)
    .groupby('period')
    .agg(total=('riders', 'sum'), days=('days', 'max'))
    .assign(per_day=lambda df_: (df_['total'] / df_['days']).round())
    .loc['2026-01':'2026-07']
)
            total  days  per_day
period                          
2026-01   66024.0    31   2130.0
2026-02   71732.0    28   2562.0
2026-03   80675.0    31   2602.0
2026-04  103266.0    30   3442.0
2026-05  127671.0    31   4118.0
2026-06  138677.0    30   4623.0
2026-07  153238.0    31   4943.0

February's total is 8 percent above January's, but its daily rate is 20 percent higher. The raw totals understate what actually happened.

Where it shows up in Bamboo Weekly

.dt.year appears in 41 of the 185 Bamboo Weekly solutions and .dt.month in 17 — roughly one solution in four. Four are worth reading.

Bamboo Weekly #63: Ukraine aid is the pattern to copy when you want a real time series. For the percentage change in military donations each quarter of the war, the groupby key is a list of three: df['date'].dt.year, df['date'].dt.quarter, and a category column. Year first, so the quarters never collapse across years.

Bamboo Weekly #71: Holidays is the best example I have of .dt.month and .dt.day used together on purpose. Asking which holidays moved between 2023 and 2024, the test is whether the month and the day both match while the year deliberately does not — the same collapse the section above warns about, done knowingly.

Bamboo Weekly #48: Aviation accidents uses .dt.hour on NTSB incident reports to ask at what time of day flights with injuries happen, and has a defensive trick I keep coming back to: df['cm_eventDate'].dt.year.drop_duplicates().sort_values().diff() proves no year is missing from a set of downloaded files, because a gap shows up as a difference greater than one.

Bamboo Weekly #66: Pittsburgh puts .dt.year straight into pivot_table(index=...) rather than into a groupby — the same idea in a different shape — and defines a summer season with df['CREATED_ON'].dt.month.isin(range(4, 10)).

Practice it

Work through a dt.year exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/dt-year/

Go deeper

The other half of the .dt accessor gives you words rather than numbers — dt.day_name() and dt.month_name(), both methods, both with parentheses — and you will often want one of each: a number to sort by, a name to display. All of this assumes a real datetime column, so to_datetime comes first, and the grouped result usually wants sort_index, unstack or pivot_table after. When the answer is a time series rather than a seasonal profile, use resample. The Pandas user guide's time series chapter lists every attribute on the accessor, including the dozen this page skipped.

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 41 Bamboo Weekly exercises that use dt.year on real-world data — try each one, then study the worked solution.

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