datetime.now() UTC Trap: The Session Timer That Drifts by Your Timezone2026-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"])]
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.
now - created_at_utc is 5 hours too large. A session created 30 seconds ago reports an age of 5h00m30s. Sessions die after 19 hours of real time.Trues.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.
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.
