Dates, Times and Time Zones

Aware datetimes, UTC storage, fromisoformat parsing and timedelta arithmetic, with formatting kept at the edge.

Syntaxdatetime.fromisoformat(date_string) -> datetime # aware when the string carries an offset

The datetime docs split every datetime into two kinds. An aware object has a tzinfo whose utcoffset() returns a value, so it "represents a specific moment in time that is not open to interpretation". A naive object "does not contain enough information to unambiguously locate itself". A naive datetime is a number that means whatever the machine that printed it assumed — which is why it works on your laptop and lies the day the service is deployed to another region.

UTC in, UTC stored, local only at the edge

Use datetime.now(UTC), never the bare datetime.now(). datetime.UTC was added in 3.11 as an alias for datetime.timezone.utc — the same singleton, two spellings, so UTC is timezone.utc is True. Store and compare UTC; call astimezone(tz) with a zoneinfo.ZoneInfo (stdlib since 3.9) only where a human reads the value.

Parsing and arithmetic

datetime.fromisoformat(s) is the fast path for ISO 8601. In 3.11 it was widened to most ISO 8601 layouts, including a trailing Z and compact forms; before that it only read what isoformat() emitted. For anything else the longhand is datetime.strptime(s, fmt), where %z parses the offset. Subtracting two datetimes gives a timedelta, and adding one shifts the value — but only if both operands are aware or both naive. Mixing them raises TypeError.

Formatting is strftime or isoformat(); prefer isoformat() for anything a machine will read back.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Store a record's created_at so it still means the same instant after a deploy to another region.
  • Parse an ISO 8601 timestamp from a JSON payload and reject one that carries no offset.
  • Compute a 30-day expiry and compare it against now without a timezone bug.
  • Render a UTC timestamp in the viewer's own time zone in a template.

More examples

The DST trap, with a real zone

This is the bug that reaches production twice a year. Scheduling ("same wall time tomorrow") wants the first form; durations ("one hour from now") want the second. ZoneInfo needs the system tz database, or the tzdata package on images that ship without one.

Example · python
from datetime import datetime, timedelta, UTC
from zoneinfo import ZoneInfo        # stdlib since 3.9

berlin = ZoneInfo("Europe/Berlin")
start = datetime(2026, 3, 29, 1, 30, tzinfo=berlin)   # the night the clocks move
print(start.isoformat())             # 2026-03-29T01:30:00+01:00

# Adding keeps the ORIGINAL tzinfo and does no time zone adjustment: the wall
# clock moves and ZoneInfo recomputes the offset from the new wall time.
print((start + timedelta(hours=1)).isoformat())   # 2026-03-29T02:30:00+01:00
# 02:30 does not exist in Berlin that night - the clocks go 02:00 -> 03:00.
print((start + timedelta(hours=2)).isoformat())   # 2026-03-29T03:30:00+02:00
# ...and that offset change makes the result only ONE hour after start.

# Elapsed-time arithmetic: go through UTC and come back.
later = (start.astimezone(UTC) + timedelta(hours=1)).astimezone(berlin)
print(later.isoformat())             # 2026-03-29T03:30:00+02:00

strptime for formats fromisoformat will not take

Reach for strptime only when the input is not ISO 8601; it is slower and the format string is one more thing to get wrong. Leave %z out and you get a naive datetime from a string that clearly stated an offset, which is the quietest way to lose a timezone.

Example · python
from datetime import datetime, UTC

# A vendor sends RFC 1123-style timestamps. %z parses "+0000" and "+00:00".
# %a and %b read English names only under the C locale - a reason to prefer
# numeric formats on anything you control.
stamp = datetime.strptime(
    "Sun, 01 Mar 2026 09:30:00 +0000", "%a, %d %b %Y %H:%M:%S %z"
)
print(stamp.isoformat())           # 2026-03-01T09:30:00+00:00
print(stamp.tzinfo is not None)    # True - %z is what makes it aware

Boundary in, edge out

Two functions, and everything between them handles UTC only. Rejecting naive input at the boundary means no function downstream has to ask whether a datetime is trustworthy, and the only strftime in the codebase sits next to the template that needs it.

Example · python
from datetime import datetime, UTC
from zoneinfo import ZoneInfo

def created_at_utc(iso_text: str) -> datetime:
    """Boundary: the one place text becomes an aware UTC datetime."""
    value = datetime.fromisoformat(iso_text)
    if value.tzinfo is None:
        raise ValueError(f"created_at must carry an offset: {iso_text!r}")
    return value.astimezone(UTC)

def render(value: datetime, tz: ZoneInfo) -> str:
    """Edge: the only place a zone other than UTC appears."""
    return value.astimezone(tz).strftime("%d %b %Y, %H:%M")

row = created_at_utc("2026-03-01T10:30:00+01:00")
print(row.isoformat())                               # ...T09:30:00+00:00
print(render(row, ZoneInfo("America/New_York")))     # 01 Mar 2026, 04:30

Discussion

  • Be the first to comment on this lesson.