IAnswerable

Timezone Bugs and How to Stop Having Them

03 Mar 2026

The rule that prevents almost every timezone bug: store UTC, render local. Every violation of that rule — storing local time, doing date math in local time, comparing dates as strings — is where the bugs live.

Store UTC, always

-- do this
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

-- not this
created_at TIMESTAMP NOT NULL DEFAULT now()  -- no timezone = ambiguous

TIMESTAMPTZ in Postgres stores in UTC internally regardless of the session timezone, and converts on the way out. Plain TIMESTAMP stores whatever was inserted, with no record of which timezone it meant — which becomes unrecoverable the first time your app server’s timezone setting changes.

Same rule in application code:

// store this
const createdAt = new Date().toISOString() // "2026-03-03T14:00:00.000Z"

// never store this
const createdAt = new Date().toLocaleString() // ambiguous, timezone-dependent

The classic off-by-one-day bug

A user in California books an appointment for “March 3rd.” Your server, running in UTC, stores 2026-03-03T00:00:00Z. When that date renders back to the user, if you convert UTC to their local timezone (UTC-8), midnight UTC becomes 4pm on March 2nd. The date flips.

The bug isn’t in the storage — UTC is correct. It’s in treating a date (no time component, no timezone) as if it were an instant (a specific moment in UTC). These are different types of data and need different handling.

// wrong: treats a calendar date as an instant, then converts timezone
const appointmentDate = new Date('2026-03-03') // parsed as midnight UTC
appointmentDate.toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles' }) // "3/2/2026" — wrong

// right: store and render calendar dates as plain dates, no time, no timezone conversion
const appointmentDate = '2026-03-03' // store as DATE type, not TIMESTAMPTZ

If something is a calendar date — a birthday, a due date, an appointment day — store it as DATE, not TIMESTAMPTZ. Don’t run it through timezone conversion at all.

Date math: do it in UTC, not local time

// wrong: "add one day" during a DST transition can add 23 or 25 hours instead of 24
const tomorrow = new Date(localDate)
tomorrow.setDate(tomorrow.getDate() + 1)

// right: work in UTC where every day is exactly 24 hours, no DST
const tomorrow = new Date(utcInstant.getTime() + 24 * 60 * 60 * 1000)

Daylight saving time is the mechanism behind most “this worked in testing, broke in production two weeks later” timezone bugs — because it only bites twice a year, on the transition dates.

Comparing dates: never as strings

// wrong: string comparison breaks across formats and timezone offsets
if (dateString1 > dateString2) { ... }

// right: compare as actual Date instants
if (new Date(dateString1) > new Date(dateString2)) { ... }

A quick checklist

Situation Store as
“This event happened at this instant” TIMESTAMPTZ, UTC
“This is a calendar date, no specific time” DATE
“This recurs at 9am local time for the user” Store the local time + the user’s timezone string, separately
Displaying to a user Convert from UTC to their timezone at render time only

That last row matters: never bake a user’s timezone into stored data unless the thing you’re storing is genuinely timezone-relative (a recurring local-time reminder). Everything else stores as UTC and converts only when it hits a screen.