Your Timestamps Are Wrong — The UTC Mistake Almost Every App Makes
A customer in Tokyo files a support ticket at 9:15 PM local time on the 14th. Your database logs it with a timestamp that reads 2026-01-14 12:15:00. Someone on your team, working in New York, pulls up that row and sees "1/14, 12:15" and reasonably reads that as 12:15 PM Eastern — except it isn't. It's UTC, unlabeled, and now two people are looking at the same event and disagreeing about what day it happened on. Nobody made an arithmetic mistake. The timestamp itself just didn't say what it was.
This is the single most common source of "the numbers don't match" bugs in applications with any kind of international, distributed, or even just multi-machine footprint — and it's almost always caused by the same root issue: storing or displaying a timestamp without being explicit about which timezone it's in.
A timestamp without a timezone isn't neutral — it's ambiguous
2026-01-14 12:15:00 looks like a fact. It isn't one. Without a timezone attached, that string could mean 12:15 in UTC, in Tokyo, in New York, or in whatever timezone the server, the database connection, or the application happened to be configured for at the moment it was written. Every one of those readings is a different actual instant in time, potentially hours apart. The column doesn't disambiguate — it just stores digits, and trusts every reader to already know what timezone those digits were written in.
This is why TIMESTAMP WITHOUT TIME ZONE (the default TIMESTAMP type in Postgres, and effectively how MySQL's DATETIME behaves) is a trap for anything that will ever be read by more than one timezone-aware context — which, in practice, is almost everything, because "the server's timezone setting" and "the reader's assumption" only need to disagree once for the bug to appear.
Where this actually breaks things
Comparing timestamps written from different sources. An event logged by a server in UTC and a event logged by a client library in local time will sort incorrectly relative to each other if both land in a timezone-naive column — the database has no way to know one needs an offset applied and the other doesn't. They just look like two plain numbers, compared as if they're already on the same footing.
Date-boundary logic. "How many orders came in today?" seems simple until "today" depends on whose today — a WHERE created_at::date = CURRENT_DATE comparison run in a timezone-naive setup silently uses whatever timezone the database connection happens to be in, which may not match the user asking the question, or the server that wrote the row, or both.
Daylight saving transitions. Twice a year, one hour either repeats or doesn't exist in local time, depending on the region. Any timestamp stored in local time instead of UTC inherits that ambiguity permanently — "2:30 AM" on the November transition date genuinely happened twice in some timezones that year, and a naive local timestamp can't tell you which occurrence it was.
The fix: store everything in UTC, convert only at display time
The rule that eliminates nearly all of this: the database stores UTC, always, with the timezone explicit in the column type — and conversion to a human's local time happens only in the display layer, never in storage.
-- Postgres: TIMESTAMPTZ actually stores the instant unambiguously
CREATE TABLE events (
id SERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
TIMESTAMPTZ in Postgres doesn't store a timezone per row the way the name might suggest — it normalizes whatever you insert into UTC internally, and converts back to whichever timezone the reading session is configured for on the way out. The point isn't that the column remembers "Tokyo" — it's that storage and comparison always happen on one unambiguous timeline (UTC), and "what timezone should this look like to the person reading it" becomes purely a presentation concern, decided fresh at read time rather than baked into storage.
MySQL's TIMESTAMP type (distinct from DATETIME) does something similar — it stores UTC internally and converts based on the session's time_zone setting. DATETIME, by contrast, stores exactly what you give it with no conversion at all, which is precisely the naive, ambiguous behavior worth avoiding for anything timezone-sensitive.
What this looks like in practice
Write UTC, always, at the point of insertion — most languages have a straightforward way to get the current UTC instant regardless of the machine's local settings, and that's what should hit the database, not the server's local clock. Store it in a timezone-aware column type where the database supports one. Do every comparison, sort, and date-boundary calculation in UTC, in the query. Convert to a specific human's local time only in the last step, in the application or presentation layer, using that specific person's actual timezone — not the server's, not an assumed default.
The moment "what timezone is this in" needs to be answered by inference rather than by reading the column type, that's the moment the bug is already possible. Making it unambiguous in the schema is what removes the failure mode instead of just making it rarer.
Working with real datetime columns and seeing exactly how they behave is part of SQL Quest's date-functions lesson — hands-on, live database, free.