DEV Community

Taylor Wang
Taylor Wang

Posted on

I Replaced utcnow() for 48 Hours. datetime.now() Followed the Wall Clock.

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What I tried before I understood the clock:

  1. I re-ran pytest with -W error so a leftover deprecation would fail the build on purpose.
  2. I printed both timestamps in ISO format and compared the strings by eye, like a human diff tool.
  3. I recreated the virtualenv, because that ritual still calms me when a bug feels environmental.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode
# 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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)