pytz
pytz packages the IANA timezone database for Python and was the standard way to do timezone math for nearly two decades. You look up a zone with pytz.timezone('Europe/Paris'), attach it to a naive datetime with localize(), and repair wall-clock times after arithmetic with normalize(). That three-step dance exists because pytz predates modern tzinfo support. Since Python 3.9 the standard library zoneinfo module does the same job with none of those steps, so today pytz is mostly a compatibility dependency that still receives prompt timezone data updates.
Keep it patched in legacy code and do not start anything new with it. The localize/normalize API was a clever workaround for a stdlib gap that closed in 2020; zoneinfo plus the tzdata package is the modern path.
Use it if
- You maintain a codebase that already uses pytz everywhere and a migration to zoneinfo is not budgeted; it still works and the data stays current
- You support Django older than 4.0 or other legacy frameworks whose internals expect pytz timezone objects
- You need helpers the stdlib does not have, like pytz.country_timezones('IN') or the curated pytz.common_timezones list
- You are stuck on Python 3.8 or earlier where zoneinfo does not exist and adding backports.zoneinfo is not an option
- You are writing new code on Python 3.9+: zoneinfo is in the standard library, works with the plain datetime constructor, and handles DST through the fold attribute with no localize/normalize ritual
- Your team is not fluent in its traps: datetime(2026, 8, 5, tzinfo=pytz.timezone('Europe/Paris')) silently applies a 19th-century local mean time offset (+00:09 for Paris) instead of CET, and nothing warns you
- You do datetime arithmetic across DST boundaries: forgetting tz.normalize() after adding a timedelta yields times that are an hour off, and this failure is silent
- You are choosing it for active development: pytz is feature-frozen by design; releases only refresh the timezone data, and the wider ecosystem (Django 4+, pandas 2+) has already moved to zoneinfo plus tzdata
Setup reality
pip install pytz is trivial: pure Python, no compilation, data bundled inside the wheel, works on any interpreter. The cost is entirely conceptual. You must learn that a pytz zone passed as tzinfo= to the datetime constructor is wrong, that localize() is the only correct way to attach a zone, that normalize() must follow any arithmetic, and that ambiguous or nonexistent times around DST transitions need the is_dst argument (or is_dst=None to get an exception). Version numbers track timezone database releases (2026.3.post1 shipped July 2026), so keep it floating rather than pinned.
Patterns
Attach a timezone to a naive datetimelocalize-naive-datetime
from datetime import datetime
import pytz
paris = pytz.timezone('Europe/Paris')
dt = paris.localize(datetime(2026, 8, 5, 12, 0))
print(dt) # 2026-08-05 12:00:00+02:00localize() is the only correct way to attach a pytz zone to a naive datetime. It picks the right UTC offset for that specific date, including DST.
The classic bug: tzinfo= in the constructoravoid-tzinfo-constructor
from datetime import datetime
import pytz
paris = pytz.timezone('Europe/Paris')
wrong = datetime(2026, 8, 5, 12, tzinfo=paris)
print(wrong) # 2026-08-05 12:00:00+00:09 <- local mean time!
right = paris.localize(datetime(2026, 8, 5, 12))
print(right) # 2026-08-05 12:00:00+02:00A pytz zone object defaults to the zone's first historical rule (local mean time, +00:09 for Paris) until localize() selects the right one. This bug passes tests that only check the date.
Convert an aware datetime to another zoneconvert-between-zones
import pytz
ny = pytz.timezone('America/New_York')
kolkata = pytz.timezone('Asia/Kolkata')
dt_ny = dt.astimezone(ny)
dt_in = dt.astimezone(kolkata)astimezone() on an already-aware datetime is safe with pytz; only creating aware datetimes needs localize().
Get the current time in UTCcurrent-time-utc
from datetime import datetime
import pytz
now = datetime.now(pytz.utc)
print(now.isoformat())pytz.utc is safe to pass as tzinfo because UTC has no DST or historical offset changes; it is the one zone where the constructor shortcut works.
Add a timedelta and fix the wall clockarithmetic-across-dst
from datetime import datetime, timedelta
import pytz
paris = pytz.timezone('Europe/Paris')
summer = paris.localize(datetime(2026, 8, 5, 12, 0))
winter = summer + timedelta(days=150) # crosses DST end, offset stays +02:00
winter = paris.normalize(winter) # corrects to +01:00datetime arithmetic works in absolute time, so the stored offset goes stale when you cross a DST transition. Skipping normalize() leaves the wall-clock hour wrong with no error.
Handle ambiguous and nonexistent times explicitlyhandle-ambiguous-times
from datetime import datetime
import pytz
paris = pytz.timezone('Europe/Paris')
# 02:30 on 2026-10-25 happens twice (clocks fall back 03:00 -> 02:00)
try:
paris.localize(datetime(2026, 10, 25, 2, 30), is_dst=None)
except pytz.exceptions.AmbiguousTimeError:
dst_side = paris.localize(datetime(2026, 10, 25, 2, 30), is_dst=True)
std_side = paris.localize(datetime(2026, 10, 25, 2, 30), is_dst=False)Default is_dst=False silently picks one interpretation. Pass is_dst=None to raise AmbiguousTimeError or NonExistentTimeError instead, which is what you want for user-entered times.
List zone names and zones by countrylist-timezones
import pytz
pytz.common_timezones[:3] # curated, current zones
len(pytz.all_timezones) # everything, including aliases
pytz.country_timezones('IN') # ['Asia/Kolkata']
pytz.country_names['IN'] # 'India'Use common_timezones for user-facing dropdowns; all_timezones includes deprecated aliases like US/Eastern that you should not show.
Handle bad zone namesunknown-zone-error
import pytz
try:
tz = pytz.timezone(user_input)
except pytz.exceptions.UnknownTimeZoneError:
tz = pytz.utcZone lookup is case-sensitive: 'europe/paris' raises UnknownTimeZoneError even though 'Europe/Paris' exists.
Format with zone abbreviation and offsetformat-with-zone
dt = paris.localize(datetime(2026, 8, 5, 12, 0))
dt.strftime('%Y-%m-%d %H:%M %Z%z') # '2026-08-05 12:00 CEST+0200'
dt.tzname() # 'CEST'
dt.utcoffset() # timedelta(seconds=7200)Abbreviations like CEST are ambiguous across the world (CST means three different things); store the IANA zone name and offset, not the abbreviation.
Migrate a call site from pytz to zoneinfomigrate-to-zoneinfo
# before (pytz)
import pytz
paris = pytz.timezone('Europe/Paris')
dt = paris.localize(datetime(2026, 8, 5, 12))
later = paris.normalize(dt + timedelta(days=150))
# after (Python 3.9+)
from zoneinfo import ZoneInfo
dt = datetime(2026, 8, 5, 12, tzinfo=ZoneInfo('Europe/Paris'))
later = dt + timedelta(days=150) # no normalize neededWith zoneinfo the plain constructor is correct and arithmetic re-resolves the offset automatically; the fold attribute (0 or 1) replaces is_dst for ambiguous times. Add the tzdata package on Windows and slim containers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| tzdata | PyPI | Pair with stdlib zoneinfo on Python 3.9+ for the same IANA data without pytz's API traps |
| backports.zoneinfo | PyPI | You want the zoneinfo API on Python 3.6 to 3.8 |
| python-dateutil | PyPI | dateutil.tz gives tzinfo objects that work with the normal datetime constructor, plus parsing and recurrence tools |
| pendulum | PyPI | You want a friendlier drop-in datetime replacement with timezone handling built into every operation |