I did not plan to spend forty-eight hours arguing with token expiry, but the timestamps refused to stay still. The suite looked calm on my laptop after an agent stripped a deprecation warning from datetime.utcnow(). Then the same expiry checks failed in a clean process, and I blamed caches, Docker, and even NTP. Have you ever trusted a green bar just because the warning log finally went quiet?
This is a field note about what I tried, what broke, and what I would repeat without hesitation. I kept naive UTC in helpers while my laptop zone already matched UTC, which is a vicious coincidence. The replacement compiled, tests passed, and the meaning of now still changed under my feet. If you already refuse naive datetimes, steal the decision table and skip my detours.
Hour 0–8: I chased the warning, not the clock
The first symptom was boring, which is how my worst debugging weeks usually begin, right? Python 3.12 marked datetime.utcnow() as deprecated, and the suggested fix was a tiny swap that silenced DeprecationWarning. I accepted datetime.now() because the names look related, and every assertion stayed green. Why would I distrust a diff that made the log look professional?
Here is the helper I should have frozen until a timezone test existed. This is a lab reconstruction, not a claim about some private production stack I cannot show you.
# token_clock.py — original naive UTC (utcnow is deprecated since 3.12)
from datetime import datetime, timedelta
def issue_token(lifetime_minutes: int = 15) -> dict:
now = datetime.utcnow()
return {
"iat": now.isoformat(timespec="seconds"),
"exp": (now + timedelta(minutes=lifetime_minutes)).isoformat(timespec="seconds"),
}
def is_expired(token: dict, now: datetime | None = None) -> bool:
current = now or datetime.utcnow()
return current.isoformat(timespec="seconds") >= token["exp"]
I ran a handful of commands and felt strangely productive until lunch.
python -W default -c "from datetime import datetime; datetime.utcnow()"
pytest -q tests/test_token_clock.py
git diff --stat
What I tried before I understood the clock:
- I re-ran pytest with
-W errorso a leftover deprecation would fail the build on purpose. - I printed both timestamps in ISO format and compared the strings by eye, like a human diff tool.
- I recreated the virtualenv, because that ritual still calms me when a bug feels environmental.
- I asked whether a missing offset could still mean UTC if the comment above the helper said UTC.
What broke in this window was my threat model, not the assertions I already had. The laptop zone was UTC, so now() and utcnow() returned the same naive civil time. The agent did not need to hallucinate a function name; it optimized for the warning and for my machine. Would you have noticed that agreement, or would you have shipped the quieter log?
Hour 8–24: a clean run disagreed, and I blamed the wrong layer
I wanted a second process that did not inherit my pytest plugins, my exported variables, or my superstitions. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to review the deprecation diff, then used the free server option as a clean pytest host. I will not invent model names, quotas, or hardware details for that box, because I did not measure them. I needed another interpreter, not a brochure.
The clean run was rude in a useful way. Tokens minted a moment ago looked already stale, or they looked immortal, depending on the offset I had not printed yet. I still did not look at TZ. I looked at ISO parsing, at whether isoformat dropped seconds, and at whether pytest cached bytecode. Do you see how a person can stay extremely busy without getting closer to the clock?
python -c "import sys, platform; print(sys.version); print(platform.platform())"
python -c "from datetime import datetime; print(repr(datetime.now())); print(repr(datetime.utcnow()))"
date
pytest -vv tests/test_token_clock.py
The model, trying to be helpful, proposed three more patches that would have buried the invariant:
- Catch the comparison and treat any parse error as
not expired, which turns a TypeError into a silent extension. - Store epoch seconds as strings, then rebuild them with
datetime.fromtimestamp, which reads naive local time again. - Sleep two seconds in the test so expiry would feel "more realistic," which only hides mixed clocks.
I rejected the sleep, at least, because I have been burned by sleep(1) making a race look civilized. fromtimestamp on a naive mental model is another trap, because naive fromtimestamp is local time. The comparison kept changing shape, and every shape still passed on my UTC laptop. Is a test that cannot fail in your zone still a test?
Hour 24–48: I printed TZ, then I printed tzinfo
The breakthrough was ugly, which I appreciate more in hindsight than I did at hour thirty. I printed time.tzname, the current UTC offset, and tzinfo for now(), utcnow(), and now(timezone.utc) on the same line. One value was naive local time. Another was naive UTC pretending to be interchangeable with local time. Both were naive, so Python compared them like ordinary civil times and did not raise. That missing TypeError is the entire plot.
# probe_tzinfo.py — run this before you accept another deprecation diff
import os
import time
from datetime import datetime, timezone
print("TZ env", os.environ.get("TZ"))
print("tzname", time.tzname)
print("now", datetime.now().isoformat(), "tzinfo", datetime.now().tzinfo)
print("utcnow", datetime.utcnow().isoformat(), "tzinfo", datetime.utcnow().tzinfo)
print("aware", datetime.now(timezone.utc).isoformat(), "tzinfo", datetime.now(timezone.utc).tzinfo)
print("utcoffset", datetime.now().astimezone().utcoffset())
Then I forced the disagreement on my own machine, which is something you can do without borrowing anyone's hardware story.
TZ=UTC python probe_tzinfo.py
TZ=America/Los_Angeles python probe_tzinfo.py
TZ=America/New_York python probe_clock.py
TZ=UTC python probe_clock.py
TZ=Asia/Tokyo python probe_clock.py
Under America/Los_Angeles during Pacific Daylight Time, local now() sits several hours behind UTC. A token minted with datetime.now() and checked with datetime.utcnow() can look expired immediately, even with a fifteen-minute lifetime. Under Asia/Tokyo, the same mix can make a token look immortal for hours, because local civil time is ahead of naive UTC. Mixing the two functions is worse than picking either one forever. Have you noticed how confident a timestamp looks when it has a T in the middle and no offset at the end?
The lab: make the failure show up on purpose
Label this as a lab you can execute, not as telemetry from a fleet I cannot prove. Save the files and run the commands with two TZ values. If both commands print expired_immediately False, your processes may both be UTC, and you have not actually tested the bug.
# probe_clock.py
from datetime import datetime, timedelta
import os
import time
def issue_with_now(lifetime_minutes: int = 15):
minted = datetime.now() # naive wall clock
return minted, minted + timedelta(minutes=lifetime_minutes)
def check_with_utcnow(exp: datetime) -> bool:
return datetime.utcnow() >= exp # naive UTC
def main() -> None:
minted, exp = issue_with_now(15)
expired = check_with_utcnow(exp)
print("TZ", os.environ.get("TZ"))
print("tzname", time.tzname)
print("minted", minted.isoformat(), "tzinfo", minted.tzinfo)
print("utcnow", datetime.utcnow().isoformat())
print("exp", exp.isoformat())
print("expired_immediately", expired)
print("utcoffset", datetime.now().astimezone().utcoffset())
if __name__ == "__main__":
main()
The repair is not "call now() everywhere and hope CI runs in UTC." The repair is an aware UTC clock at the edges, plus a refusal to compare naive values. Numeric exp as UTC epoch seconds is even harder to misread in logs, which is why JWT style payloads prefer that shape.
# token_clock.py — aware UTC, refuse naive
from datetime import datetime, timedelta, timezone
UTC = timezone.utc
def issue_token(lifetime_minutes: int = 15) -> dict:
now = datetime.now(UTC)
return {
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=lifetime_minutes)).timestamp()),
}
def is_expired(token: dict, now: datetime | None = None) -> bool:
current = now or datetime.now(UTC)
if current.tzinfo is None:
raise TypeError("refusing naive now; pass an aware UTC datetime")
return int(current.timestamp()) >= int(token["exp"])
# tests/test_token_clock.py
from datetime import datetime, timedelta, timezone
import token_clock as clock
UTC = timezone.utc
def test_fresh_token_is_not_expired():
token = clock.issue_token(lifetime_minutes=15)
assert clock.is_expired(token, now=datetime.now(UTC)) is False
def test_token_expires_after_lifetime():
token = clock.issue_token(lifetime_minutes=15)
later = datetime.now(UTC) + timedelta(minutes=16)
assert clock.is_expired(token, now=later) is True
def test_naive_now_is_rejected():
token = clock.issue_token(lifetime_minutes=15)
naive = datetime(2026, 9, 12, 12, 0, 0)
try:
clock.is_expired(token, now=naive)
except TypeError:
return
raise AssertionError("naive now should have been refused")
Run the matrix before you celebrate the missing warning.
TZ=UTC pytest -q tests/test_token_clock.py
TZ=America/Los_Angeles pytest -q tests/test_token_clock.py
TZ=Asia/Tokyo pytest -q tests/test_token_clock.py
TZ=America/Los_Angeles python probe_clock.py
TZ=UTC python probe_clock.py
Decision table I wish I had on hour one
| Call | Clock it actually reads | tzinfo |
Dangerous partner |
|---|---|---|---|
datetime.utcnow() |
UTC civil time | naive |
datetime.now(), fromtimestamp()
|
datetime.now() |
local wall clock | naive |
utcnow(), any ISO string without an offset |
datetime.now(timezone.utc) |
UTC | aware | naive datetimes, which should TypeError
|
datetime.fromtimestamp(ts) |
local wall clock | naive | epoch values you believed were UTC |
datetime.fromtimestamp(ts, tz=timezone.utc) |
UTC | aware | none, if you keep the rest aware |
time.time() as int
|
UTC epoch seconds | n/a | pretty ISO logs with no offset |
A few rules I would tape above the keyboard:
- If two timestamps lack
tzinfo, Python will compare them, even when they came from different clocks. - If one timestamp is aware and the other is naive, you want that
TypeError; do not catch it. - If your tests only run where
TZ=UTC, they cannot see this class of bug at all. - If an agent "fixes" a deprecation by deleting three letters, you still owe the suite a second timezone.
What I would repeat, and what I would not
What I would repeat on the next deprecation diff is embarrassingly small. I would print tzinfo and utcoffset before I print the traceback. I would keep a TZ= matrix next to pytest, even when the laptop already lives in UTC. I would store expiry as UTC epoch seconds and use aware datetimes only at the edges. I would treat a quieter warning log as a clue, not as proof.
What I would not repeat is trusting a one-line swap because the names share a suffix. I would not let a model add sleep, a broad except, or fromtimestamp without a timezone. I would not compare ISO strings that have no offset and then act surprised when two continents disagree. Would I still use an agent to hunt deprecations? Yes, but I would make it run probe_clock.py under two TZ values before I merge.
Limitations, and who should not bother
This workflow will not help you if every datetime in the codebase is already aware and every payload already uses UTC epoch seconds. It will not replace NTP, monotonic clocks, or a real time daemon when hosts drift. It also will not tell you what zone any vendor server uses, because I am not going to invent that as a product fact. A free server is useful here only as a second process, and only if you print the zone yourself.
Skip this lab if you are debugging a UI date picker, a calendar recurrence rule, or civil-time business hours. Those problems need a timezone database and explicit local zones, not a blanket timezone.utc. Skip it if your language runtime already refuses naive comparisons and your tests already pin TZ. And skip any patch that "fixes" utcnow() by calling now() with no argument, even when the suite is green on your laptop.
Steal the probe, pin TZ in CI, and refuse naive clocks before you celebrate a quieter warning log.
Top comments (0)