Learning Goals
3 minBy the end of this lesson you can:
- Tell the difference between a naive and an aware datetime.
- Create aware datetimes with
zoneinfo.ZoneInfoand UTC. - Convert a moment from one timezone to another with
astimezone. - Follow the golden rule: store UTC, display local.
Warm-Up · The Midnight Bug
5 minA real story: a team schedules a "daily 8 a.m." report on a cloud server. It works in testing. In production it emails at 4 p.m. local time — because the server runs on UTC, and nobody said whose 8 a.m.
from datetime import datetime now = datetime.now() # "naive": no timezone attached print(now) # 2026-05-28 14:30:00 # But 14:30 WHERE? On your laptop? The server? Python doesn't know.
A plain datetime.now() is naive — it carries no timezone, so the same number means different real moments on different machines. An aware datetime tags itself with a timezone, so it points at one unambiguous instant worldwide. For anything scheduled, logged, or shared, you want aware datetimes.
New Concept · Aware Datetimes
14 minNaive vs. aware
from datetime import datetime, timezone from zoneinfo import ZoneInfo naive = datetime.now() # no tzinfo → naive print(naive.tzinfo) # None aware = datetime.now(ZoneInfo("Asia/Kuala_Lumpur")) print(aware.tzinfo) # Asia/Kuala_Lumpur print(aware) # 2026-05-28 14:30:00+08:00
The +08:00 is the offset from UTC — that's what makes it unambiguous. zoneinfo is built into Python 3.9+ and knows every IANA timezone name ("Asia/Tokyo", "Europe/London", "America/New_York").
UTC: the universal reference
from datetime import datetime, timezone # the RIGHT way to get "now" for storage: now_utc = datetime.now(timezone.utc) print(now_utc) # 2026-05-28 06:30:00+00:00
UTC (Coordinated Universal Time) is the world's reference clock — it has no daylight saving and never shifts. KL is always UTC+8; London is UTC+0 or +1 depending on the season. Store everything in UTC and you have one consistent timeline.
Converting between zones
from datetime import datetime, timezone from zoneinfo import ZoneInfo # a meeting at 9am New York time ny = datetime(2026, 5, 28, 9, 0, tzinfo=ZoneInfo("America/New_York")) # what time is that in KL and Tokyo? print(ny.astimezone(ZoneInfo("Asia/Kuala_Lumpur"))) # 21:00 +08:00 print(ny.astimezone(ZoneInfo("Asia/Tokyo"))) # 22:00 +09:00 print(ny.astimezone(timezone.utc)) # 13:00 +00:00
astimezone keeps the same instant but expresses it in a different zone's wall-clock time. 9 a.m. in New York is the same moment as 9 p.m. in KL — astimezone does the maths, including daylight saving.
The golden rule
Save timestamps in UTC (in files, databases, logs). Convert to the user's local zone only when showing them. This avoids the entire class of timezone bugs: your data is always on one clock, and display is a last-step formatting concern.
Don't mix naive and aware
naive = datetime.now() aware = datetime.now(timezone.utc) # aware - naive → TypeError! can't subtract across the divide
Python refuses to compare or subtract a naive and an aware datetime — it would be guessing. Keep your program all-aware (recommended) or all-naive, never mixed. To upgrade a naive one whose zone you know: naive.replace(tzinfo=ZoneInfo("Asia/Kuala_Lumpur")).
Formatting shows the offset
aware = datetime.now(ZoneInfo("Asia/Kuala_Lumpur")) print(aware.strftime("%Y-%m-%d %H:%M %Z (%z)")) # 2026-05-28 14:30 +08 (+0800) print(aware.isoformat()) # 2026-05-28T14:30:00+08:00 ← great for APIs
Worked Example · A World-Clock Scheduler Check
12 minGoal: given a job scheduled for "every day at 08:00 Asia/Kuala_Lumpur," answer "has it already run today?" correctly no matter what timezone the server is in — by always reasoning in the job's zone.
from datetime import datetime, time from zoneinfo import ZoneInfo JOB_ZONE = ZoneInfo("Asia/Kuala_Lumpur") RUN_AT = time(8, 0) # 08:00 local to the job def already_ran_today() -> bool: # "now" expressed in the JOB's timezone, not the server's now_local = datetime.now(JOB_ZONE) scheduled = datetime.combine(now_local.date(), RUN_AT, tzinfo=JOB_ZONE) return now_local >= scheduled def next_run() -> datetime: now_local = datetime.now(JOB_ZONE) today_run = datetime.combine(now_local.date(), RUN_AT, tzinfo=JOB_ZONE) if now_local < today_run: return today_run from datetime import timedelta return today_run + timedelta(days=1) print("Server thinks it's:", datetime.now().strftime("%H:%M")) print("Job-zone time: ", datetime.now(JOB_ZONE).strftime("%H:%M %Z")) print("Already ran today? ", already_ran_today()) print("Next run (UTC): ", next_run().astimezone(ZoneInfo("UTC")))
Server thinks it's: 06:30 Job-zone time: 14:30 +08 Already ran today? True Next run (UTC): 2026-05-29 00:00:00+00:00
Read the code
The whole bug class disappears because we never reason in the server's naive clock — every comparison happens in JOB_ZONE. datetime.combine stitches a date and a time into one aware datetime, and we convert the answer to UTC at the end for storage/logging. This is exactly the logic you'll lean on when scheduling real jobs in Lesson 35.
Try It Yourself
13 minPrint the current time in KL, London, New York, and UTC — all from one moment — using astimezone. Confirm they describe the same instant.
Write convert(when, from_zone, to_zone) that takes a "YYYY-MM-DD HH:MM" string in one zone and returns the wall-clock time in another. Test 3 p.m. London → KL.
Hint
from datetime import datetime from zoneinfo import ZoneInfo def convert(when, from_zone, to_zone): dt = datetime.strptime(when, "%Y-%m-%d %H:%M") dt = dt.replace(tzinfo=ZoneInfo(from_zone)) return dt.astimezone(ZoneInfo(to_zone)) print(convert("2026-05-28 15:00", "Europe/London", "Asia/Kuala_Lumpur"))
Write to_utc(local_str, zone) that turns a local time string into a UTC ISO string for storage, and from_utc(iso_str, zone) that reads it back and displays it locally. Prove they round-trip exactly.
Hint
from datetime import datetime, timezone from zoneinfo import ZoneInfo def to_utc(local_str, zone): dt = datetime.strptime(local_str, "%Y-%m-%d %H:%M") dt = dt.replace(tzinfo=ZoneInfo(zone)) return dt.astimezone(timezone.utc).isoformat() def from_utc(iso_str, zone): return datetime.fromisoformat(iso_str).astimezone(ZoneInfo(zone))
Mini-Challenge · The Team Availability Grid
8 minGiven a meeting at a fixed UTC time and a list of team members' timezones, print each person's local time and flag whether it falls in their working hours (09:00-18:00). This is how scheduling tools find a slot that works for everyone.
Show a sample solution
from datetime import datetime, timezone from zoneinfo import ZoneInfo meeting = datetime(2026, 5, 28, 13, 0, tzinfo=timezone.utc) team = { "Aisha": "Asia/Kuala_Lumpur", "Ben": "Europe/London", "Carlos": "America/New_York", "Yuki": "Asia/Tokyo", } for name, zone in team.items(): local = meeting.astimezone(ZoneInfo(zone)) ok = 9 <= local.hour < 18 flag = "✅ working hours" if ok else "⚠️ off hours" print(f"{name:8} {local:%a %H:%M %Z} {flag}")
Non-negotiables: one UTC source moment, per-person astimezone, a working-hours flag.
Recap
3 minA naive datetime has no timezone, so it's ambiguous across machines; an aware one carries a tzinfo and points to a single global instant. Build aware datetimes with datetime.now(ZoneInfo("Area/City")) or datetime.now(timezone.utc), and move between zones with astimezone (same instant, different wall clock). Never mix naive and aware — Python won't let you. The golden rule: store UTC, display local. Get this right and scheduled, logged, and shared times work the same everywhere.
Vocabulary Card
- naive / aware
- A datetime without / with a timezone attached.
- UTC
- The world reference clock with no daylight saving; the safe storage zone.
- zoneinfo.ZoneInfo
- Built-in access to IANA timezones by name (
"Asia/Tokyo"). - astimezone
- Re-expresses the same instant in another timezone.
Homework
4 minBuild worldclock.py that prints a live-style table of the current time in five cities of your choice, sorted by their UTC offset, each labelled with whether it's currently day (06:00-18:00) or night. Use one source moment (datetime.now(timezone.utc)) and convert with astimezone. Add an optional argparse --at "YYYY-MM-DD HH:MM" (interpreted as UTC) to see the table at a chosen moment.
Sample · worldclock.py
import argparse from datetime import datetime, timezone from zoneinfo import ZoneInfo CITIES = { "Los Angeles": "America/Los_Angeles", "New York": "America/New_York", "London": "Europe/London", "Kuala Lumpur":"Asia/Kuala_Lumpur", "Tokyo": "Asia/Tokyo", } p = argparse.ArgumentParser(description="World clock.") p.add_argument("--at", help='UTC moment "YYYY-MM-DD HH:MM"') a = p.parse_args() if a.at: moment = datetime.strptime(a.at, "%Y-%m-%d %H:%M").replace( tzinfo=timezone.utc) else: moment = datetime.now(timezone.utc) rows = [] for city, zone in CITIES.items(): local = moment.astimezone(ZoneInfo(zone)) offset = local.utcoffset().total_seconds() / 3600 period = "day" if 6 <= local.hour < 18 else "night" rows.append((offset, city, local, period)) for offset, city, local, period in sorted(rows): print(f"{city:14} {local:%Y-%m-%d %H:%M %Z} {period}")
Non-negotiables: one UTC source, astimezone per city, sorted by offset, day/night flag, working --at.