datetime and time
datetime and time
Store instants as timezone-aware UTC. Display them in a desk zone with zoneinfo. Do not treat naive “local time” as something you can save and compare later.
Mental model
datetime.datetime is a calendar stamp: year, month, day, hour, minute, second. Naive means tzinfo is None — a wall clock with no location. Aware means it knows its offset.
datetime.timezone.utc is the boring zone for storage and for datetime.now. zoneinfo.ZoneInfo names an IANA zone (America/Chicago) for display and for “what time is it at this desk.”
timedelta is a duration (eight hours in a shift), not a clock.
The time module is clocks and sleeps (time.monotonic, time.sleep). It is not where you keep “Tuesday 14:30.” Use datetime for that.
Worked examples
Case 1: An aware instant, UTC in, desk zone out
Save as shift_open.py. The stored value is UTC. The desk clock is a conversion.
# shift_open.py
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def main() -> None:
opened = datetime(2026, 9, 7, 14, 30, tzinfo=timezone.utc)
desk_tz = ZoneInfo("America/Chicago")
print(opened.isoformat())
print(opened.astimezone(desk_tz).isoformat())
if __name__ == "__main__":
main()Run:
uv run python shift_open.pyOutput:
2026-09-07T14:30:00+00:00
2026-09-07T09:30:00-05:00
isoformat() is the string you put in JSON and logs.
Case 2: timedelta for a shift length
Save as shift_length.py. Add a duration to an aware datetime. The result stays aware.
# shift_length.py
from datetime import datetime, timedelta, timezone
def main() -> None:
opened = datetime(2026, 9, 7, 14, 30, tzinfo=timezone.utc)
closed = opened + timedelta(hours=8)
print(closed.isoformat())
print(str(closed - opened))
if __name__ == "__main__":
main()Run:
uv run python shift_length.pyOutput:
2026-09-07T22:30:00+00:00
8:00:00
Case 3: Parse the ISO string you stored
Save as parse_opened.py. fromisoformat keeps the offset. +00:00 comes back as timezone.utc.
# parse_opened.py
from datetime import datetime, timezone
def main() -> None:
stored = "2026-09-07T14:30:00+00:00"
opened = datetime.fromisoformat(stored)
print(opened.tzinfo == timezone.utc)
print(opened.hour, opened.minute)
if __name__ == "__main__":
main()Run:
uv run python parse_opened.pyOutput:
True
14 30
Case 4: “Now” is UTC when you ask for now
Save as utc_now.py. datetime.now(timezone.utc) is the call. Do not use datetime.now() with no arguments for something you will store.
# utc_now.py
from datetime import datetime, timezone
def main() -> None:
now = datetime.now(timezone.utc)
print(now.tzinfo == timezone.utc)
print(now.utcoffset().total_seconds() == 0)
if __name__ == "__main__":
main()Run:
uv run python utc_now.pyOutput:
True
True
The clock value changes every run. The zone does not. Assert the zone, not the digits.
The trap
Naive and aware datetimes do not mix. Stamping a local wall time as UTC is a silent five-hour lie.
Save as naive_trap.py:
# naive_trap.py
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def main() -> None:
naive = datetime(2026, 9, 7, 9, 30)
aware = datetime(2026, 9, 7, 14, 30, tzinfo=timezone.utc)
try:
print(naive < aware)
except TypeError as exc:
print(type(exc).__name__)
wrong = datetime(2026, 9, 7, 9, 30, tzinfo=timezone.utc)
right = datetime(
2026, 9, 7, 9, 30, tzinfo=ZoneInfo("America/Chicago")
).astimezone(timezone.utc)
print(wrong.isoformat())
print(right.isoformat())
if __name__ == "__main__":
main()Run:
uv run python naive_trap.pyOutput:
TypeError
2026-09-07T09:30:00+00:00
2026-09-07T14:30:00+00:00
wrong is “09:30 UTC.” right is “09:30 at the Chicago desk,” stored as UTC. Those are different instants. Attach the real zone first, then convert to UTC to store.
The boring rule
- Store UTC (
timezone.utc). Display withZoneInfo. - Never save
datetime.now()with notzinfoas the system of record. - Parse ISO-8601 with an offset (
fromisoformat). Reject naive strings at the boundary. - Use
timedeltafor durations. Do not add raw hours onto a naive local clock. time.monotonic()is for measuring elapsed seconds, not for timestamps.
Try this
- In
shift_length.py, print the close time inAmerica/Chicago. - In
parse_opened.py, parse2026-09-07T09:30:00-05:00and print UTCisoformat(). - Replace
timezone.utcinutc_now.pywithZoneInfo("UTC")and print whethertzinfo == timezone.utc(it may not — compareutcoffset()instead).