timedelta.seconds Trap: The Session Timer That Wraps at Midnight2026-07-07
This function is supposed to compute how long a user's session lasted, in seconds, so we can alert on unusually long ones. Tests pass. It ships. Weeks later, a security engineer notices that sessions lasting days are being reported as lasting minutes — and the "alert on sessions > 24 hours" rule has never fired, not once.
from datetime import datetime, timedelta
def session_duration(start: datetime, end: datetime) -> int:
"""Return the length of a session in seconds."""
return (end - start).seconds
def check_session(user_id: str, login: datetime, now: datetime) -> None:
duration = session_duration(login, now)
print(f"user={user_id} duration={duration}s")
if duration > 86_400: # more than one day
alert_long_session(user_id, duration)
# Looks fine in tests:
login = datetime(2026, 7, 1, 9, 0, 0)
now = datetime(2026, 7, 1, 9, 30, 0)
check_session("u1", login, now) # duration=1800s ✅
# In production, days later:
login = datetime(2026, 7, 1, 9, 0, 0)
now = datetime(2026, 7, 4, 10, 0, 0) # ~73 hours later
check_session("u1", login, now) # duration=3600s 😱 alert never fires
A timedelta stores its value as three separate integer components: days, seconds, and microseconds. The names are misleading. timedelta.seconds is not the total number of seconds in the delta — it's the seconds component, normalized to the range [0, 86400). Anything larger than a day rolls into the days component.
So for a 73-hour session:
(end - start).days is 3(end - start).seconds is 3600 (the remaining 1 hour)Tests pass because they use deltas under 24 hours, where the days component is zero and .seconds happens to equal the truth. The bug only surfaces in production, exactly at the threshold you care about: long sessions. Worse, the "alert if > 86400" check is now structurally impossible to trigger — .seconds can never reach 86400 by definition. The alarm is wired to a signal that will never fire.
This is a language-level footgun: datetime.timedelta chose to expose its normalized storage fields as public attributes with the same names as human units. The type system can't help you, either — both wrong and right return an int/float.
Use .total_seconds(), which flattens all three components into a single float:
def session_duration(start: datetime, end: datetime) -> float:
"""Return the length of a session in seconds."""
return (end - start).total_seconds()
Two hardening habits worth adopting: (1) whenever you touch timedelta, ask yourself whether you want the component or the total — the attribute names lie about which is which; (2) add at least one test that spans a day boundary. A single case with end - start > timedelta(days=1) catches every variant of this bug instantly.
The same shape of trap lurks in timedelta.microseconds (0 to 999999, not "total microseconds") and, in a distant cousin, in time.struct_time.tm_yday-style APIs where a "day of year" is not the same as "days since epoch". When a library exposes normalized components under human names, you have to know which is which by heart — the attribute won't warn you.
timedelta.seconds is the seconds component capped at 86400, not the total duration — always reach for .total_seconds() when you want a real elapsed time.
