Convert between time zones¶
The question. You have a datetime in one zone — UTC for storage, or local for a user — and you need it in another. Maybe to display a stored UTC timestamp in London time, or to store a user's entered local time in UTC.
The two-line pattern: attach a zone to make the datetime aware, then call .astimezone() to convert. Every cross-zone conversion in modern Python (3.9+) uses this shape.
from datetime import datetime
from zoneinfo import ZoneInfo
utc = ZoneInfo('UTC')
london = ZoneInfo('Europe/London')
new_york = ZoneInfo('America/New_York')
# UTC -> local for display
stored = datetime(2026, 4, 21, 14, 30, tzinfo=utc)
display = stored.astimezone(london)
print('UTC stored:', stored)
print('London: ', display)
print('formatted: ', display.strftime('%d %B %Y, %H:%M %Z')) # %Z prints the zone
# Local -> UTC for storage (attach the user's zone, then convert)
entered = datetime(2026, 9, 15, 15, 0, tzinfo=london)
for_storage = entered.astimezone(utc)
print('\nuser entered (London):', entered)
print('for storage (UTC): ', for_storage)
# Comparing aware datetimes across zones — Python normalises to UTC
london_mtg = datetime(2026, 9, 15, 15, 0, tzinfo=london)
ny_mtg = datetime(2026, 9, 15, 9, 0, tzinfo=new_york)
print('\nlondon > ny?', london_mtg > ny_mtg) # True: 14:00 UTC > 13:00 UTC
# Variant: 'the same wall-clock time in every zone' — per-user 09:00 local
users = {
'alice': london,
'bob': new_york,
'carmen': ZoneInfo('Asia/Tokyo'),
}
for name, zone in users.items():
local_9am = datetime(2026, 9, 15, 9, 0, tzinfo=zone)
print(f'{name:8} local 09:00 = {local_9am.astimezone(utc)} UTC')
# Three different UTC instants — schedule each user's reminder separately.
# DST watch-out: attach the zone BEFORE arithmetic, not after
from datetime import timedelta
mtg = datetime(2026, 3, 29, 9, 0, tzinfo=london) # day BST starts
print('Meeting: ', mtg.astimezone(utc)) # 08:00 UTC (BST)
print('Same meeting -1d:', (mtg - timedelta(days=1)).astimezone(utc)) # 09:00 UTC (GMT)
# The UTC offset shifts because we crossed the spring-forward boundary.
# Elapsed real time is still 24 hours; the clock representation changes.
Why it works¶
A datetime is either naive (no zone attached — just a string of digits) or aware (knows which zone it's in). Arithmetic and comparison on aware datetimes are correct across zones; the same operations on naive ones are silently wrong as soon as a DST transition shows up. .astimezone(zone) is defined only on aware datetimes — it computes the same real-world moment, rendered in the target zone.
zoneinfo.ZoneInfo (3.9+) reads the IANA timezone database that your operating system already has. IANA names like Europe/London know about DST boundaries, historical offset changes, and leap seconds. Fixed offsets (+01:00) don't — they're frozen in time and will silently mis-handle the next spring-forward. Always name the zone; never hard-code the offset.
%Z in a strftime format string emits the zone name (BST, GMT, EDT), which matters in UIs so users know which zone they're looking at.
Trade-offs¶
Store in UTC, convert on display. UTC doesn't have DST, doesn't depend on a user's machine settings, and compares trivially. Any other storage convention is a footgun waiting to go off. For the full argument, see the UTC everywhere essay.
Attach the zone before arithmetic. datetime(2026, 3, 29, 9, 0) - timedelta(days=1) then .replace(tzinfo=london) gives wrong results around DST boundaries. datetime(2026, 3, 29, 9, 0, tzinfo=london) - timedelta(days=1) gives correct ones. The rule: zone first, then arithmetic.
.replace(tzinfo=zone) is almost always wrong. It claims the datetime is in that zone without checking. Use it to attach a zone to a naive datetime that you know is in that zone; never use it to convert. For actual conversion, always use .astimezone(zone).
datetime.utcnow() is deprecated in 3.12 and returns a naive datetime anyway. Use datetime.now(tz=ZoneInfo('UTC')) for the current UTC instant — aware, correct, obvious.
Related reading¶
- UTC everywhere — the design essay for the "why store UTC?" question.
- Avoid common datetime mistakes — including naive/aware mixing and
utcnow(). - Time-zone aware formatting — every
%Z/%zsubtlety.