Seconds or Milliseconds: The Unix Timestamp Bug Everyone Ships Once
Why a date lands in 1970 or the year 56000, what the 2038 problem actually breaks, and the digit-count check that catches both.
Table of contents
A user's profile says they joined on 20 January 1970. Another says 3 August 56042. Both records are fine; the code reading them is not. One system counted seconds from the epoch and another counted milliseconds, and nothing in either number says which.
It is the most common date bug in software, and it is entirely mechanical to avoid once you know the tell.
Key takeaways#
- Unix time counts from 1970-01-01T00:00:00Z. The unit is convention, not part of the value.
Date.now()in JavaScript returns milliseconds. Most Unix tooling andtime()in C return seconds.- Count the digits: a current timestamp is 10 digits in seconds, 13 in milliseconds.
- Reading ms as s lands you far in the future; reading s as ms lands you just after 1970.
- A signed 32-bit
time_toverflows on 19 January 2038, and the systems still using one are not the ones you interact with daily.
Why does the unit ambiguity exist at all?#
Because a Unix timestamp is just an integer. There is no unit attached, no type, nothing to validate against. 1756... is a valid number whichever scale produced it, and every language will happily construct a date from it.
The split runs along ecosystem lines. C's time(), the date +%s shell command, PostgreSQL's EXTRACT(EPOCH …), and most Unix-derived APIs use seconds. JavaScript's Date.now(), Java's System.currentTimeMillis(), and most JVM and .NET APIs use milliseconds. Move a value from one world to the other without converting and it is off by a factor of 1,000.
That factor is what produces the two signature symptoms:
| You did | Result | Looks like |
|---|---|---|
| Read milliseconds as seconds | value × 1000 too large | a date tens of thousands of years out |
| Read seconds as milliseconds | value ÷ 1000 too small | a date within hours of 1 Jan 1970 |
Neither throws. Both render a perfectly valid date.
How do I tell which one I have?#
Count the digits. Since September 2001 a seconds timestamp has been 10 digits, and it stays 10 until November 2286. The millisecond equivalent is 13.
const looksLikeMilliseconds = String(value).length >= 13;
const ms = looksLikeMilliseconds ? value : value * 1000;
new Date(ms);That heuristic is sound for any current date, and it is what most defensive parsers use. It is not a substitute for knowing your source — treat it as a guard for data you did not produce, not as a design.
The better fix is upstream: name the variable for its unit. expiresAtMs and expiresAtSeconds make the mismatch visible at the call site, where expiresAt hides it until it renders.
The most consequential place this bites is authentication. A JWT's exp and nbf claims are defined in seconds, so a millisecond value produces a token that never expires or is never yet valid.
Types help more than naming if your language allows it. A branded type or a small wrapper class makes seconds and milliseconds genuinely incompatible, so passing one where the other belongs fails to compile rather than producing a date in the wrong millennium. That is heavier than a naming convention and worth it anywhere timestamps cross a service boundary.
Where does the time zone come in?#
It does not, and that is worth stating clearly because it causes its own confusion.
A Unix timestamp is an absolute instant. It has no time zone, because it counts from a fixed moment in UTC. Two people in different countries observing the same event record the same number.
The zone enters only at display. Rendering a timestamp means choosing a zone, and if your API renders in UTC while your database console renders in local time, the same instant appears as two different clock times. Most "off by a few hours" bugs are exactly that — not a wrong value, but two correct renderings of one value.
This is why storing an offset-naive local datetime is a mistake that surfaces months later, usually on the morning the clocks change.
Is the 2038 problem real?#
Yes, and it is narrower than the headlines suggest.
A signed 32-bit integer holds a maximum of 2,147,483,647. As a seconds-since-epoch value that is 03:14:07 UTC on 19 January 2038. One second later it wraps to negative and the date becomes December 1901.
What is not affected: JavaScript, which uses a double and covers ±273,000 years; 64-bit Linux and macOS, where time_t is 64-bit; PostgreSQL and modern MySQL timestamp types; anything storing milliseconds in a 64-bit integer.
What is: 32-bit embedded systems and firmware, older file formats with a 32-bit time field, some legacy database columns typed as INT, and network protocols that specified a 32-bit field. The exposure is real in industrial and IoT contexts and close to zero in a typical web stack.
The pragmatic check is whether any timestamp in your system is stored in a column or struct field that is 32 bits wide. If it is a BIGINT or a double, you are not in scope.
One wrinkle catches people who assume they are safe. MySQL's TIMESTAMP type is 32-bit and does stop at 2038; its DATETIME type does not. Those two look interchangeable in a schema and are not, so a table using TIMESTAMP for an expiry date that can legitimately fall after 2038 has a problem today rather than in twelve years.
A checklist before you ship a date#
- Which unit does the source produce — seconds or milliseconds?
- Does the variable name say so?
- Is the storage type at least 64 bits?
- Is the value stored as an instant, not as a local wall-clock time?
- Which zone does each surface render in — API, database console, log aggregator?
When you are staring at a raw number and want to know what it means, the timestamp converter shows both interpretations and both zones at once, which usually makes the answer obvious. The Unix time converter goes the other direction when you need a value to paste into a query.
The short version#
The number carries no unit and no zone, so both have to come from you. Count digits when you inherit a value, name variables for their unit when you produce one, store instants rather than local times, and check the width of the column before assuming 2038 is somebody else's problem.
Tags
- Dates
- JavaScript
- Databases
- Debugging

