Time Zones in Databases: Why Your Timestamps Are Lying
A timestamp column that looks fine in development can quietly shift by hours in production, merge two events into one during a DST change, or put a meeting at the wrong time after a government changes its clocks. Here's what your database actually stores, and how to choose between instants, wall times, and zones.
The Session That Lasted Zero Minutes
A user in New York logs in at 1:30 AM and logs out an hour later. Your analytics dashboard says the session lasted zero minutes. Another report shows the logout happening before the login for a handful of users. Nobody touched the code.
The date was the first Sunday in November, the night US clocks fall back. At 2:00 AM Eastern, the clock goes back to 1:00 AM, so 1:30 AM happens twice. Your created_at column stored "2026-11-01 01:30:00" for both events, with nothing to say which 1:30 it meant.
This is the most common way timestamps lie: the database stored exactly what you gave it, and what you gave it was ambiguous. Most time bugs come from mixing up three different things that all look like "a time."
Three Kinds of Time
Before choosing a column type, decide which of these you're storing.
A single point on the global timeline, the same everywhere on Earth. "The payment was captured," "the user logged in," "the job finished." An instant has no time zone; time zones only matter when you display it. UTC is the usual way to write one down.
What a clock on the wall says, like "9:00 AM on March 15." It isn't a point on the timeline until you add a place. 9:00 AM in Tokyo and 9:00 AM in London are different instants. Meeting times, store opening hours, and alarm clocks are wall times.
A named set of rules for turning wall time into instants, like America/New_York or Asia/Karachi, maintained in the IANA tz database. A zone is not an offset. -05:00 is New York's offset in winter; in summer it's -04:00. The zone is what knows when that changes.
Most events your backend records are instants. Most things users schedule are wall times in a zone. The bugs come from storing one as if it were the other.
What Your Database Actually Stores
Column type names make this harder than it needs to be, because they're misleading in both major databases.
| Type | What's stored | What happens on read |
|---|---|---|
| PostgreSQL timestamp (without time zone) | A wall time. Any offset in the input is silently discarded. | Returned as-is. The reader has to know what zone it was meant to be in. |
| PostgreSQL timestamptz (with time zone) | An instant, as UTC. The zone is used for conversion and then thrown away. | Converted to the session's TimeZone setting for display. |
| MySQL DATETIME | A wall time, no zone. | Returned as-is. |
| MySQL TIMESTAMP | An instant: converted from the session time_zone to UTC on write. | Converted back to the session time_zone. Range ends at 2038-01-19. |
| SQL Server datetimeoffset | A wall time plus the offset at that moment. | Returned with its offset. Still no zone rules. |
The trap in that table is the name timestamp with time zone. It doesn't store a time zone. It stores an instant, and uses the zone only to figure out which instant you meant. That's usually what you want, but if you need to know later that the user was in Karachi, you have to store that separately.
โ ๏ธ The silent offset drop
In PostgreSQL, inserting '2026-11-01 01:30:00-04' into a plain timestamp column stores 2026-11-01 01:30:00. The -04 is ignored without an error or warning. If you've ever seen data shift by exactly your UTC offset after a migration, this is a likely cause.
Watch the Bytes Change Meaning
The simulator writes two events, a login and a logout one hour apart, during the New York fall-back hour. Pick a column type, the time zone of the session that writes, and the zone of the session that reads.
| Event | Really happened (UTC) | Stored bytes mean | Reader sees | Error |
|---|---|---|---|---|
| Login | 2026-11-01 05:30 | 2026-11-01 01:30 | 2026-11-01 01:30+00 | -4h |
| Logout | 2026-11-01 06:30 | 2026-11-01 01:30 | 2026-11-01 01:30+00 | -5h |
Both events happen on 1 Nov 2026, the night New York falls back from EDT to EST. A timestamp column keeps only the wall-clock reading the writer's session produced; the reader has to guess which zone it was in. A timestamptz column converts to UTC on the way in, so every reader gets the same instant, rendered in their own zone.
Start with the defaults: a timestamp column, a New York writer, a UTC reader. Both rows are off by four or five hours, and the session length is wrong. Then try a few combinations:
- Writer and reader both in New York. The offsets look right now, but the session is still zero minutes long. Both events were stored as
01:30, and that information can't be recovered. - Writer and reader both in Karachi or both in UTC. Everything is correct, because neither zone has DST. This is why the bug never shows up on your laptop or your UTC staging server.
- Switch to
timestamptz. Every combination is exact. The stored value is UTC, and each reader sees the same instant in their own zone.
A naive timestamp column is only correct if every writer and every reader agrees on the zone, forever. That includes app servers, cron hosts, analytics tools, and the engineer running a query from their laptop. A timestamptz column doesn't need that agreement.
Rule 1: Store Instants as Instants
For anything that already happened, like created_at, paid_at, or last_login_at, use an instant type: timestamptz in PostgreSQL, TIMESTAMP in MySQL if the 2038 limit is acceptable (otherwise DATETIME with a strict convention that it always holds UTC), and datetimeoffset or UTC datetime2 in SQL Server.
Then make UTC the default everywhere it can leak in:
Database server and session time zone
Set the database's default time zone to UTC. Session settings decide how timestamptz is displayed, and in MySQL they decide how TIMESTAMP is converted.
Application servers and containers
Set TZ=UTC on hosts and containers. Many drivers (JDBC is the classic example) convert using the process's default zone, so a server that happens to run in local time will write different values than one in UTC.
Serialization at the API boundary
Send ISO 8601 strings with an explicit offset, like 2026-11-01T05:30:00Z. A string with no offset is a wall time, and every client will guess its zone differently.
Convert only for display
Convert to the user's zone at the last moment, in the UI or in a report query, never before storing.
โ A JavaScript gotcha
new Date("2026-11-01") is parsed as midnight UTC, but new Date("2026-11-01T00:00") is parsed as midnight in the browser's local zone. The ECMAScript spec says so; it's not a browser bug. If a date-only string turns into "October 31" for users west of Greenwich, look for this.
Rule 2: Store Future Wall Times With Their Zone
Instants work well for the past. For the future, they can be wrong.
Say a user in Mexico City booked a recurring 9:00 AM meeting in 2022 for dates in 2023. If you converted each occurrence to UTC at booking time, you used the DST rules in force then. In late 2022, Mexico abolished daylight saving time for most of the country. Every converted occurrence in what would have been summer time was now off by an hour, and the user never changed anything. Egypt brought DST back in 2023 with about two months' notice. Changes like these happen somewhere almost every year, and the IANA database ships several releases a year to keep up.
The user didn't mean "14:00 UTC." They meant "9:00 AM Mexico City time," whatever that turns out to be. So store what they meant:
appointments local_start timestamp -- 2027-06-14 09:00 (wall time, no zone) time_zone text -- 'America/Mexico_City' (IANA name, not '-06:00') -- compute the instant when you need it, with current rules: SELECT local_start AT TIME ZONE time_zone AS starts_at FROM appointments;
This is one of the few places a zone-less timestamp column is correct, because it's paired with a zone column that gives it meaning. If you also need an indexed instant for queries like "what starts in the next hour," store a computed UTC column and recompute it when your tz data updates.
| You're storing | Store as | Example |
|---|---|---|
| Something that happened | An instant (timestamptz / UTC) | created_at, paid_at, deleted_at |
| A future event at a place | Wall time + IANA zone name | Appointments, flights, store events |
| A recurring rule | Wall time + zone + recurrence rule | Every Monday 9:00 Europe/Berlin |
| A calendar date with no time | DATE | Birthdays, due dates, holidays |
| A duration | An interval or integer seconds | Session length, SLA timers |
๐ก Birthdays aren't timestamps
Storing a birthday as midnight UTC is a classic bug: users east or west of UTC see it shift by a day. A birthday is a date, not a moment, so store it as DATE and never convert it.
Rule 3: "Today" Depends on Where You Are
The other place time zones show up is in queries. "Orders per day" is ambiguous until you say whose day. Grouping timestamptz values by their UTC date puts a 9:00 PM Los Angeles order on the next day.
SELECT (created_at AT TIME ZONE 'America/Los_Angeles')::date AS day,
count(*)
FROM orders
GROUP BY 1
ORDER BY 1;Two details are worth knowing. First, AT TIME ZONE converts in both directions: applied to a timestamptz it returns a wall time in that zone, and applied to a timestamp it returns an instant. Second, days in DST zones aren't always 24 hours long; the transition days have 23 or 25. If you compute "yesterday" as now() - interval '24 hours', you'll occasionally miss or double count an hour. Use calendar arithmetic in the right zone instead.
A range filter like WHERE created_at >= '2026-11-01' has the same problem: that literal is interpreted in the session's zone. Write the offset explicitly, or compute the boundaries in application code from the user's zone.
Choosing a Type
Is this a moment that already happened (or happens at a fixed instant regardless of local rules)?
Key Takeaways
- Decide whether you have an instant, a wall time, or a date. The column type follows from that.
timestamp with time zonedoesn't store a zone. It stores a UTC instant. If you need the user's zone, add a column for it.- A zone-less timestamp needs agreement from every writer and reader. One server in local time, or one DST night, is enough to corrupt it.
- Run everything in UTC (database, app servers, containers) and convert only at display time.
- Store future local events as wall time plus an IANA zone. DST rules change, and a UTC value computed today can be wrong next year.
- Offsets aren't zones.
-05:00doesn't know about daylight saving;America/New_Yorkdoes. - Group and filter by day in an explicit zone. "Today" depends on where you're standing.
References
- PostgreSQL docs: Date/Time Types, including how offsets are ignored for
timestamp - MySQL docs: The DATE, DATETIME, and TIMESTAMP Types
- IANA Time Zone Database
- Jon Skeet, Storing UTC is not a silver bullet, on future events and rule changes