The Timezone-Naive Datetime That Sorted Backwards
The report listed tomorrow's events before today's, and the comparison never raised an error, only returned the wrong order, because aware and naive…
The report's first row was an event scheduled for tomorrow and its last was one that happened this morning, and the ordering was not random, it was confidently wrong, which is the signature of a comparison that ran without complaint on values that should not have been comparable. The column mixed timezone aware datetimes, carrying their offset, with naive ones, carrying none, and the query sorted them together, and the mix produced an order that was neither UTC nor local, just wrong, because the naive values were interpreted in whichever zone the database assumed, and the assumption was the bug.
This is the bug hunt for the naive datetime, and for the specific cruelty that the failure mode is not an exception but a silent, plausible ordering, which is why it shipped, and why the report's readers, trusting the sort, made decisions on tomorrow coming first.
This was Python 3.13.2 writing to Postgres 16.3, and the mix entered through one writer that stored naive values while the rest stored aware, which is the standard way the mix happens, one legacy path among modern ones.
The symptom, precisely
Events created by the new service, aware and UTC, sorted correctly among themselves, and events created by the legacy importer, naive, sorted correctly among themselves, but the combined ordering interleaved them wrongly, with naive future events leading, because the database, storing the naive values as timestamps without zone, compared them to the aware values after converting the aware ones to the session zone, and the session zone was behind UTC, so the aware events shifted backwards by the offset, and the naive ones, unshifted, leapt ahead. The ordering was therefore a function of the session zone, which is the detail that makes the bug reproduce differently per reader, which is the confusion that doubled the investigation.
The hypotheses that died
The sort key was wrong
First I assumed the ORDER BY used the wrong column or direction. The query was correct for homogeneous data, and reproduced correctly when filtered to one writer. It died because the ordering was right per population and wrong combined, which is a data property, not a query property.
The clocks differed
Next I suspected clock skew between writers. The naive values were not offset by skew, they were offset by exactly the session zone's UTC offset, a clean number, which skew never is. It died because the offset was a timezone, not a clock.
The display layer reformatted
Then I assumed the report reformatted on render. The raw query result, fetched directly, was already misordered, so the order arrived broken from the database. It died because the sort happened where the types met.
The breakthrough
The diagnosis is that a naive datetime is not a time, it is a time with the zone missing, and every comparison involving it forces the system to invent the missing zone, and the invention differs per layer, the database session, the application, the driver, so the same naive value compares differently in different contexts, which is why the order changed per reader, and why no single layer was wrong, each invented a zone, reasonably, and the inventions disagreed.
This is the calendar cousin of the cron that skipped every month with thirty one days, and the shared property is the one worth fixing.
The fix, in order
The column gets one type, aware. The schema stores timestamps with time zone, and the writers convert to UTC before writing, so the stored value carries its zone, and the comparison is between two complete values, which is the only comparison that is a fact. The migration backfilled the naive rows by assigning them the zone the legacy importer intended, which is a decision, recorded, not a guess, because the backfill's zone choice is the one place the missing information must be invented deliberately.
The reader rejects naive at the boundary. The application parses datetimes with a parser that requires the offset, and a naive input raises at the boundary, per the boundary discipline from validate at the boundary and nowhere else, so the missing zone becomes a loud error at ingest instead of a silent ordering downstream, which is the conversion of the silent failure into the loud one, at the only place it can be fixed cheaply.
The sort is tested across populations. The regression test writes an aware event and a naive shaped event from the legacy path and asserts the combined order equals the true chronological order, which fails on the original code, because the original order is zone dependent, and the test pins the session zone to two values and asserts the order is identical under both, which is the per reader invariance made executable.
What I would do differently
It ended at the earliest and cheapest place to refuse it. It started at the column that accepts incomplete values. Every hand-off in between did exactly what it was told, and this is the one that lied.
I would also have named the session zone in every report query, explicitly, so the reader's zone is a parameter, not an ambient, because the ambient zone is the hidden input that made the order differ per reader, and hidden inputs are the bug class, in datetimes as in caches.
The takeaway
A naive datetime is a time with the zone missing, and every comparison forces the system to invent the zone, differently per layer, so a mixed column sorts by the inventions, which disagree, producing an order that is confidently wrong and reader dependent. Store aware values only, reject naive at the boundary, backfill with a recorded decision, and test the order across zones.
The report that listed tomorrow first was not lying about the data. It was sorting by the zones the layers invented, and the inventions, each reasonable, composed into nonsense, which is the oldest failure in this archive, missing information filled by assumption, wearing a timestamp.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud