Timezone bugs rarely announce themselves. They show up as a booking that moved by five hours, a daily report that loses its last evening, or a birthday that displays a day early, and often only for some users. The cause is almost always the same: somewhere between the client and the database, a time lost its offset.
The fastest way to see what a value really means is to convert the raw timestamp and compare UTC with local time side by side. The converter shows UTC, your browser's zone and one extra IANA zone you pick, such as the zone of the user who filed the bug. Keep it open while you work through the steps below.
Read the symptom first
The size of the error usually points at the cause before you open any code:
| What you see | Likely cause |
|---|---|
| Off by exactly the user's UTC offset (5 h, 5 h 30 min, 9 h) | A time without an offset was read as UTC on one side and as local time on the other |
| Off by that offset in winter and one hour less in summer | The same bug, in a zone with daylight saving time |
| Off by exactly one hour for part of the year | A fixed offset was stored instead of a zone, or a machine has outdated timezone rules |
| A date-only value shows the previous day | The date was parsed as UTC midnight and displayed in a zone behind UTC |
| Correct on your laptop, wrong on the server | The process time zone differs between machines |
| January 1970, or a year in the tens of thousands | Seconds and milliseconds mixed up; not a timezone problem at all |
For that last row, see Unix timestamps in seconds vs milliseconds.
Store UTC, send offsets
Three rules prevent most of these bugs:
- Store instants in UTC, in a type that knows it. In PostgreSQL that is
timestamptz, which stores an absolute instant and converts it to the session time zone on output. Plaintimestamp(without time zone) stores the wall-clock digits you give it, and if the literal includes an offset, PostgreSQL silently ignores it. - Send ISO 8601 with an offset:
2026-03-09T14:00:00Zor2026-03-09T10:00:00-04:00. A value like2026-03-09T10:00:00is a wall-clock reading with no location, and every parser fills the gap with its own assumption. - Convert to local time only at the edge, when you render for a person, using their IANA zone.
One exception: future events that people schedule in local time. A meeting at 09:00 in Berlin next July should be stored as the wall time plus Europe/Berlin, not as a UTC instant, because governments can change offset rules between now and then. Work out the UTC instant when you need to compare or notify.
A worked example: the booking that moved four hours
A user in New York books a slot for 10:00 on 9 March 2026. Their calendar shows 06:00. Here is what each hop recorded:
# 1. Request body sent by the browser
{"start": "2026-03-09T10:00:00"}
# 2. Row in PostgreSQL (column type: timestamp without time zone)
start_at = 2026-03-09 10:00:00
# 3. API response (the serializer assumes stored values are UTC)
{"start": "2026-03-09T10:00:00Z"}
# 4. Browser in America/New_York renders it
Mon Mar 09 2026 06:00:00 GMT-0400 (Eastern Daylight Time)
The client meant 10:00 New York time, which is 14:00 UTC on that date. It sent no offset, the database stored the bare digits, and the serializer labelled them UTC on the way out. The error is four hours because daylight saving time began on 8 March; the same booking in January would have been five hours off. An error that changes size with the seasons is the signature of a missing offset in a DST zone.
The fix belongs at hops 1 and 2: send 2026-03-09T10:00:00-04:00 (or the UTC instant) and store it in a timestamptz column.
The JavaScript parsing trap
JavaScript's Date parser treats strings that look almost identical in different ways. In a browser set to New York:
new Date("2024-01-15")
// 2024-01-15T00:00:00.000Z date-only: parsed as UTC midnight
// displays as Sun Jan 14 2024 19:00:00 GMT-0500
new Date("2024-01-15T10:00")
// 2024-01-15T15:00:00.000Z no offset: parsed as LOCAL time
new Date("2024-01-15T10:00Z")
// 2024-01-15T10:00:00.000Z explicit UTC
new Date("2024-01-15T10:00:00-05:00")
// 2024-01-15T15:00:00.000Z explicit offset
The first line is the "birthday shows a day early" bug: a date-only string becomes UTC midnight, which is still the previous evening in every zone behind UTC. The second line produces a different instant on every machine. Strings outside the ISO format, such as "2024-01-15 10:00" with a space, fall back to engine-specific parsing, so don't rely on them at all.
Two habits fix most of this. Always include Z or an offset in strings you parse, and keep date-only values (birthdays, due dates) as plain YYYY-MM-DD strings rather than Date objects. The converter's Date → Timestamp mode uses your browser's parser, so it follows the same rules; paste a value with and without its offset and watch the result move.
Python has its own version of the trap. A naive datetime (one without tzinfo) is treated as local time by .timestamp(), so code that builds UTC values with datetime.utcnow() is off by the machine's offset:
# On a machine in America/New_York
datetime(2026, 10, 25, 9, 30).timestamp() # 1792935000.0 (4 h off)
datetime(2026, 10, 25, 9, 30, tzinfo=timezone.utc).timestamp() # 1792920600.0
Use datetime.now(timezone.utc) and keep every datetime timezone-aware.
DST gaps and overlaps
Twice a year, local time stops mapping one-to-one onto UTC. In America/New_York in 2026:
- Gap, 8 March: clocks jump from 01:59:59 EST to 03:00:00 EDT, so 02:30 never happens.
- Overlap, 1 November: clocks fall back from 01:59:59 EDT to 01:00:00 EST, so 01:30 happens twice, at 05:30Z and again at 06:30Z.
Software has to pick an answer for both. JavaScript moves a nonexistent time forward and resolves an ambiguous one to the earlier instant:
// Process running in America/New_York
new Date("2026-03-08T02:30").toString()
// Sun Mar 08 2026 03:30:00 GMT-0400 (Eastern Daylight Time)
new Date("2026-11-01T01:30").toISOString()
// 2026-11-01T05:30:00.000Z the first 01:30 (EDT), not the second
Other libraries and databases may choose differently, which is one more reason not to pass local times between systems. DST also breaks arithmetic: on 7 March, adding 24 hours to 09:00 gives 10:00 the next day, while adding one calendar day gives 09:00. Decide which one a "daily" job or a "same time tomorrow" reminder means, and test it on a transition date.
IANA zones vs fixed offsets
An offset such as -05:00 describes one instant. A zone such as America/New_York is a set of rules: -05:00 in winter, -04:00 in summer, plus every historical change. Store the zone whenever you compute future or recurring local times; an offset saved in January is wrong in July.
Never parse abbreviations. IST is used for India, Ireland and Israel, and CST for both US Central and China. They are fine as a display label, not as input. In the browser, Intl.DateTimeFormat takes IANA names through its timeZone option, and in the converter you can search zones by city ("Tokyo", "New York") to add one next to UTC and local time.
A debugging checklist
- Collect the raw value at every hop: request body, database row, API response, client log. Use raw values, not what a UI or database client formatted for you.
- Normalise each one to UTC and line them up. The hop where the instant changes is where the bug lives.
- Measure the difference. Exactly the offset: a missing or ignored offset. One hour: DST or a stored fixed offset. A factor of 1,000: units.
- Check every process time zone: the
TZenvironment variable, the JVM's-Duser.timezone, PostgreSQLSHOW timezone;, MySQLSELECT @@global.time_zone, @@session.time_zone;. Servers and containers often run in UTC while laptops don't, which explains most "only in production" reports. If the bug appears in one environment only, diff the same API response from both and look at which time fields change. - Check the serializer: does it emit
Zor an offset, and does it treat naive values as UTC or as local time? - Write a regression test on a DST date with the time zone set explicitly, for example
TZ=America/New_York, so it fails in CI the way it fails for users.
Next steps
Take the raw value from your bug report, open the timestamp converter with the reporter's zone as the extra timezone, and compare it with what they saw. If you are still deciding how your API should send times, read Unix timestamp vs ISO 8601.