Overview

Time zone bugs are unusually nasty because they pass every test you write and fail twice a year in production, usually at 2am local time on a Sunday. Python's standard library got meaningfully better at this in 3.9 with zoneinfo, but the APIs still let you shoot yourself in the foot.

The rule that prevents almost all of it: store UTC, convert at the edges, and never create a naive datetime.

Naive versus aware

A naive datetime has no timezone information attached. It's a wall clock reading with no answer to "which wall."

from datetime import datetime

naive = datetime.now()
print(naive.tzinfo)   # None

An aware datetime knows its offset:

from datetime import datetime, timezone

aware = datetime.now(timezone.utc)
print(aware.tzinfo)   # UTC

Python will happily let you subtract one from the other, and it will be wrong. It'll also let you compare them, which raises TypeError in Python 3 — a small mercy.

If you remember one thing from this article: use datetime.now(timezone.utc), not datetime.utcnow(). The latter returns a naive datetime and is deprecated for exactly this reason.

zoneinfo in practice

zoneinfo replaced pytz as the recommended approach. It uses the system's IANA time zone database (or the tzdata package on systems that lack one):

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

utc_now = datetime.now(timezone.utc)

# Convert to a local zone for display
tokyo = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
new_york = utc_now.astimezone(ZoneInfo("America/New_York"))

print(tokyo)      # 2026-09-18 23:30:00+09:00
print(new_york)   # 2026-09-18 10:30:00-04:00

Note that astimezone is the right method, not replace. Using replace(tzinfo=...) on an aware datetime tells Python "this same wall clock reading is now in a different zone," which is almost never what you mean:

# Wrong: changes the zone without adjusting the time
utc_now.replace(tzinfo=ZoneInfo("Asia/Tokyo"))

# Right: same instant, expressed in another zone
utc_now.astimezone(ZoneInfo("Asia/Tokyo"))

This mistake is responsible for a lot of off-by-nine-hours bugs.

Install tzdata if you're on a slim container

Alpine and python:*-slim images often have no system zone database, so ZoneInfo("America/New_York") raises ZoneInfoNotFoundError at runtime — in production, not in tests, because your dev machine has the database.

pip install tzdata

That package ships the database with your application, which is what you want anyway. You get consistent behavior regardless of the host OS.

DST is where the real bugs live

Most zones have a one-hour gap in spring and a one-hour overlap in autumn. Both produce ambiguous or nonexistent local times.

In the US, 2:00–3:00 AM on the second Sunday in March doesn't exist. If you construct a naive datetime for that range and attach a zone, Python picks something:

from datetime import datetime
from zoneinfo import ZoneInfo

# 2:30 AM on a spring-forward day in New York
dt = datetime(2026, 3, 8, 2, 30, tzinfo=ZoneInfo("America/New_York"))
print(dt)   # 2026-03-08 02:30:00-05:00

That timestamp never existed, but Python produced one anyway, silently choosing the pre-transition offset. Same problem in reverse during the autumn overlap: 1:30 AM happens twice, and you get the first occurrence.

This matters most for scheduling. A daily job scheduled at 2:30 AM local time will skip a day or run twice depending on the transition. If you're building a scheduler, schedule in UTC and convert for display, or use a library that understands the calendar semantics — APScheduler with a timezone-aware trigger, or croniter with awareness of the zone.

Working with an API

Most APIs that do this correctly accept and return ISO 8601 with an offset. Python 3.11+ parses that out of the box:

from datetime import datetime

dt = datetime.fromisoformat("2026-09-18T14:30:00+02:00")
print(dt.tzinfo)   # UTC+02:00

On older versions, fromisoformat chokes on the Z suffix that many APIs use for UTC. Work around it:

def parse_iso(s: str) -> datetime:
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

Storing and comparing

ContextWhat to do
Database columnUse timestamptz in Postgres, not timestamp. It stores UTC and handles conversion.
Log timestampsUTC, always, with the offset in the string
API responsesISO 8601 with offset, or epoch seconds
User-facing displayConvert at the very last moment, in the presentation layer
Business logicUTC, no exceptions

The Postgres point deserves emphasis because it's a one-way migration later. timestamp without time zone stores a wall clock reading with no offset. When you later need to know what instant it referred to, you can't reconstruct it. Use timestamptz from the start.

Users' time zones

You need two things: the user's zone, and whether they want a fixed zone or their current one.

# From a stored preference
user_tz = ZoneInfo(user.timezone)          # "Europe/Berlin"

# From a browser, sent to your API
user_tz = ZoneInfo(request.headers["X-Timezone"])

# Fixed offset from a client that only sent a UTC offset
from datetime import timezone, timedelta
offset = timezone(timedelta(minutes=request.json["utc_offset_minutes"]))

Store the IANA name (Europe/Berlin), not the offset. Offsets change twice a year in most places; the zone name doesn't. A user in Berlin who has +01:00 saved will see all their times an hour off for half the year.

The checklist

  • datetime.now(timezone.utc) — never utcnow(), never naive now()
  • astimezone() to convert, never replace(tzinfo=...)
  • zoneinfo, not pytz
  • pip install tzdata in containers
  • timestamptz in Postgres
  • Store IANA zone names, not offsets
  • Convert in the presentation layer only
  • Test with a spring-forward and a fall-back date in your test suite — add them now, they're two lines