Unix Timestamps, Leap Seconds and the 2038 Problem
Published 5/20/2025 · 17 min read · Developer tools
Daniel Okonkwo — Front-end developer and tech writer at OneKitly
Web performance · File formats
Checked against 7 sources
Unix time is the number of seconds since 1970-01-01T00:00:00Z, computed as though every day contains exactly 86,400 seconds. Leap seconds are excluded, so it is not a count of elapsed physical seconds: 86,400 Unix seconds separate 2016-12-31T00:00:00Z from 2017-01-01T00:00:00Z even though 86,401 SI seconds actually passed, because UTC inserted 23:59:60 in between. That is the deliberate trade — the counter loses agreement with physics and gains the property that dividing by 86,400 always gives the right UTC date, with no table of leap seconds required. Twenty-seven leap seconds have been inserted since 1972, the last on 2016-12-31, and a POSIX clock has no way to represent :60, so it repeats or freezes a second instead. The 2038 problem is separate: a signed 32-bit counter maxes out at 2,147,483,647, which is 2038-01-19T03:14:07Z. One second later it wraps to −2,147,483,648, or 1901-12-13T20:45:52Z. A 64-bit time_t runs to 9,223,372,036,854,775,807 seconds, roughly 292 billion years, and ends the problem permanently. The commonest bug is not 2038 but confusing seconds with milliseconds: for 2026-08-15, the seconds value read as milliseconds gives 1970-01-21 and the milliseconds value read as seconds gives the year 58589.
Unix time counts seconds since 1970 while deliberately ignoring leap seconds, which is why it is not a count of elapsed physical seconds. Here is what that trade buys, what a leap second does to the counter, the exact instant a signed 32-bit counter overflows, and the factor-of-1000 bug that lands you in 1970 or the year 58589.
What the counter actually counts
Unix time, or POSIX time, is defined as the number of seconds elapsed since 1970-01-01T00:00:00Z under the assumption that every day contains exactly 86,400 seconds. The definition is arithmetic, not observational: the specification gives a formula built from the year, day of year, hour, minute and second, and that formula has no term for leap seconds because leap seconds do not exist inside it.
The consequence is worth stating bluntly, because it is where most confusion starts: a Unix timestamp is not a count of physical seconds. It is a compressed representation of a UTC calendar date and time. Two timestamps 86,400 apart are exactly one UTC day apart by construction, always, regardless of what a caesium clock measured over the same interval.
One caveat, because the property is often overstated. It holds in UTC, not in local time. Measured across real time zone data, local midnight to local midnight in Europe/Paris was 82,800 Unix seconds on 2026-03-29 and 90,000 on 2026-10-25, against 86,400 on an ordinary day; America/New_York gave 82,800 on 2026-03-08. Daylight saving does not change the counter — it changes how many counter ticks fit inside a local calendar day, which is precisely why timestamps should be stored as UTC instants and converted only at the moment of display.
The trade: no leap seconds, no lookup table
Excluding leap seconds looks like a defect until you consider the alternative. If the counter tracked physical seconds, converting a timestamp to a date would require knowing every leap second inserted between the epoch and that timestamp. Leap seconds are announced by the IERS a few months in advance, based on measurements of Earth's rotation, so that table cannot be computed — only distributed. Every device would need it, kept current, and any device with a stale table would compute a different calendar date from the same number.
The design chose determinism over physical accuracy, and the payoff is that timestamp arithmetic works everywhere with no shared state. A phone that has never had a network connection computes exactly the same date from 1786752000 as a datacentre clock does. Division by 86,400 is correct. Adding a day is adding 86,400. Sorting by timestamp sorts by time. None of that would survive a physical-seconds definition.
The price is paid in exactly one place: any calculation of a physical duration across a leap second is off by the number of leap seconds it crossed. Between 2016-12-31T00:00:00Z and 2017-01-01T00:00:00Z the Unix difference is 86,400, while 86,401 SI seconds actually elapsed. For almost every application that error is irrelevant. For satellite ranging, financial trade sequencing at sub-second granularity, and physics timing it is not, and those fields use TAI or GPS time instead — monotone scales with no leap seconds at all.
What a leap second does to the counter
UTC inserts a leap second by allowing a minute to contain 61 seconds, with the extra second labelled 23:59:60. Twenty-seven of them have been inserted since 1972 — TAI minus UTC was 10 seconds on 1972-01-01 and has been 37 seconds since 2017-01-01, and the difference is exactly the count. All of them were positive; a negative leap second, removing a second, is permitted by the standard and has never been used.
A POSIX clock has no representation for the second labelled 60, so something has to give. The classic behaviour is to repeat: the counter emits the same value twice, one second is not monotone, and any code assuming strictly increasing timestamps sees a duplicate. Some systems freeze the counter for a second instead. Both are visible to applications, and both have caused real outages in systems that treated a timestamp as a unique key or as a strictly increasing sequence number.
The pragmatic answer that large operators converged on is smearing: spreading the extra second across a window of hours so that no clock ever repeats or stops, at the cost of every clock in the window being slightly wrong. This matters less every year, because in November 2022 the 27th General Conference on Weights and Measures resolved to stop inserting leap seconds by or before 2035, allowing UTC to drift from solar time by more than the present limit. The mechanism has not yet been decided, but the direction is settled: the leap second is being retired.
2038, computed exactly
A signed 32-bit integer holds values from −2,147,483,648 to 2,147,483,647. Interpreted as seconds since the epoch, the maximum is 2038-01-19T03:14:07Z. Storing 2,147,483,648 into a real 32-bit signed slot and reading it back gives −2,147,483,648, which is 1901-12-13T20:45:52Z. Storing 2,147,483,649 gives 1901-12-13T20:45:53Z. The failure is not a crash or an error; it is a date 136 years in the past, delivered silently and used.
Two adjacent facts are worth knowing. An unsigned 32-bit counter reaches 4,294,967,295, which is 2106-02-07T06:28:15Z — a common workaround in embedded firmware, and one that only postpones the problem while making pre-1970 dates unrepresentable. And a signed 64-bit time_t reaches 9,223,372,036,854,775,807 seconds, about 292 billion years, which is roughly twenty-one times the current age of the universe. That is not a postponement; it is a permanent fix.
The date to worry about is not 2038 but today, because the first systems to break are the ones computing instants in the future. From 2026-08-15 the overflow is 360,731,647 seconds away — 4,175 days, or 11.43 years. A ten-year horizon from that date ends on 2036-08-14 and still fits. A fifteen-year horizon ends on 2041-08-14 and does not. Anything that stores an expiry, an amortisation schedule, a retention window or a certificate lifetime longer than about eleven and a half years is already producing values a 32-bit field cannot hold.
What is and is not at risk in 2026
Mainstream 64-bit operating systems are fine and have been for years. On the machine used for this article, sizeof(time_t) is 8 bytes and the shell command date -u -r 2147483648 prints Tue Jan 19 03:14:08 UTC 2038 with no wrap at all. The same is true of every current 64-bit Linux, macOS and Windows build. JavaScript was never exposed: a Date holds a float64 count of milliseconds and the specification clamps its range to plus or minus 8,640,000,000,000,000 milliseconds, which runs from −271821-04-20 to +275760-09-13.
The exposure that remains is narrow but real, and it is mostly in fields rather than in CPUs. Column types with a documented 32-bit range are the most common: MySQL's TIMESTAMP is specified to end at 2038-01-19 03:14:07 UTC, while its DATETIME type is not affected. Wire formats and file formats that specify a 32-bit field cannot be widened without a version bump. Embedded firmware on 32-bit microcontrollers frequently uses a 32-bit counter deliberately, for memory reasons. And any codebase that stores an epoch second in an explicitly 32-bit type — an int32 column, a fixed-width binary record, a C struct compiled for a 32-bit target — carries the limit regardless of the operating system underneath.
The practical audit is short. Grep for int32 and INTEGER columns holding epoch seconds; check the documented range of every timestamp column type you use; look for fixed-width binary records and any struct compiled for a 32-bit target; and test with the value 2147483648 rather than waiting. If a field cannot be widened, storing an ISO 8601 string or a 64-bit millisecond count are both fine alternatives, and both cost more bytes than they save arguments.
Seconds or milliseconds: the factor-of-1000 bug
This is by far the commonest timestamp bug, and it is entirely a units problem. Unix tools, most APIs and the POSIX definition use seconds. JavaScript, Java and a great many web APIs use milliseconds. The two are the same number scaled by 1,000, and neither is labelled in the wire format, so a mismatch is invisible until a date is displayed.
Both directions produce an absurd result, which is the good news. Take 2026-08-15T00:00:00Z: as seconds it is 1786752000, as milliseconds 1786752000000. Feed the seconds value to something expecting milliseconds and you get 1970-01-21T16:19:12Z — three weeks after the epoch, because 1.79 billion milliseconds is only about twenty days. Feed the milliseconds value to something expecting seconds and you get the year 58589, specifically +058589-12-01T00:00:00Z. Both are so far from plausible that a single spot check catches them.
The heuristic that works: a current timestamp in seconds has ten digits, and in milliseconds thirteen. Ten digits will stay correct until 2286. Better than a heuristic is naming the field so the unit is impossible to misread — expiresAtSeconds rather than expiresAt — or carrying an RFC 3339 string across the boundary and parsing it on arrival, which is self-describing and costs a few dozen bytes.
Storing time so it survives
Store the instant, not the rendering. An instant is a point on the timeline and is fully specified by a UTC timestamp with enough width — a 64-bit epoch second, a 64-bit millisecond count, or an RFC 3339 string ending in Z. A rendering is what a person in a particular place would see, and it depends on time zone rules that governments change with a few weeks' notice. Storing the rendering means storing an answer that can become wrong retroactively.
One exception deserves naming, because storing UTC is often oversold as universal advice. A future appointment in a named place is not an instant — it is a wall-clock time in a jurisdiction, and if that jurisdiction moves its clocks the correct instant changes. A meeting at 09:00 in Berlin next November should be stored as the local date, the local time and the IANA zone identifier, and resolved to an instant only when needed. Store UTC for things that happened, and local time plus a zone name for things that are scheduled.
Beyond that, three habits remove most of the remaining pain. Give every timestamp field a unit in its name so a reader never has to guess between seconds and milliseconds. Use a 64-bit type everywhere, including in the database column, so 2038 is a historical curiosity rather than a deadline. And never treat a timestamp as a unique identifier or a monotone sequence number, because leap-second handling, clock corrections and virtual machine migrations can all make the same value appear twice or make time briefly run backwards.
| Representation | Unit | Earliest | Latest | Where you still meet it |
|---|---|---|---|---|
| Signed 32-bit time_t | Seconds | 1901-12-13T20:45:52Z | 2038-01-19T03:14:07Z | 32-bit embedded targets, int32 columns, fixed-width binary records |
| Unsigned 32-bit counter | Seconds | 1970-01-01T00:00:00Z | 2106-02-07T06:28:15Z | Firmware workarounds; no pre-1970 dates possible |
| Signed 64-bit time_t | Seconds | About 292 billion years before 1970 | Year 292,277,026,596 (2^63 − 1 seconds) | Every current 64-bit Linux, macOS and Windows build |
| JavaScript Date | Milliseconds (float64) | −271821-04-20 | +275760-09-13 (±8,640,000,000,000,000 ms) | Browsers and Node; never had a 2038 problem |
| MySQL TIMESTAMP | Seconds | 1970-01-01 00:00:01 UTC | 2038-01-19 03:14:07 UTC | Very widely deployed; DATETIME is the unaffected alternative |
| RFC 3339 string | Text, self-describing | No lower bound in the format | No upper bound in the format | APIs and logs; costs bytes, removes the seconds-versus-milliseconds ambiguity |
Frequently asked questions
- Is a Unix timestamp in UTC, or in my local time zone?
- It is neither, strictly, and that is the useful way to think about it. A Unix timestamp identifies a point on the timeline. It carries no time zone at all, because it does not need one — the number 1786752000 refers to the same moment everywhere on Earth. What is true is that converting it to a human-readable date requires a zone, and the epoch itself is anchored at 1970-01-01T00:00:00 UTC, so converting with no zone specified gives you UTC. This is why a timestamp is the right thing to store and transmit and the wrong thing to display: the storage layer needs an unambiguous instant, and the presentation layer needs a zone, a locale and a calendar. The common bug is converting to local time somewhere in the middle of a pipeline and then storing the result, which bakes in one machine's zone and quietly shifts every value by its offset. Convert once, at the last possible moment, in the user interface.
- What exactly happens at 03:14:07 on 19 January 2038?
- In any system holding the timestamp in a signed 32-bit integer, the counter reaches 2,147,483,647 and the next increment overflows. Simulated in a real 32-bit signed slot, storing 2,147,483,648 reads back as −2,147,483,648, which converts to 1901-12-13T20:45:52Z. The behaviour is not an exception or a crash; the value simply becomes a date 136 years in the past and gets used. What that causes depends on the code above it. Sorting reverses. Age and duration calculations go enormously negative. Certificates and sessions appear to have expired long ago, or never to expire. Cron-style schedulers either fire continuously or stop. Records land in the wrong partition. The reason it is dangerous is precisely that nothing raises an error: every layer receives a well-formed number and behaves correctly for the number it was given. That is also why testing is easy — set a field to 2147483648 today and read it back, rather than waiting for the date.
- My servers are 64-bit. Am I safe from the 2038 problem?
- Your operating system is, and your application may not be. On a modern 64-bit machine sizeof(time_t) is 8 bytes — checked here — and the shell prints Tue Jan 19 03:14:08 UTC 2038 for the value 2147483648 with no wrap. But the kernel is rarely where the limit lives. The exposure is in fields you chose: an INTEGER or int32 database column holding epoch seconds, a protocol or file format with a fixed 32-bit timestamp field, a struct compiled for a 32-bit embedded target, a fixed-width binary record written years ago. MySQL's TIMESTAMP column type is a documented example, specified to end at 2038-01-19 03:14:07 UTC regardless of how many bits the server has, while its DATETIME type is unaffected. A second, less obvious exposure is any third-party device or appliance in the estate — printers, cameras, controllers, sensors — where the firmware is 32-bit by design and may never be updated. The audit is worth doing now rather than in 2037, because the failures start with future-dated values and 2026 is already inside the eleven-and-a-half-year window.
- How can I tell whether a number is seconds or milliseconds?
- Count the digits. A present-day timestamp in seconds has ten digits and will keep having ten until 2286; the same instant in milliseconds has thirteen. For 2026-08-15T00:00:00Z the two values are 1786752000 and 1786752000000. If you are unsure, convert and look at the result, because both mistakes produce something obviously absurd: reading the seconds value as milliseconds gives 1970-01-21T16:19:12Z, three weeks after the epoch, and reading the milliseconds value as seconds gives the year 58589. A date in January 1970 or in the far future is almost always this bug rather than bad data. The durable fix is not detection but naming. Call the field expiresAtSeconds or createdAtMillis so the unit travels with the value, or send an RFC 3339 string such as 2026-08-15T00:00:00Z across service boundaries — it is self-describing, sorts correctly as text, survives being pasted into a log, and costs about twenty bytes.
- Do leap seconds mean my duration calculations are wrong?
- Technically yes, and in practice almost never enough to matter. Subtracting two Unix timestamps gives the difference in the counter, which omits every leap second in between. Across the 2016 leap second the counter says 86,400 seconds passed between 2016-12-31T00:00:00Z and 2017-01-01T00:00:00Z, while 86,401 SI seconds actually elapsed. Since only twenty-seven leap seconds have ever been inserted, the largest possible error on a duration spanning the whole period since 1972 is twenty-seven seconds — irrelevant for billing, session length, cache expiry, response latency at the millisecond level, or anything a business measures. It becomes relevant in satellite ranging, high-precision physics and financial sequencing at sub-second granularity, and those fields use TAI or GPS time, which have no leap seconds at all. There is a much bigger practical hazard than leap seconds, though: measuring elapsed time with the wall clock. NTP corrections, virtual machine migrations and manual clock changes can move a wall clock forwards or backwards by far more than a second. For measuring durations, use a monotonic clock — the one your language exposes as something like performance.now or a steady clock — and reserve wall-clock timestamps for recording when something happened.
- Should I store timestamps in UTC, or with a time zone?
- It depends on whether you are recording something that happened or scheduling something that will. For anything in the past — a log line, an order, a payment, an audit record — store the instant in UTC and convert only when displaying. The instant is a fact and never changes; the way it renders in a user's local time is a presentation concern that can be recomputed at any moment. For anything scheduled in the future, UTC is the wrong choice, and this is the case most often got wrong. A meeting at 09:00 in Berlin next November is not an instant — it is a wall-clock time in a jurisdiction. If Germany changes its clock rules between now and then, the correct instant moves, and a UTC value stored today would become an appointment at the wrong local hour. Store the local date, the local time and the IANA zone identifier such as Europe/Berlin, and resolve to an instant when you need one. Governments do change zone rules, usually with a few weeks of notice, and the tz database is updated several times a year to track them.
Articles you may find interesting
All guides →Related tools
Sources
- The Open Group / IEEE — POSIX Base Specifications — Seconds Since the Epoch (the formula that excludes leap seconds)
- IETF — RFC 3339 — Date and Time on the Internet: Timestamps
- IERS — International Earth Rotation and Reference Systems Service — Bulletin C, leap second announcements and the TAI−UTC value
- BIPM — 27th General Conference on Weights and Measures (2022), Resolution 4 on the future of the leap second
- ITU — Recommendation ITU-R TF.460 — Standard-frequency and time-signal emissions, the definition of UTC and 23:59:60
- Oracle — MySQL Reference Manual — The DATE, DATETIME, and TIMESTAMP Types (TIMESTAMP ends 2038-01-19 03:14:07 UTC)
- MDN Web Docs — Date — the ±8,640,000,000,000,000 millisecond range of a JavaScript Date
Spotted a mistake in this article?