Skip to content

pandas dt.hour

The one datetime part that is wrong until you know what timezone the column is in.

What it does

Have you ever grouped by hour, gotten a clean 24-row profile of when your users show up, and shipped it — without ever asking what clock those hours were on? That is the whole risk in .dt.hour.

.dt.hour returns the hour of the day as an int32, 0 through 23. It is an attribute, so it takes no parentheses; that rule and the rest of the numeric family live on dt.year. It truncates rather than rounds: 13:59:59 is hour 13, not 14.

The part worth your attention is that it reports the hour actually stored in the column. It does not know where your users are, and it converts nothing. A tz-aware UTC column gives you UTC hours; a tz-naive column gives you whatever the file's author meant, about which Pandas has no opinion at all.

Official documentation: Series.dt.hour, Series.dt.tz_convert and Series.dt.tz_localize.

A worked example, on real data

The Wikimedia Foundation publishes hourly pageview counts for every Wikipedia, free and without a key. Here are two months of the Hebrew Wikipedia, whose readers are overwhelmingly in one timezone — which makes the trap visible:

import pandas as pd

url = ('https://wikimedia.org/api/rest_v1/metrics/pageviews/aggregate/'
       'he.wikipedia/all-access/user/hourly/2026060100/2026073123')

df = (pd
      .json_normalize(pd.read_json(url,
                      storage_options={'User-Agent': 'Mozilla/5.0'})['items'])
      .assign(ts=lambda df_: pd.to_datetime(df_['timestamp'],
                                            format='%Y%m%d%H', utc=True))
      [['ts', 'views']])

df.head(3)
                         ts  views
0 2026-06-01 00:00:00+00:00  17995
1 2026-06-01 01:00:00+00:00  14622
2 2026-06-01 02:00:00+00:00  16207

That %Y%m%d%H is the sort of string I never get right from memory — %H for the 24-hour clock against %M for minutes. I build them at strfti.me, which shows the result as you type. Note the +00:00: thanks to utc=True, this column is tz-aware, and it holds UTC.

Now the hourly profile, 1,464 rows collapsed into 24:

by_utc = df.groupby(df['ts'].dt.hour)['views'].mean().round().astype(int)

by_utc.loc[[1, 2, 3, 18, 19, 20]]
ts
1      19868
2      19734
3      27113
18     95943
19    101623
20     93632
Name: views, dtype: int64

by_utc.idxmax() is 19 and by_utc.idxmin() is 2. Read that as a finding and you would say Hebrew Wikipedia peaks at seven in the evening and bottoms out at two in the morning. Both statements are false, and neither one looks wrong.

Israel ran on UTC+3 through these two months. Convert the column before you take the hour off it:

by_local = (df
            .groupby(df['ts'].dt.tz_convert('Asia/Jerusalem').dt.hour)['views']
            .mean().round().astype(int))

by_local.loc[[1, 2, 3, 18, 19, 20]]
ts
1     50413
2     34395
3     24484
18    78628
19    77863
20    83006
Name: views, dtype: int64

Every number moved three slots. The peak is at 22:00 and the quiet hour is 05:00 — which is what a country's reading habits actually look like, and three hours away from the first answer. The totals are identical; only the labels changed, and the labels were the answer.

Mistakes people make

Taking .dt.hour off a UTC column and calling it the local hour. The one above. Nothing raises, the shape of the curve is right, and the conclusion is off by your offset. Check df['ts'].dtype first: datetime64[us, UTC] is a warning, and plain datetime64[us] means nobody has told Pandas anything.

Reaching for tz_convert on a naive column. This one is loud, at least:

df['ts'].dt.tz_localize(None).dt.tz_convert('Asia/Jerusalem')
TypeError: Cannot convert tz-naive timestamps, use tz_localize to localize

The two verbs are not interchangeable. tz_localize attaches a timezone to timestamps that had none, asserting what the numbers always meant. tz_convert changes the numbers to another zone. A naive column needs .dt.tz_localize('UTC').dt.tz_convert('Asia/Jerusalem'), in that order.

Assuming every local day has 24 of them. Once you are in a real timezone, the day the clocks go forward is 23 hours long and the day they go back is 25, with one hour appearing twice. Pooling across a year hides that; a per-day count does not.

Binning by hand. Morning, afternoon and night are not .dt.hour — they are .dt.hour fed to cut, which is what Bamboo Weekly #172 does below.

Where it shows up in Bamboo Weekly

Four Bamboo Weekly solutions use .dt.hour on real data.

Bamboo Weekly #48: Aviation accidents is the free one, and the simplest shape: .dt.hour on NTSB reports to ask at what time of day the flights with injuries happen.

Bamboo Weekly #172: World Cup turns the number into three named parts of the day with pd.cut, passing right=False and include_lowest=True so the boundaries land where you meant them, then groups goals scored by that. Morning games average 2.25 goals and night games 2.94.

Bamboo Weekly #113: US airport traffic answers a question you cannot answer without this attribute — how many people enter the US at each hour — and shows how to reach an hour that is buried in a multi-index, with reset_index first.

Bamboo Weekly #161: Missiles in Israel histograms air-raid alerts by hour, and is worth reading for one keyword: nbins=24, because the default of ten bins across a 24-hour day produces buckets nobody can interpret.

Practice it

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

Go deeper

The numeric siblings and the no-parentheses rule are on dt.year; the weekday half of time-of-day analysis is on dt.dayofweek. If you want the calendar day rather than the clock time, that is dt.date. None of this works until the column is a real datetime, which is to_datetime — and it takes utc=True, the cheapest way to make sure the column knows what it holds. The Pandas user guide's time series chapter has the full treatment of timezone handling.

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