DEV Community

Taylor Wang
Taylor Wang

Posted on

I Reproduced a Date Cutoff on a Clean Server. datetime.now() Had No tzinfo.

The local pytest run was green before lunch, and I almost shipped the cutoff check. Have you ever trusted datetime.now() because every clock on your desk agreed with you? I had a daily window that closed at midnight, plus a helper that compared now to a stored date. The helper looked boring on first read, which is exactly how these bugs like to dress.

I kept a 48-hour field notebook this week instead of another vague works-on-my-machine thread. What follows is that notebook: what I tried, what broke, and what I would repeat. None of these snippets ran in production traffic; they are a labeled lab reproduction you can copy.

Hour 0: the local suite looked honest

I started with a tiny helper and a test that used a real calendar date. That choice felt pragmatic until the environment changed under my feet. Why would I freeze time for a check that only cares about today?

# lab_repro/cutoff.py — labeled lab example
from datetime import datetime, date

def still_open(deadline: date) -> bool:
    """Return True while local calendar date is on or before deadline."""
    return datetime.now().date() <= deadline
Enter fullscreen mode Exit fullscreen mode
# tests/test_cutoff.py — labeled lab example
from datetime import date
from lab_repro.cutoff import still_open

def test_deadline_is_today_still_open():
    assert still_open(date.today()) is True
Enter fullscreen mode Exit fullscreen mode

That test is tautological on a single machine, and I should have seen it immediately. It compares date.today() to datetime.now().date() inside the same process. Did I notice that both sides were reading the same clock? Not at hour zero, which still bothers me.

Hours 1–8: I blamed the runner, then the prompt

I reran pytest with -vv, then with --lf, then from a login shell I had not customized. I printed date.today() inside a fixture and stared at it like it owed me rent. The printed value never moved in a way that explained a remote failure I cared about. Have you done that same dance with a green local suite and a red remote log?

I also asked a coding model to fix the flake without pasting any timezone at all. Guess what it suggested first, before it even asked me about clocks or offsets? Extra sleeps, a retry loop, and a looser assertion around midnight. That is not really a model failure so much as a prompt failure on my side.

If you hide the environment, the model will invent ceremony around the symptom. What actually broke the story was simpler than any retry. My laptop was not in UTC, and the failing box was. A cutoff stored as a naive calendar date is not a timestamp at all. It is a political opinion about where midnight lives.

Hours 8–16: I copied the repro onto a clean server

I needed a machine that did not inherit my laptop's TZ variable. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free server option as a throwaway Unix box for the TZ split. I also used its free model access to review the diff after I had a failing command.

I am not claiming a particular model name, quota, or piece of hardware, because I did not measure those. I wanted a shell, a CPython, and a timezone I could export. You can do the same work with any clean container; the server was convenience, not the lesson.

On that box I installed the lab package and then forced the zone from the shell:

# labeled lab commands, run from the repo root
python -m venv .venv
. .venv/bin/activate
pip install -e ".[test]"

TZ=UTC python -c "import time; print(time.tzname)"
TZ=UTC pytest -q
TZ=America/Los_Angeles pytest -q
Enter fullscreen mode Exit fullscreen mode

The interesting part is not that one command fails forever in every climate. The interesting part is a deadline that is today in California and tomorrow in UTC. Can a green local run survive that split? Only if you never leave your offset, which I eventually did.

The smallest example that actually fails

date.today() and datetime.now() both consult the process timezone, which feels obvious after the fact. A stored date from an API often does not consult anything. This is the mismatch I should have written into the first test.

# lab_repro/cutoff.py — labeled lab example
from datetime import datetime, date, timezone

def still_open(deadline: date, *, now: datetime | None = None) -> bool:
    """Compare deadline to an aware UTC clock, not to the host's local clock."""
    current = now or datetime.now(timezone.utc)
    if current.tzinfo is None:
        raise ValueError("refusing to compare a naive datetime to a calendar date")
    return current.date() <= deadline
Enter fullscreen mode Exit fullscreen mode

Wait, is current.date() still wrong if the product promised a Pacific calendar day? Yes, and that is the entire point of this notebook. date() on an aware UTC value is a UTC calendar date, not a civil date in Los Angeles. If the copy says Pacific, convert with ZoneInfo and test that boundary. Silent local time is how I fooled myself for a day.

Here is a test file that does not lie when the host zone changes around it. I inject now so pytest does not depend on the wall clock.

# tests/test_cutoff.py — labeled lab example
from datetime import date, datetime, timezone
import pytest
from lab_repro.cutoff import still_open

def test_refuses_naive_now():
    naive = datetime(2026, 9, 9, 23, 30, 00)
    with pytest.raises(ValueError, match="naive"):
        still_open(date(2026, 9, 9), now=naive)

def test_utc_calendar_day_before_deadline():
    now = datetime(2026, 9, 9, 7, 0, tzinfo=timezone.utc)
    assert still_open(date(2026, 9, 9), now=now) is True

def test_utc_calendar_day_after_deadline():
    now = datetime(2026, 9, 10, 0, 1, tzinfo=timezone.utc)
    assert still_open(date(2026, 9, 9), now=now) is False

@pytest.mark.parametrize("tz", ["UTC", "America/Los_Angeles", "Asia/Shanghai"])
def test_injected_now_does_not_follow_process_tz(tz, monkeypatch):
    monkeypatch.setenv("TZ", tz)
    now = datetime(2026, 9, 9, 23, 30, tzinfo=timezone.utc)
    assert still_open(date(2026, 9, 9), now=now) is True
Enter fullscreen mode Exit fullscreen mode

Notice the calendar numbers are fixed in the test names on purpose. I am writing this on 2026-09-09, but the assertions do not call date.today(). If they did, this article would rot tomorrow morning. That is the original bug, wearing a documentation hat.

Why the parametrized TZ test still needs an injected now

Setting TZ in pytest does not always rewrite clocks that were already imported. On Unix you may also need time.tzset(), and Windows will not follow that ritual. Injecting now keeps the assertion honest without pretending every platform honors the same export. Would I still set TZ in CI after that? Yes, as a smoke check, not as the source of truth.

Commands that made the flake deterministic

I stopped asking whether the suite was flaky, and I started asking which timezone made it fail. That question fits in a shell, and it does not need a dashboard. I would rather keep four boring commands than another screenshot of a green local run.

  1. Print three clocks in one process so the offset cannot hide.
  2. Run pytest under TZ=UTC and under TZ=America/Los_Angeles without editing files.
  3. Fail the build if a production helper accepts a naive datetime value.
  4. Grep for datetime.now() and date.today() before blaming the runner or the model.
python -c "from datetime import datetime, date, timezone; print(date.today(), datetime.now(), datetime.now(timezone.utc))"
rg "datetime\.now\(|date\.today\(|datetime\.utcnow\(" -n lab_repro tests
TZ=UTC pytest -q
TZ=America/Los_Angeles pytest -q
Enter fullscreen mode Exit fullscreen mode

On Linux, TZ=... is enough for CPython's naive local clock in a fresh process. On Windows the same environment variable is not a complete story, which is one reason a Unix-like clean server helped. I am not calling that a benchmark; it is just a different default offset I could see.

The decision table I wish I had at hour 2

Clock expression Follows process TZ? Safe for a UTC deadline? What I do now
datetime.now() Yes No Ban in cutoff code
date.today() Yes No Ban in cutoff code
datetime.utcnow() Naive UTC, no tzinfo Misleading Avoid; deprecated since 3.12
datetime.now(timezone.utc) No Yes, as a UTC day Default for UTC rules
Injected aware now in tests N/A Yes Required in this lab

datetime.utcnow() is naive on purpose, which is why it still surprises careful people. CPython documented the deprecation in 3.12 and pointed readers at datetime.now(timezone.utc). I will not pretend a naive UTC value is basically aware. It is a footgun with a nostalgic name, and my grep now treats it as a hit.

What broke, in plain language

  • The unit test compared two local clocks in the same process, so it could not see a timezone bug.
  • The helper stored a date and read a naive datetime, then shrugged about midnight.
  • Model suggestions without the TZ value optimized retries instead of offsets.
  • My laptop offset hid the failure until I ran the same files on a UTC box.

Would I have found this faster with more logs around the helper? Maybe, if those logs included an offset. Would naive timestamps have helped anyway? Probably not, because a log line without a zone is another local rumor.

What I would repeat

I would open the model chat only after I had two commands that disagreed in public. The transcript then had something concrete to review, namely TZ=UTC pytest versus TZ=America/Los_Angeles pytest. That is a better prompt than tests are flaky, please fix, which I will not send again.

I would keep a clean server, or a container, as a timezone I do not live in every day. If your house is already UTC, force America/Los_Angeles instead of feeling virtuous. The method is the disagreement between two offsets, not the brand of the shell.

I would inject now in every test that cares about a deadline or a cutoff window. Wall-clock tests are documentation that expires at midnight, and I am tired of rereading them.

Limitations, and who should skip this

This notebook is not a scheduling product, and it is not legal advice about end of day. Business deadlines often mean a specific civil timezone, not UTC and not the server's /etc/timezone. If you need 5pm Pacific, convert with ZoneInfo("America/Los_Angeles") and test the boundary in that zone.

Do not use this approach if your code already standardizes on aware datetimes and a frozen clock fixture. You already have the medicine, and another server will only slow you down. Do not treat a throwaway box as your only CI, and do not paste secrets into a model prompt while you debug dates.

Windows developers should treat TZ= as incomplete documentation, not as a portability layer. Use an injected now, and skip time.tzset() except where the platform actually documents it. The lab above assumes a Unix-like shell and a fresh interpreter.

I also would not use naive-versus-aware hunting as an excuse to ignore real race conditions around midnight jobs. Those still exist when every datetime is aware, and they need locks or idempotent keys. The timezone bug was sufficient for my 48-hour notes, but it is not the only way a cutoff can lie.

Top comments (0)