Skip to content
all notes
4 min read

What day is it?

DjangoPostgreSQLCorrectness

Almost every product eventually asks "did this happen today?" — a streak, a daily limit, a report grouped by date, a "you've already checked in" badge. It reads like a trivial question. It is not: a day is a property of a place, not of a timestamp.

Move the session around the clock and watch the two answers separate:

23:00

Truncated in Asia/Dubai

Wed

23:00 local

Truncated in UTC

Wed

19:00 UTC

Both agree at this hour. This is the window your tests run in, which is why the bug survives them.

Why it hides

The gap only opens near the edges of the day, and only for users whose offset is far from the server's. If you develop in the same timezone your database reports in, and you test at two in the afternoon, the two answers agree every single time. The bug is invisible in exactly the conditions you build under — and it lands on the users furthest from you.

It also gets worse in the direction you least want. A user logging a late-night session gets it filed under tomorrow; a user in a negative offset gets it filed under yesterday. Both look, from the inside, like the app losing their work.

The fix is a parameter, not an algorithm

Postgres will truncate in whatever timezone you hand it. The mistake isn't reaching for the wrong function, it's calling the right one without an argument and inheriting the server's zone by accident. In Django that's one keyword:

# wrong: "today" according to wherever the database happens to live
.annotate(day=TruncDate("started_at"))

# right: "today" according to the person who was there
.annotate(day=TruncDate("started_at", tzinfo=ZoneInfo(user_timezone)))

Which means the timezone has to be a real, stored property of the user, captured at signup and editable afterwards — not inferred from the browser on each request, and definitely not from an IP address. A user who travels does not want their streak history to rewrite itself every time they land somewhere.

Make the boundary explicit in tests

The same shape as any other invisible bug: you don't catch it by reading, you catch it by pinning the case that only fails at the edge. Freeze the clock somewhere awkward and assert the day:

@pytest.mark.parametrize("tz,expected", [
    ("Asia/Dubai",          date(2026, 7, 8)),   # 23:30 local, still the 8th
    ("America/Los_Angeles", date(2026, 7, 7)),   # same instant, still the 7th
])
def test_day_boundary_is_local(tz, expected):
    at = datetime(2026, 7, 8, 19, 30, tzinfo=timezone.utc)
    assert local_day(at, tz) == expected

Two rows, one instant, two correct answers. If someone later "simplifies" the query by dropping the tzinfo, one of them goes red immediately — which is the whole point of writing it down.