Developer
Unix timestamps and time zones: converting time without off-by-hours bugs
Most time bugs come from mixing up three different things: an instant on the timeline, the clock reading someone in a particular place sees for it, and the rule that maps one to the other. This guide separates them. It covers Unix timestamps, the 2038 limit, offsets versus named zones, daylight saving traps and the JavaScript Date quirks that produce off-by-hours results. It is for anyone who stores, logs, schedules or displays times.
By the MaffeiTools team · Updated · 9 min read
Seconds or milliseconds: read the digit count
A Unix timestamp counts time since 1970-01-01T00:00:00Z, but the unit depends on the system. Unix tools, the C library and many web APIs count seconds. JavaScript's Date.now, Java's System.currentTimeMillis and many JSON APIs count milliseconds. The two differ by a factor of 1000, so a milliseconds value read as seconds lands tens of thousands of years in the future, and the reverse lands in January 1970.
The digit count settles it for dates in the present era. A 10-digit number is seconds and a 13-digit number is milliseconds for every moment from 2001-09-09 until 2286-11-20, because seconds reach 10 digits at 1,000,000,000 and milliseconds reach 13 at 1,000,000,000,000. The MaffeiTools Unix timestamp converter uses a slightly different cut in its Auto choice, and prints the rule under the input: 12 or more digits before any decimal point are read as milliseconds, anything shorter as seconds. That rule cannot recognize genuine millisecond values before 1973-03-03, which have 11 digits or fewer, so for such data you choose Milliseconds explicitly. The Seconds and Milliseconds buttons override Auto whatever the digit count.
The converter also refuses values beyond plus or minus 8,640,000,000,000,000 milliseconds. That is not an arbitrary limit: the JavaScript Date object cannot represent an instant outside that range, which MDN gives as roughly the years 271821 BC to 275760 AD.
1700000000 -> seconds 2023-11-14T22:13:20.000Z 1700000000000 -> milliseconds 2023-11-14T22:13:20.000Z 999999999999 -> milliseconds 2001-09-09T01:46:39.999Z 99999999999 -> seconds 5138-11-16T09:46:39.000Z (as milliseconds, the same digits are 1973-03-03T09:46:39.999Z) -1 -> seconds 1969-12-31T23:59:59.000Z 86400000 -> Auto reads seconds 1972-09-27T00:00:00.000Z 86400000 -> Milliseconds chosen 1970-01-02T00:00:00.000Z
What a Unix timestamp counts, and why leap seconds are missing
POSIX defines seconds since the Epoch by a formula in which every day contains exactly 86,400 seconds. The current edition of the Base Definitions calls the result a value that approximates the number of seconds elapsed since the Epoch, and gives the arithmetic: seconds, plus minutes times 60, plus hours times 3600, plus days into the year times 86,400, plus whole years since 1970 times 31,536,000, with leap-year corrections. There is no term for leap seconds, so they cannot be represented.
UTC itself does insert leap seconds occasionally, so a minute can have 61 seconds. RFC 3339 allows for this in its grammar with a seconds field of 60, and its examples include 1990-12-31T23:59:60Z, the leap second inserted at the end of 1990. A POSIX timestamp has no slot for that instant: the timestamp for 23:59:59 is followed directly by the timestamp for 00:00:00 the next day. IANA's time zone documentation states that POSIX requires leap seconds to be excluded, and MDN says the same of JavaScript's Date.now, which counts milliseconds with leap seconds ignored.
In practice this means the difference of two timestamps is not always the number of SI seconds that elapsed between them: subtract two timestamps that straddle a leap second and the result is one second short. For anything other than high-precision scientific timing the convention is harmless, and it is what keeps date arithmetic on timestamps simple. It matters mainly when logs from different systems must be correlated to the second across a leap-second boundary.
Date.UTC(1990,11,31,23,59,59) / 1000 -> 662687999 Date.UTC(1991,0,1) / 1000 -> 662688000 Date.UTC(1990,11,31,23,59,60) -> 1991-01-01T00:00:00.000Z
The 2038 limit for signed 32-bit time_t
A signed 32-bit integer holds values up to 2^31 minus 1, which is 2,147,483,647. As a Unix timestamp in seconds, that is 2038-01-19T03:14:07Z. One second later the value would be 2^31, which does not fit, and in two's-complement arithmetic the counter wraps to minus 2,147,483,648. Read as a timestamp, that is 1901-12-13T20:45:52Z, so a system that stores time in a signed 32-bit field jumps back 136 years.
The exposure is any place that keeps seconds in a signed 32-bit field: older 32-bit builds of C programs using time_t, embedded firmware, binary file formats and database columns declared as 32-bit. A 64-bit time_t pushes the limit billions of years away, and an unsigned 32-bit field moves it to 2106-02-07T06:28:15Z. JavaScript is not affected in the same way, since Date uses a 64-bit floating-point number of milliseconds, but a JavaScript service that writes timestamps into a 32-bit column inherits the limit from the column.
The problem arrives earlier than the date suggests for data that carries future timestamps, such as certificate expiries, contract end dates or thirty-year loan schedules. Code that computes a date past January 2038 can fail years before the calendar gets there, so boundary values belong in the tests of any component that stores time.
2147483647 -> 2038-01-19T03:14:07.000Z (Tue, 19 Jan 2038 03:14:07 +0000) 2147483648 -> 2038-01-19T03:14:08.000Z (as a 64-bit value) new Int32Array([2147483648])[0] -> -2147483648 -2147483648 -> 1901-12-13T20:45:52.000Z 4294967295 (unsigned) -> 2106-02-07T06:28:15.000Z
UTC, fixed offsets and named zones are different objects
A timestamp identifies an instant and carries no zone. To show it to a person you need a rule that turns the instant into a wall-clock reading. A UTC offset such as +04:00 is a single number that applies to one instant. A time zone is a named rule set with a history: it says which offset applied on every past date and, as far as anyone can predict, on future ones.
The IANA time zone database is the source of those rules. Its names follow an Area/Location pattern, such as Africa/Cairo, America/New_York and Pacific/Honolulu. The database's own documentation warns that alphabetic abbreviations are ambiguous, noting that CST means one thing in China and another in North America and IST can mean India, Ireland or Israel, and it advises numeric offsets instead. Storing an abbreviation loses information you cannot recover.
The same instant reads differently by place, and offsets are not always whole hours: India runs at UTC+05:30. The four lines below are one moment written four ways.
UTC 2023-11-14T22:13:20Z Asia/Dubai 2023-11-15T02:13:20+04:00 America/New_York 2023-11-14T17:13:20-05:00 Asia/Kolkata 2023-11-15T03:43:20+05:30 (UTC+05:30)
Why adding 24 hours is not adding a day
On days when daylight saving time starts or ends, a calendar day has 23 or 25 hours. So there are two different operations that both get called adding a day. Adding elapsed time means adding 86,400 seconds to the instant. Adding calendar time means keeping the same wall-clock reading tomorrow. Across a transition they give different answers, and a scheduler that wants a 9:00 daily reminder needs the second.
New York moved to daylight time on 2024-03-10. Adding 24 hours to noon on March 9 lands at 1:00 PM on March 10, one hour late by the clock; the same noon reading the next day is only 23 hours away. The same rule shifts meetings across zones: nine in the morning in New York is 14:00 in London on March 8 but 13:00 on March 11, because the United States had already changed its clocks by March 11 and the United Kingdom had not.
Local times also can fail to map to a single instant. When clocks spring forward, a reading such as 02:30 does not exist. When they fall back, a reading such as 01:30 happens twice. The MaffeiTools time zone converter documents its policy: an ambiguous time resolves to the first, daylight-saving occurrence, and a nonexistent time resolves forward past the jump. Libraries differ on these cases, so a business rule that depends on one should state which reading it wants.
noon 2024-03-09 2024-03-09T12:00:00-05:00 (2024-03-09T17:00:00Z)
+ 24 hours 2024-03-10T13:00:00-04:00 (2024-03-10T17:00:00Z)
noon 2024-03-10 2024-03-10T12:00:00-04:00 (23 hours after the first)
02:30 on 2024-03-10 (does not exist) -> 2024-03-10T03:30:00-04:00
01:30 on 2024-11-03 (happens twice) -> 2024-11-03T01:30:00-04:00 (first)
2024-11-03T01:30:00-05:00 (one hour later)
09:00 New York 2024-03-08 -> London 14:00, Kolkata 19:30
09:00 New York 2024-03-11 -> London 13:00, Kolkata 18:30ISO 8601 and RFC 3339 formats, and which one to write
RFC 3339 is a profile of ISO 8601 for the internet: a full date, the letter T, a full time, and a required offset, such as 1996-12-19T16:39:57-08:00. The offset can be Z for UTC or a signed hour and minute. That is why an RFC 3339 timestamp is unambiguous: the reader never has to guess which zone was meant, and two strings with different offsets can still be compared by converting each to an instant. RFC 3339 also notes that applications may separate the date and time with a space for readability, and that generators should use uppercase letters.
RFC 3339 gives special meaning to an offset of minus zero, written -00:00: it says the time in UTC is known but the local offset is not, whereas Z and +00:00 both say that UTC is the preferred reference. Its section on unqualified local time explains why omitting the offset is unacceptable on the internet: a bare local time will be misread in about 23 of every 24 places. JavaScript's toISOString always emits UTC with a Z and three fractional digits.
An offset records where the clock was, not the rule behind it. IANA's own documentation gives the reason for storing a zone name for future events: if someone schedules a meeting for 13:00 next October 1 Dublin time and Ireland then changes its daylight saving rules, software that relies on a conversion made before the change can go wrong. A record of a past event is an instant, so store UTC. A future appointment is a local wall-clock time plus an IANA zone, resolved when the day arrives.
JavaScript Date: the parts that surprise people
The Date constructor with separate fields takes a zero-based month, so 0 is January and 11 is December, and MDN's own examples carry a comment to that effect. Out-of-range fields do not throw, they roll over: new Date(2024, 1, 30) is not an error but March 1. A day that comes from user input needs an explicit range check.
String parsing has two behaviors that differ by one character. Per MDN, the standardized date-time string format treats a date-only form as UTC and a date-time form with no offset as local time. So new Date('2024-03-10') is midnight UTC while new Date('2024-03-10T00:00:00') is midnight where the code runs. In a browser in Los Angeles those are eight hours apart, and a date displayed in a negative-offset zone can show the previous day. Strings in other formats, such as a month name, are parsed however the implementation chooses, including which zone applies, and MDN adds that engines are inconsistent about impossible dates such as February 30 in a string. Give parsers ISO input with an explicit offset.
Date counts milliseconds, ignores leap seconds, and is always a UTC instant internally; the confusing part is the local-time getters and toString, which apply the host's zone. The time zone converter and the timestamp converter avoid the host zone by asking Intl.DateTimeFormat for the wall-clock parts in a named IANA zone, which is also the approach that works for zones other than the browser's.
new Date("2024-03-10").toISOString() -> 2024-03-10T00:00:00.000Z
new Date("2024-03-10T00:00:00").toISOString() -> 2024-03-10T08:00:00.000Z
new Date(2024, 1, 30).toDateString() -> Fri Mar 01 2024
new Date(2024, 0, 31).getMonth() -> 0A working rule set for storing and converting time
These rules follow from the mechanisms above and cover most production cases.
- Store past and present events as UTC instants: epoch seconds or milliseconds in an integer wide enough for the year 2038, or an RFC 3339 string with an offset.
- Store future appointments as local date, local time and an IANA zone name, and convert when needed.
- Convert to the viewer's zone only at the edge, when rendering, using a named zone rather than a saved offset.
- Add elapsed time to instants; add calendar days to wall-clock fields in the intended zone.
- Label the unit on every numeric timestamp in an API, or use RFC 3339 strings so the unit question disappears.
- Check any suspicious value with a converter: a result in January 1970 means seconds were expected and a milliseconds-scale or zero value arrived, and one in the year 50,000 means the reverse.