Unix Time, ISO 8601 and Time Zones: The Rules Behind Every Off-by-One-Day Bug
Timestamps look simple — a number, or a string with a T in the middle — and they produce a disproportionate share of production bugs: a report that is a day off, a scheduled job that runs twice, an expiry that fires an hour early each spring. Almost all of them come from a small set of confusions, and this guide names each one so you can recognise it before it ships.
The short version
- A Unix timestamp is an instant: seconds since 1970-01-01 00:00:00 UTC. It has no time zone. Only a wall-clock time has a zone.
- Seconds vs milliseconds is a factor-of-1000 bug that reads as "the year 56,000" or "January 1970". Check the digit count: 10 is seconds, 13 is milliseconds.
- A zone (
Asia/Dubai) is a set of rules; an offset (+04:00) is one value from those rules at one moment. Store the zone name, never just the offset. - Store instants in UTC. Convert to local time only at display. For future appointments, also store the zone name.
- ISO 8601 with an explicit
Zor offset is the only string form worth exchanging between systems.
Instants and wall-clock times are different things
The single most useful idea in this whole topic is the distinction between an instant — a point on the universal timeline, the same everywhere on Earth — and a wall-clock time — what a clock on a particular wall shows. 1727438400 is an instant. "12:00 on 27 September 2024" is a wall-clock time and is meaningless until you say whose wall: it is a different instant in Dubai, London and São Paulo.
Unix time is a count of seconds since the epoch, 00:00:00 UTC on 1 January 1970, and it is an instant. That is why it is the right format for systems to exchange and store: no zone, no ambiguity, no daylight-saving rules, and arithmetic is plain subtraction. Every bug in this guide is, at bottom, a wall-clock time being treated as an instant or an instant being displayed as if it were a wall-clock time in the wrong zone.
Seconds, milliseconds and the year 56,000
Unix tradition, most databases and JWT claims count seconds. JavaScript's Date.now(), Java's System.currentTimeMillis(), Go's UnixMilli() and most JSON APIs count milliseconds. Some tracing systems use microseconds or nanoseconds. Mix them and you get a date tens of thousands of years away (milliseconds read as seconds) or a date in January 1970 (seconds read as milliseconds).
The tell is the digit count. Current time in seconds is a 10-digit number; in milliseconds, 13; microseconds, 16; nanoseconds, 19. A JWT whose exp has 13 digits was written with Date.now() instead of Math.floor(Date.now() / 1000), and every validator will treat it as never expiring. Layerbit's timestamp converter reads the unit off the magnitude and says which it assumed, and its JWT inspector warns when a claim looks like milliseconds.
ISO 8601: which form you actually want
ISO 8601 is a family of formats, and only one member of it is safe for interchange: the full date-time with an explicit zone designator, 2026-09-27T12:00:00Z or 2026-09-27T16:00:00+04:00. The Z means UTC; the offset means "this wall-clock time, at this offset from UTC". Either way the instant is unambiguous. RFC 3339 is the profile of ISO 8601 that requires this, and it is what "ISO date" should mean in an API contract.
The forms without a zone are where trouble starts. 2026-09-27T12:00:00 is a wall-clock time with no wall; each parser picks one. JavaScript's Date parses that string as local time, but parses the date-only 2026-09-27 as UTC midnight — so in New York the second one displays as the evening of the 26th. That inconsistency, inherited from an old spec quirk, is the direct cause of a great many "the date is one day off" reports, and the fix is to never emit a date-time string without a Z or offset.
A zone is not an offset
+04:00 is a number. Asia/Dubai is a name for a set of rules, maintained in the IANA time zone database, that say what the offset is at every moment in history and (as far as legislation allows) the future. For Dubai the rules are simple: +04:00 always. For Europe/London the offset is +00:00 in winter and +01:00 in summer, and the dates of the switch have changed several times over the decades. For America/Sao_Paulo daylight saving was abolished in 2019, so an offset stored in 2018 for a 2020 appointment was wrong by the time it arrived.
This is why storing an offset with a future wall-clock time is a bug waiting to happen. If a user books "9 am on 15 March" in London, storing 09:00+00:00 gives the wrong instant if the clocks have changed by then. Store the wall-clock time plus the zone name, and compute the instant when it is needed using the rules current at that time. Browsers ship the IANA database through Intl.DateTimeFormat, which is how a purely client-side converter can show the correct historical offset for any date.
Convert any timestamp in any zone, locally
Layerbit's Unix Timestamp Converter detects the unit, shows the instant as ISO 8601, RFC 2822 and in any IANA zone with the offset in force at that moment, flags 32-bit overflow, and does calendar-correct arithmetic — all in the browser.
Daylight saving: the hour that happens twice
Twice a year, in zones that observe daylight saving, wall-clock time misbehaves. In spring the clocks jump forward and a whole hour of wall-clock times — 02:00 to 02:59 in most of Europe and North America — never happens. In autumn the clocks fall back and an hour happens twice; 01:30 refers to two different instants. Any code that schedules by wall-clock time has to decide what to do with these, and most code does not decide, it just breaks.
- A cron job at
30 2 * * *on a server in a DST zone does not run on the spring-forward night and runs twice on the fall-back night. Run schedulers in UTC. - "Add one day" to an instant by adding 86,400 seconds gives a wall-clock time an hour off across a transition. Calendar arithmetic must be done in the zone, not on the epoch number.
- "Add 24 hours" and "add one day" are different operations on a DST day, and a function that does not distinguish them is wrong on two days a year.
What to store
| You are recording… | Store | Why |
|---|---|---|
| When something happened (a log, an order, a login) | The instant, in UTC: timestamptz, or epoch seconds/milliseconds as an integer | It is a point in time; the zone of the observer is irrelevant to the fact. |
| When something will happen at a place (an appointment, a store opening) | Wall-clock time plus the IANA zone name | The instant may move if the zone's rules change before the date arrives. |
| A date with no time (a birthday, a due date) | A plain date, DATE or YYYY-MM-DD, never a midnight timestamp | Midnight is a different instant in every zone; a date-as-timestamp shifts by a day at the boundaries. |
| A duration | Seconds, or an ISO 8601 duration such as PT1H30M | A start and end pair can drift across DST; a duration cannot. |
Two database notes. MySQL's TIMESTAMP column silently converts to and from the session time zone, so the same row reads differently from two connections with different settings; DATETIME stores what you give it with no conversion. PostgreSQL's timestamptz stores UTC and converts on the way out, which is the behaviour you want, while plain timestamp stores a naked wall-clock time and is almost never what you want.
The edges: 2038, leap seconds, negative time
A signed 32-bit integer of seconds overflows at 03:14:07 UTC on 19 January 2038. Modern systems use 64 bits, but embedded firmware, older file formats, some database schemas and a surprising amount of C code with int where time_t was meant do not. If a system will still be running in 2038 — and industrial and medical equipment often is — check the width now. Leap seconds are simpler than they sound: Unix time ignores them by definition, pretending every day is exactly 86,400 seconds, which keeps arithmetic simple at the cost of drifting from atomic time by 27 seconds so far. Negative timestamps are legal and mean before 1970; they work almost everywhere except MySQL's TIMESTAMP type, which cannot represent them.
One rule covers most of it
Represent time as an instant in UTC everywhere inside the system, attach a zone name only where a future wall-clock time is the actual requirement, and convert to local time at the last possible moment for display. Systems built on that rule still have to handle DST edge cases and unit mismatches at their boundaries, but they do not have the pervasive, hard-to-reproduce off-by-an-hour and off-by-a-day bugs that come from letting wall-clock times masquerade as instants deep in the data model.
Related guides and tools
Frequently asked questions
No. It counts seconds since 1970-01-01 00:00:00 UTC and identifies a single instant that is the same everywhere. A time zone only enters the picture when you convert that instant into a wall-clock date and time for display, and that conversion is where the zone must be supplied explicitly.
Count the digits. Current Unix time is a 10-digit number in seconds and a 13-digit number in milliseconds; microseconds have 16 and nanoseconds 19. A value that converts to the year 56,000 was milliseconds read as seconds; one that converts to January 1970 was seconds read as milliseconds.
Store instants — things that have happened — in UTC, and convert to local time only when displaying. Store future appointments as a wall-clock time together with the IANA zone name, because the zone's offset rules may change before the date arrives. Never store just an offset like +04:00 in place of a zone name.
A historical quirk of the ECMAScript specification: date-only ISO strings are parsed as UTC midnight, while date-time strings without a zone designator are parsed as local time. The inconsistency causes many off-by-one-day bugs. Avoid it by always including a Z or an explicit offset in any date-time string you exchange, and by treating date-only values as plain dates rather than timestamps.
A signed 32-bit count of seconds overflows on 19 January 2038 at 03:14:07 UTC, wrapping to a date in 1901. Current operating systems use 64-bit time, but embedded devices, older file formats and some database columns do not, and equipment installed today may still be running then. Check any long-lived system that stores time in 32 bits.