mrkeyoor.com_
Thu 06 Aug 00:59 UTC
PyPIUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5The API has been effectively frozen for over a decade; releases are timezone data refreshes, so upgrades never break code.
Docs3/5Documentation is a single long README on pythonhosted.org; it does cover localize, normalize, and is_dst honestly, but there is no structured reference and newcomers routinely miss the constructor trap anyway.
Maintenance3/5Data releases land promptly (2026.3.post1 in July 2026, repo pushed the same month) but it is a one-maintainer, feature-frozen project with 31 open issues and PRs; fine for data, do not expect changes.
Ecosystem4/5135.9M weekly downloads, largely as a transitive dependency of older libraries; the ecosystem's direction of travel is away from it, with Django 4+ and pandas 2+ on zoneinfo.

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
Skip it if

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:00

localize() 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:00

A 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:00

datetime 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.utc

Zone 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 needed

With 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

PackageRegistryPick it when
tzdataPyPIPair with stdlib zoneinfo on Python 3.9+ for the same IANA data without pytz's API traps
backports.zoneinfoPyPIYou want the zoneinfo API on Python 3.6 to 3.8
python-dateutilPyPIdateutil.tz gives tzinfo objects that work with the normal datetime constructor, plus parsing and recurrence tools
pendulumPyPIYou want a friendlier drop-in datetime replacement with timezone handling built into every operation