datetime and time

Updated

September 8, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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 with ZoneInfo.
  • Never save datetime.now() with no tzinfo as the system of record.
  • Parse ISO-8601 with an offset (fromisoformat). Reject naive strings at the boundary.
  • Use timedelta for durations. Do not add raw hours onto a naive local clock.
  • time.monotonic() is for measuring elapsed seconds, not for timestamps.

Try this

  1. In shift_length.py, print the close time in America/Chicago.
  2. In parse_opened.py, parse 2026-09-07T09:30:00-05:00 and print UTC isoformat().
  3. Replace timezone.utc in utc_now.py with ZoneInfo("UTC") and print whether tzinfo == timezone.utc (it may not — compare utcoffset() instead).