Python has quietly become one of the better languages for handling time, but only if you use the modern parts of it. Since version 3.9 the standard library ships zoneinfo, which reads the real IANA time-zone database and makes correct daylight saving arithmetic a one-liner. The catch is that most Python date bugs are not really about zones at all: they come from a single naive datetime that never got told where it lives. Here is how to keep every timestamp honest, with real dates around the March 2026 transition and the exact code that gets each step right.
Naive versus aware: the distinction every bug hides behind
A Python datetime is one of two things. A naive datetime has no tzinfo, so it is just a reading on some clock with no record of which clock. An aware datetime carries a tzinfo and therefore points at exactly one instant in the history of the world. The two look identical when you print them, which is why the difference causes so much quiet damage.
from datetime import datetime, timezone
naive = datetime(2026, 3, 9, 9, 0)
print(naive.tzinfo) # None -> a wall-clock reading, no zone
aware = datetime(2026, 3, 9, 9, 0, tzinfo=timezone.utc)
print(aware.tzinfo) # UTC -> a real instantThe rule that prevents an entire class of failures: make a datetime aware the moment it enters your program, from a form field, an API payload, or a database row, and never let a naive one travel further than that boundary. Subtracting two naive datetimes that came from different zones produces a number that looks plausible and is wrong.
zoneinfo: the standard-library answer
The ZoneInfo class attaches a named IANA zone to a datetime, and it knows the full history of that zone's offset changes and daylight saving rules. You do not need pytz, and you do not need to install anything on Python 3.9 or later.
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
before = datetime(2026, 3, 7, 9, 0, tzinfo=ny)
after = datetime(2026, 3, 9, 9, 0, tzinfo=ny)
print(before.isoformat()) # 2026-03-07T09:00:00-05:00 (EST, UTC-5)
print(after.isoformat()) # 2026-03-09T09:00:00-04:00 (EDT, UTC-4)Look closely at the offsets. The same 09:00 wall-clock time is UTC-5 on 7 March and UTC-4 on 9 March, because United States daylight saving began at 02:00 local on Sunday 8 March 2026. zoneinfo applied the right offset on each side of the transition without being asked. That is the whole point of naming the zone instead of hard-coding a number. If you want to eyeball an offset before writing code, the time zone converter reads it straight off for any city and date.
The pytz trap that has bitten everyone
Plenty of older code and tutorials still use pytz, and pytz has one behaviour that catches newcomers every single time. If you attach a pytz zone the natural way, with tzinfo=, you do not get the modern offset. You get the zone's Local Mean Time from the nineteenth century, before standard offsets existed.
import pytz
from datetime import datetime
ny = pytz.timezone("America/New_York")
wrong = datetime(2026, 3, 9, 9, 0, tzinfo=ny)
print(wrong.isoformat()) # 2026-03-09T09:00:00-04:56 <- Local Mean Time, not EDT
right = ny.localize(datetime(2026, 3, 9, 9, 0))
print(right.isoformat()) # 2026-03-09T09:00:00-04:00 <- correctThat -04:56 offset is not a typo, it is New York's solar-clock offset from 1883, and pytz hands it to you because its zone objects were designed to be applied through localize() rather than attached directly. This is exactly the friction zoneinfo removes: with ZoneInfo the direct tzinfo= form is the correct form. If you are on 3.9 or later, migrating off pytz is usually a search-and-replace plus deleting every localize() call.
A worked example: a standup that survives 8 March 2026
Say a team runs a daily standup at 09:00 New York time and stores it. A tempting shortcut is to compute the UTC time once and save that fixed instant. Watch what that does across the spring transition.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
# 09:00 New York on 7 March, converted to UTC and frozen
frozen_utc = datetime(2026, 3, 7, 9, 0, tzinfo=ny).astimezone(timezone.utc)
print(frozen_utc.isoformat()) # 2026-03-07T14:00:00+00:00
# Read that frozen instant back as New York time on 9 March
print(frozen_utc.astimezone(ny).isoformat()) # 2026-03-09T10:00:00-04:00The frozen 14:00 UTC was 09:00 in New York before the clocks moved. After 8 March, New York is one hour ahead of where it was, so that same 14:00 UTC now reads as 10:00 local. The standup silently drifts an hour late for everyone in New York, and it will keep drifting every time a rule changes. The fix is to store the wall-clock time together with the zone name America/New_York and recompute the UTC instant per occurrence, so zoneinfo always applies the offset that is correct for that specific date. This is the same recurring-event failure that breaks calendar invites, and the daylight saving survival guide lists every 2026 transition date so you can see exactly which of your scheduled jobs sit near one.
Converting between zones and to UTC for storage
Conversion is always the same call: astimezone. It never changes the instant, only the zone you view it through, so an aware datetime converted to any number of zones still names one moment.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
meeting = datetime(2026, 3, 9, 9, 0, tzinfo=ZoneInfo("America/New_York"))
print(meeting.astimezone(timezone.utc).isoformat())
# 2026-03-09T13:00:00+00:00
print(meeting.astimezone(ZoneInfo("Europe/London")).isoformat())
# 2026-03-09T13:00:00+00:00 (London is still on GMT until 29 March 2026)
print(meeting.astimezone(ZoneInfo("Asia/Singapore")).isoformat())
# 2026-03-09T21:00:00+08:00Note that London reads 13:00 with a +00:00 offset here, because the United Kingdom does not move to British Summer Time until 29 March 2026, three weeks after the United States shifts. For those three weeks the usual five-hour gap between New York and London is only four hours, which is the kind of seam that quietly wrecks a cross-Atlantic schedule. For the general rules on why to persist UTC for past instants and wall-time-plus-zone for future ones, the companion post on storing dates covers the database side in full.
Parsing and formatting: lean on ISO 8601
The safest string to move a timestamp around in is ISO 8601 with an explicit offset, for example 2026-03-09T13:00:00+00:00. Since Python 3.11, fromisoformat parses the full range of these, including a trailing Z for UTC, and isoformat produces them.
from datetime import datetime
# 3.11+ accepts the Z suffix and offsets
ts = datetime.fromisoformat("2026-03-09T13:00:00Z")
print(ts.tzinfo) # UTC
print(ts.strftime("%Y-%m-%d %H:%M %Z")) # 2026-03-09 13:00 UTCAvoid parsing a timestamp with strptime and no %z, because that gives you back a naive datetime and drops you straight into the first problem in this article. If the input string has no offset at all, decide the source zone deliberately and attach it, rather than letting a naive value slip through. Developers coming from the browser will find the parallel decisions laid out in the JavaScript date and time guide, and the conceptual grounding for offsets and zone names sits in time zones explained.
Gaps, overlaps, and the fold flag
Two moments a year, a wall-clock time is either missing or duplicated. In spring, the hour from 02:00 to 03:00 vanishes, so 02:30 never happens. In autumn, the hour from 01:00 to 02:00 repeats, so 01:30 happens twice. Python marks the second occurrence with the fold attribute.
from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
# Autumn overlap: 1 November 2026, 01:30 happens twice
first = datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=ny)
second = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=ny)
print(first.utcoffset()) # -1 day, 20:00:00 (EDT, UTC-4) first pass
print(second.utcoffset()) # -1 day, 19:00:00 (EST, UTC-5) second passThe default fold=0 picks the first occurrence, which is usually what you want, but if a timestamp near a transition drives billing, logging order, or an alarm, set fold explicitly so you are not silently choosing one of two real instants by accident.
Common Python time-zone mistakes
- Using datetime.utcnow(). It returns a naive datetime that only looks like UTC. Use datetime.now(timezone.utc) so the result is actually marked as UTC.
- Attaching pytz zones with tzinfo=. You get a Local Mean Time offset from the 1800s. Use zone.localize(), or move to zoneinfo where tzinfo= is correct.
- Storing a frozen UTC instant for a future local event. It drifts across the next daylight saving change. Store wall time plus the IANA zone name instead.
- Hard-coding an offset like UTC-5 for a city. The offset changes twice a year. Name the zone and let zoneinfo pick the offset for the date.
- Parsing with strptime and no %z. That yields a naive value and hides the missing zone until much later.
A time-zone-safe Python checklist
- Attach a zone to every datetime at the boundary where it enters your code.
- Use zoneinfo and ZoneInfo("Region/City"), not pytz, on Python 3.9 or later.
- Get the current time with datetime.now(timezone.utc), never datetime.utcnow().
- Store past instants as aware UTC; store future events as wall time plus a zone name.
- Move timestamps between systems as ISO 8601 strings that include the offset.
- Set fold explicitly when a time sits inside an autumn overlap and the choice matters.
Frequently asked questions
What is the difference between a naive and an aware datetime in Python?
A naive datetime has no tzinfo attached, so it stores a wall-clock reading with no idea which zone it belongs to. An aware datetime carries a tzinfo, so it maps to a single unambiguous instant. Almost every Python time-zone bug is a naive datetime being treated as if it were aware. Make datetimes aware at the boundary where they enter your program and keep them aware.
Should I use zoneinfo or pytz in 2026?
Use zoneinfo. It has been in the standard library since Python 3.9, needs no third-party install, reads the system IANA database, and lets you attach a zone directly with tzinfo=ZoneInfo('Europe/London'). pytz still works but requires the localize() and normalize() dance, and attaching a pytz zone with tzinfo= gives a wrong nineteenth-century offset. Reach for pytz only to keep an old codebase running.
Does datetime.now() return UTC in Python?
No. Plain datetime.now() returns a naive local-clock reading with no zone, and datetime.utcnow() returns a naive datetime whose numbers happen to be UTC but which is still not marked as UTC, which is a common trap. Use datetime.now(timezone.utc) to get a correct aware UTC timestamp, and treat datetime.utcnow() as deprecated.
How do I store a Python datetime with its time zone in a database?
For a past or logged instant, convert to aware UTC with astimezone(timezone.utc) and store that in a timestamptz column. For a future local appointment, store the wall-clock time plus the IANA zone name such as Europe/London in a separate column, because the UTC offset for that future date is not knowable until the daylight saving rules are fixed. Never store a bare local time with no zone.
How does Python handle a time that lands in the daylight saving gap or overlap?
In the spring gap, a wall-clock time like 02:30 does not exist, and zoneinfo resolves it by shifting forward using the offset after the transition. In the autumn overlap, a time like 01:30 happens twice, and the fold attribute on the datetime marks which occurrence you mean: fold=0 is the first pass, fold=1 is the second. Set fold explicitly whenever a time near a transition matters.