Python's datetime.now() UTC Trap: The Session Timer That Drifts by Your Timezone

2026-06-04

This function decides whether a session is still alive. The database stores created_at as a naive datetime in UTC (a common legacy convention), and the TTL is 24 hours. Tests pass. Staging passes. Production, six months later, starts evicting users at unpredictable times.

from datetime import datetime, timedelta

SESSION_TTL = timedelta(hours=24)

def session_is_valid(created_at_utc: datetime) -> bool:
    """
    created_at_utc: naive datetime recorded as UTC in the DB.
    Returns True if the session is younger than SESSION_TTL.
    """
    now = datetime.now()
    return (now - created_at_utc) < SESSION_TTL

def cleanup_expired(sessions):
    return [s for s in sessions if session_is_valid(s["created_at"])]

The Bug

datetime.now() returns the local-timezone wall-clock as a naive datetime — no tzinfo attached. created_at_utc is also naive, but stored in UTC. Python has no way to know they live in different zones, so the subtraction proceeds as if both were the same clock. The resulting timedelta is silently offset by your machine's UTC offset.

The deeper problem is that Python's naive datetimes are silently wrong rather than loudly broken. There's no TypeError here to grab your attention; the arithmetic just happens with the wrong reference frame. The function's docstring claims UTC; datetime.now() ignores the claim.

A tempting "fix" is to swap in datetime.utcnow() — and it does eliminate the offset bug. But utcnow() is itself a trap: it returns a naive datetime that also claims to be UTC, refuses to compare against aware datetimes (TypeError: can't subtract offset-naive and offset-aware), and was deprecated in Python 3.12 precisely because so many bugs flow from it.

The Fix

Use timezone-aware datetimes end-to-end. Pass tz=timezone.utc to datetime.now(), and treat naive datetimes at I/O boundaries as a bug to fix at parse time, not propagate.

from datetime import datetime, timedelta, timezone

SESSION_TTL = timedelta(hours=24)

def session_is_valid(created_at: datetime) -> bool:
    """created_at: timezone-aware datetime."""
    if created_at.tzinfo is None:
        raise ValueError("created_at must be timezone-aware")
    now = datetime.now(timezone.utc)
    return (now - created_at) < SESSION_TTL

If the DB column really is naive-UTC and you can't migrate it, attach the zone at the boundary: created_at.replace(tzinfo=timezone.utc). Doing it in one place is far safer than hoping every caller remembers to use utcnow() instead of now() — a rule that looks like it's holding right up until a tired engineer types five fewer characters.

Key Takeaway: Naive datetimes don't carry their timezone, so arithmetic between them silently uses whatever frame each side happened to be in — always work with timezone-aware datetimes and reject naive ones at the boundary.

All newsletters