Unix timestamp bugs: seconds vs milliseconds, Y2038, and the off-by-1000 that ships
published
TL;DR
The single most common epoch bug is mixing units: JavaScript counts milliseconds, most of Unix counts seconds, and a value off by a factor of 1000 lands you in 1970 or year 52,000. After that comes the 2038 signed-32-bit overflow, leap-second handling, and the reminder that an epoch is always UTC — the timezone trouble starts when you format it.
The off-by-1000
The Unix epoch is “seconds since 1970-01-01T00:00:00Z.” But not every platform agrees on the unit, and the disagreement is silent:
| Platform / call | Returns | Unit |
|---|---|---|
JS Date.now(), new Date().getTime() | 1718000000000 | milliseconds |
Unix date +%s | 1718000000 | seconds |
Python time.time() | 1718000000.123 | seconds (float) |
Go time.Now().Unix() | 1718000000 | seconds |
Go time.Now().UnixMilli() | 1718000000000 | milliseconds |
Java System.currentTimeMillis() | 1718000000000 | milliseconds |
Java Instant.getEpochSecond() | 1718000000 | seconds |
Postgres extract(epoch from now()) | 1718000000.45 | seconds (float) |
Feed a seconds value to new Date() in JavaScript and you get a moment 1000× too early — new Date(1718000000) is 21 January 1970, not June 2026, because new Date(number) expects milliseconds. Feed a milliseconds value to a seconds-based API and you land tens of thousands of years in the future.
The reliable tell is magnitude. A seconds timestamp for “now” is 10 digits and stays 10 digits until November 2286; the millisecond equivalent is 13 digits. A safe normaliser:
// Coerce an ambiguous epoch to milliseconds.
// Anything below ~1e12 is almost certainly seconds.
const toMillis = (n) => (n < 1e12 ? n * 1000 : n);
new Date(toMillis(1718000000)); // seconds in → correct
new Date(toMillis(1718000000000)); // millis in → correct
This heuristic breaks only for timestamps before ~2001 (sub-1e9 seconds) or in the far future, which is acceptable for almost every real dataset.
Y2038
Systems that store Unix time in a signed 32-bit integer can hold at most 2**31 - 1 = 2147483647 seconds. That value is reached at 2038-01-19T03:14:07Z. One second later the counter overflows to negative and wraps to December 1901. This is the Year 2038 problem, and it is the reason 32-bit time_t is being replaced by 64-bit across operating systems and databases.
2**31 - 1 = 2147483647 → 2038-01-19T03:14:07Z (last valid 32-bit second)
2147483648 → 1901-12-13T20:45:52Z (overflow wrap)
You are exposed if any layer — an embedded device, a legacy database column, a serialization format, an old C library — still narrows the value to 32 bits. A 64-bit signed integer pushes the limit past the year 292 billion, so the fix is to make sure the type is 64-bit end to end.
Leap seconds and smearing
UTC is occasionally adjusted by a leap second to stay aligned with Earth’s rotation. Unix time, by definition, ignores leap seconds — it pretends every day has exactly 86,400 seconds. When a leap second is inserted, the Unix clock either repeats a value or the surrounding infrastructure has to absorb the discontinuity. Naively stepping the clock backward breaks timers and ordering assumptions.
The common production fix is leap smearing: spread the extra second across a window (e.g. 24 hours) so the clock never jumps. Google documented its smear and the public NTP servers that implement it (Google — Leap Smear). If you compare timestamps across systems with different smear policies during a leap event, expect sub-second disagreement.
Timezones
An epoch value has no timezone — it is an absolute count of seconds from a UTC instant. Two machines anywhere on Earth that capture Date.now() at the same moment record the same number. Timezones enter only when you format an epoch into a human calendar date, or parse a human date into an epoch.
const t = 1718000000000;
new Date(t).toISOString(); // '2024-06-10T06:13:20.000Z' (UTC, unambiguous)
new Date(t).toLocaleString('en-US', { timeZone: 'America/New_York' });
// formats the same instant in NY time
Store and transmit UTC (or the raw epoch). Apply a timezone only at the display edge. The classic bug is parsing '2024-06-10' with new Date('2024-06-10'), which JavaScript treats as UTC midnight — then displaying it in a negative-offset zone shows the previous day.
What to do
- Detect the unit by magnitude before constructing a date from an unknown source; normalise to one unit internally.
- Store epochs as 64-bit, and audit any 32-bit column or device in the path before 2038.
- Keep everything in UTC through storage and transport; convert to local time only when rendering.
- Round-trip via ISO 8601 / RFC 3339 strings when humans or logs are involved — they are unambiguous about offset.
When a timestamp looks wrong, paste it into the timestamp converter to see seconds, milliseconds, and the ISO form side by side — that usually exposes an off-by-1000 instantly.
Caveats
- The
< 1e12heuristic is pragmatic, not exact; document the assumed range for your data. - JavaScript
Datehas millisecond resolution; for higher precision useperformance.now()(monotonic, not wall-clock) orBigInt-backed nanosecond APIs. - Leap-second behavior is environment-specific. If exact ordering across a leap event matters, pin your NTP/smear policy explicitly.