89.7 Timezone-Aware Datetimes: zoneinfo (Python 3.9+) and pytz
Right, let’s talk about time. Or more specifically, let’s talk about how computers handle the bafflingly human concept of timezones. If you’ve ever tried to schedule a meeting with someone in another country and felt a deep, existential dread, you already understand the problem. Your database stores a timestamp, but is that timestamp in UTC? Local time? The timezone of your server, which is in a data center you’ve never actually visited? This is how outages and missed birthday calls happen.
We’re going to fix that. We’re going to make our datetimes aware. An “aware” datetime is one that has its head screwed on straight; it knows exactly what point in time it represents because it knows its timezone offset from UTC. A “naive” datetime is one that doesn’t—it’s just a date and a time floating in the void, ambiguous and prone to misinterpretation. Our goal is to banish naivety.
The Old Guard: pytz
For years, the only sane way to handle timezones in Python was the third-party pytz library. It’s a warhorse, battle-tested and complete, but it has some… idiosyncrasies. You can’t ignore it because you’ll find it in a million codebases, so let’s get this over with.
The main thing you need to know about pytz is that it doesn’t play by the same rules as the standard library. You can’t just attach a pytz timezone to a naive datetime. You have to use the .localize() method. If you try the former, it will lie to you silently, and you’ll get subtly wrong offsets.
import pytz
from datetime import datetime
# The WRONG way (a classic pitfall)
naive_dt = datetime(2023, 10, 31, 12, 0) # Halloween, 12pm
tz = pytz.timezone('US/Eastern')
wrong_dt = tz.localize(naive_dt) # This is correct...
also_wrong_dt = naive_dt.replace(tzinfo=tz) # ...but THIS is dangerously incorrect.
# `also_wrong_dt` will have the wrong UTC offset because it doesn't account for DST.
print(wrong_dt) # 2023-10-31 12:00:00-04:00 (EDT)
print(also_wrong_dt) # 2023-10-31 12:00:00-05:00 (EST - wrong for October!)
# The RIGHT way with pytz (always use .localize())
correct_dt = pytz.timezone('US/Eastern').localize(datetime(2023, 10, 31, 12, 0))
print(correct_dt) # 2023-10-31 12:00:00-04:00
See? It’s a trap. pytz is powerful, but you have to handle it with oven mitts. This is why the Python core developers finally decided to bring order to the chaos in Python 3.9.
The New Hope: zoneinfo
Enter zoneinfo, a module in the standard library as of Python 3.9. It uses the system’s timezone database (or the tzdata package if you’re on a weird OS like Windows that doesn’t have one) and, crucially, it behaves like a sane person would expect. It follows the standard library rules.
from datetime import datetime
from zoneinfo import ZoneInfo
# Creating an aware datetime is now intuitive and safe
naive_dt = datetime(2023, 10, 31, 12, 0)
tz_eastern = ZoneInfo('US/Eastern')
aware_dt = naive_dt.replace(tzinfo=tz_eastern)
print(aware_dt) # 2023-10-31 12:00:00-04:00
No more .localize() nonsense. You just .replace(tzinfo=ZoneInfo(...)) and you’re done. It automatically knows about Daylight Saving Time. This is how it should have always worked.
The Golden Rule: Work in UTC, Convert to Local
Here’s the single most important piece of advice I can give you: store and compute times in UTC. UTC is the one true time; it doesn’t have DST, it doesn’t change. It’s the stable bedrock of your application. You only convert to a user’s local time for display purposes.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# 1. Create an event in UTC. This is what you store in your database.
utc_dt = datetime(2023, 10, 31, 16, 0, tzinfo=timezone.utc) # 4pm UTC
print(f"Stored in DB: {utc_dt}") # 2023-10-31 16:00:00+00:00
# 2. When a user in California asks for the time, convert it for them.
user_tz = ZoneInfo('America/Los_Angeles')
local_dt = utc_dt.astimezone(user_tz)
print(f"User sees: {local_dt}") # 2023-10-31 09:00:00-07:00 (9am PDT)
# 3. When a user in London asks, convert it for them.
london_dt = utc_dt.astimezone(ZoneInfo('Europe/London'))
print(f"Another user sees: {london_dt}") # 2023-10-31 17:00:00+01:00 (5pm BST)
The astimezone() method is your best friend. It handles all the gnarly conversion logic for you. Notice how the same UTC moment becomes 9am on the West Coast and 5pm in the UK. This is the correct way.
Dealing with Ambiguity: When Clocks Fall Back
Alright, let’s address the elephant in the room: the hour that happens twice. In many timezones, when Daylight Saving Time ends, clocks are set back one hour. So 1:30 AM happens twice—once in Daylight Time and once in Standard Time. How does zoneinfo handle this? It makes an assumption, and you need to know about it.
# The ambiguous hour in US/Eastern: 2023-11-05 01:30 happens twice.
ambiguous_time = datetime(2023, 11, 5, 1, 30) # Naive
tz = ZoneInfo('US/Eastern')
# ZoneInfo will default to the *earlier* instance (the DST time)
dt_earlier = ambiguous_time.replace(tzinfo=tz)
print(dt_earlier) # 2023-11-05 01:30:00-04:00
# To get the later instance (the standard time), you need to use the 'fold' attribute
dt_later = ambiguous_time.replace(tzinfo=tz, fold=1)
print(dt_later) # 2023-11-05 01:30:00-05:00
print(dt_earlier == dt_later) # False - they are different moments in time!
print(dt_earlier < dt_later) # True - the DST one happens first.
The fold attribute (0 for first occurrence, 1 for second) is your tool for resolving this ambiguity. If you’re building a system that schedules events during this window, you must account for this. Most of the time, the default behavior is fine, but you can’t claim to be thorough if you don’t know this exists.
The pytz to zoneinfo Migration Guide
So you have a legacy codebase full of pytz? The migration is straightforward but requires attention to detail.
- Stop using
pytz.timezone. UseZoneInfoinstead. - Replace
.localize(dt)with.replace(tzinfo=ZoneInfo(...)). pytz.UTCbecomesdatetime.timezone.utc.- For normalization (a
pytzconcept), useastimezone().pytzhad a.normalize()method to adjust a datetime after arithmetic. Inzoneinfo, you just do the arithmetic and then call.astimezone(ZoneInfo('Your/Timezone'))if you need to. TheZoneInfoclass handles the DST transitions correctly during arithmetic.
The bottom line is this: for any new project on Python 3.9+, use zoneinfo. It’s in the standard library, it’s well-designed, and it removes the sharp edges that made pytz so error-prone. It finally makes dealing with timezones in Python feel… well, not exactly pleasant, but at least comprehensible. And in this line of work, that’s a monumental victory.