Unix Timestamp Guide: Epoch Time, Y2038, and Seconds vs. Milliseconds

A Unix timestamp is just a number — 1700000000, say — and yet it's responsible for a disproportionate share of confused bug reports: dates that land 50 years in the future, times that are off by exactly one hour, or a database column full of numbers nobody's sure whether to divide by 1000. Almost all of that confusion traces back to a small set of facts about what the number actually represents. This guide covers them, and you can check any of the specifics against our timestamp converter, which shows local time, UTC, ISO 8601, and both seconds and milliseconds side by side for a given value.

What epoch time actually is

A Unix timestamp (also called epoch time or POSIX time) is the number of seconds that have elapsed since January 1, 1970, 00:00:00 UTC — a moment called “the Unix epoch.” That instant is arbitrary; it was chosen in the early 1970s during the development of Unix simply as a convenient recent reference point, not for any deeper reason. Every timestamp is a count forward (positive) or backward (negative, for dates before 1970) from that single fixed instant.

The reason this design won out over storing a year/month/day/hour/etc. breakdown is that a single integer is trivial to compare, sort, and do arithmetic on. Figuring out which of two dates comes first, or how many seconds elapsed between two events, is a single subtraction if both are epoch timestamps — versus a genuinely fiddly calendar calculation (accounting for varying month lengths, leap years, and leap seconds) if both are stored as human-readable date strings. Nearly every operating system, database, and programming language uses epoch time somewhere under the hood — file modification times, database created_at columns, JWT expiration claims, and log timestamps are all commonly stored this way — precisely because it turns date math into ordinary integer math.

The Y2038 problem

Classic Unix timestamps are stored as a signed 32-bit integer, which can represent values from roughly -2.1 billion to +2.1 billion. Counting forward in seconds from 1970, that positive range runs out at 03:14:07 UTC on January 19, 2038 — the instant a signed 32-bit second-counter overflows and wraps around to a large negative number, which most systems interpret as a date back in 1901. This is the Y2038 problem, and it's a direct structural echo of the Y2K bug: a fixed-width numeric field that seemed to have plenty of headroom when it was designed decades earlier turns out not to.

The practical impact today is smaller than Y2K's was, mainly because the fix is already well underway: modern 64-bit systems generally use a 64-bit time value, which pushes the overflow date out roughly 292 billion years — long enough not to matter. The real risk is concentrated in embedded systems, older 32-bit hardware, and legacy software still running with a 32-bit time_t that may not get updated before 2038 arrives, plus any database schema or file format that was designed around a 32-bit timestamp column decades ago. If you're building something with a multi-decade expected lifetime — firmware, an embedded device, a long-lived file format — it's worth explicitly confirming your timestamp field is 64-bit rather than assuming it.

Seconds vs. milliseconds: how to tell them apart

This is the single most common practical timestamp bug: JavaScript's Date.now() and new Date().getTime() return milliseconds since the epoch, while Unix timestamps in most other contexts — Python's time.time() by convention often gets treated as seconds, PostgreSQL's EXTRACT(EPOCH FROM ...), and most Unix command-line tools — return or expect seconds. Mixing the two silently produces wildly wrong dates without throwing any error, because both are just plain numbers to whatever's consuming them.

Feed a millisecond timestamp to a function expecting seconds and you get a date roughly 1,000 times too far in the future — a timestamp meant to mean “a few days ago” can resolve to a date centuries out, since multiplying by 1000 instead of dividing pushes you that far off in the wrong direction. Feed a seconds timestamp to something expecting milliseconds and you get a date suspiciously close to January 1, 1970, since a normal current-day seconds value is a small number when read as if it were milliseconds.

The reliable way to tell them apart, since there's no header or type tag on a bare number: check the order of magnitude. As of the mid-2020s, current seconds-based timestamps are 10-digit numbers (roughly 1.7-1.8 billion), while current milliseconds-based timestamps are 13-digit numbers (roughly 1.7-1.8 trillion). A useful rule of thumb — the one our own converter's auto-detect uses — is that any value at or above 10^12 is almost certainly milliseconds, since a seconds-based timestamp doesn't cross that threshold until the year 33658. When in doubt, converting the value both ways and checking which result lands on a plausible, recent-looking date is a fast sanity check.

Timezone and UTC conversion gotchas

The timestamp number itself has no timezone — it's an absolute count of seconds from a fixed instant, identical everywhere on Earth at any given moment. Timezone only enters the picture when you convert that number into a human-readable string, and that's exactly where bugs creep in. “Store times in UTC, display in local time” is the standard advice for a reason: if you instead store a timestamp that's already been shifted to a particular local timezone, you've baked an assumption into the data that becomes wrong the moment it's read from a different timezone, or the moment daylight saving time flips.

Daylight saving time is its own frequent source of off-by-one-hour bugs, particularly around scheduled or recurring events: a job scheduled for “2:30 AM local time” on the day clocks spring forward may not exist at all in some timezones (that local time is skipped entirely), and on the day they fall back it can occur twice. Storing the timestamp as UTC entirely sidesteps this ambiguity — there's no such thing as a DST transition in UTC — and the conversion to a specific local time only needs to happen at the point of display, using the viewer's actual timezone rather than the server's. A related trap: JavaScript's Date object always displays in the browser's local timezone by default (via toString()) unless you explicitly call a UTC-specific method like toISOString() or toUTCString(), which is an easy thing to overlook when a server and a browser in different timezones are comparing the “same” date and seeing different wall-clock values for it.

Keep reading