All posts

How to debug timezone errors in APIs

Find out why API times are off by hours: read the symptom, store UTC, send ISO 8601 offsets, handle DST and JavaScript parsing, then work the checklist.

By Last reviewed 6 min readTime zonesAPIsDebugging

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 seeLikely 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 summerThe same bug, in a zone with daylight saving time
Off by exactly one hour for part of the yearA fixed offset was stored instead of a zone, or a machine has outdated timezone rules
A date-only value shows the previous dayThe date was parsed as UTC midnight and displayed in a zone behind UTC
Correct on your laptop, wrong on the serverThe process time zone differs between machines
January 1970, or a year in the tens of thousandsSeconds 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:

  1. 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. Plain timestamp (without time zone) stores the wall-clock digits you give it, and if the literal includes an offset, PostgreSQL silently ignores it.
  2. Send ISO 8601 with an offset: 2026-03-09T14:00:00Z or 2026-03-09T10:00:00-04:00. A value like 2026-03-09T10:00:00 is a wall-clock reading with no location, and every parser fills the gap with its own assumption.
  3. 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

  1. 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.
  2. Normalise each one to UTC and line them up. The hop where the instant changes is where the bug lives.
  3. 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.
  4. Check every process time zone: the TZ environment variable, the JVM's -Duser.timezone, PostgreSQL SHOW timezone;, MySQL SELECT @@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.
  5. Check the serializer: does it emit Z or an offset, and does it treat naive values as UTC or as local time?
  6. 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.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.