BlogDeveloper

Unix timestamps, epochs, and the year 2038 problem

What a Unix timestamp actually is, the seconds-vs-milliseconds bug that bites everyone, and why signed 32-bit time overflows at 03:14:07 UTC on 19 January 2038.

A Unix timestamp is a single integer: the number of seconds elapsed since 1970-01-01 00:00:00 UTC, the moment known as the epoch. That one design decision — count seconds from a fixed point in UTC — makes timestamps cheap to store, trivial to compare, and free of timezone ambiguity. It also sets up two of the most common bugs in production systems: the off-by-1000 error between seconds and milliseconds, and the overflow that will hit signed 32-bit clocks at a precise instant in 2038.

What the number actually represents

The value is a monotonic count of seconds. 0 is the epoch. 1000000000 is 2001-09-09 01:46:40 UTC. 1700000000 is 2023-11-14 22:13:20 UTC. In mid-2026, a current timestamp in seconds is a ten-digit number starting with 17.

The count is timezone-independent at storage time. A timestamp carries no offset, locale, or "this was Tokyo time" flag. It is an instant on the global timeline, expressed as seconds-from-epoch in UTC, so two machines on opposite sides of the planet produce the same timestamp for the same instant. The timezone only enters when you convert that integer into a human-readable string — a presentation concern, not a storage one.

That is what makes timestamps good for storage: exactly one canonical value per instant, with no conversion needed to sort, diff, or compare events from different sources.

Seconds vs milliseconds vs microseconds

The single most frequent timestamp bug has nothing to do with 2038. It is unit confusion. Different platforms use different resolutions:

  • Seconds — most Unix system calls, POSIX time(), the Linux date +%s command, PostgreSQL EXTRACT(EPOCH …), most database epoch columns, JWT exp/iat/nbf claims.
  • Milliseconds — JavaScript. Date.now() returns milliseconds, not seconds. So do Java's System.currentTimeMillis() and most JVM date APIs.
  • Microseconds / nanoseconds — high-resolution timers, some tracing systems, Go's time.UnixNano().

The classic failure is passing a JavaScript millisecond value into a function expecting seconds, or vice versa — an off-by-1000 that lands you in the wrong millennium. A seconds value of 1700000000 read as milliseconds is 1970-01-20, twenty days after the epoch. A milliseconds value of 1700000000000 read as seconds is the year 55840.

You can usually tell which unit you have by digit count, for any time in the current era:

  • 10 digits → seconds (e.g. 1748000000, May 2025)
  • 13 digits → milliseconds (e.g. 1748000000000)
  • 16 digits → microseconds
  • 19 digits → nanoseconds

This heuristic holds from 2001 (10-digit seconds begin) until 2286 (seconds reach 11 digits) — so for any timestamp you realistically handle, digit count tells you the unit. When in doubt, convert both interpretations and keep the one that lands in a plausible decade.

The year 2038 problem

Many older systems store time in a signed 32-bit integer — the C time_t type as it was historically defined on 32-bit platforms. A signed 32-bit integer can hold values from −2147483648 to 2147483647. That upper bound, 2^31 − 1 = 2147483647 seconds after the epoch, is:

2038-01-19 03:14:07 UTC

At exactly 03:14:08 UTC on 19 January 2038, the counter cannot increment without overflowing. In a signed integer, incrementing past 2147483647 wraps to −2147483648, which is 1901-12-13 20:45:52 UTC. A clock that does this jumps from January 2038 to December 1901. This is the Year 2038 problem, sometimes written Y2038 or the "Epochalypse."

What actually breaks:

  • Embedded and legacy systems — routers, industrial controllers, point-of-sale terminals, and other long-lived devices running 32-bit firmware that will not be patched.
  • Old database schemas — columns or serialization formats that pinned time to 32-bit. MySQL's TIMESTAMP type historically topped out at 2038-01-19 03:14:07 UTC for this reason; DATETIME did not have the limit.
  • Software that computes future dates — a 30-year mortgage amortization, a long-expiry certificate, a far-future cache TTL. These hit 2038 arithmetic today, not in 2038. Code computing dates past January 2038 has been failing on 32-bit time_t for years.

The fix is a 64-bit time_t. A signed 64-bit integer counts seconds to about the year 292277026596 — roughly 292 billion years out, which retires the problem permanently. 64-bit operating systems and modern languages already use 64-bit time, and Linux extended time_t on 32-bit architectures in the kernel and glibc around 2020–2021. The migration reality is that the risk now lives in deployed firmware and old binaries, not in current compilers: anything you compile fresh on a 64-bit platform is already safe; the danger is the 32-bit device in the field that nobody will recompile.

The 1901 lower bound and unsigned variants

The same 32-bit width creates a floor. A signed 32-bit time_t cannot represent any instant before 1901-12-13 20:45:52 UTC (the −2147483648 boundary). Dates earlier than that — a 19th-century birth date, a historical event — overflow downward. This is why some older systems mishandle pre-1901 dates entirely.

A few systems used an unsigned 32-bit integer instead. It cannot represent times before the epoch (no negative values) but pushes the upper bound to 2^32 − 1 = 4294967295, or 2106-02-07 06:28:15 UTC — buying ~68 more years at the cost of all pre-1970 timestamps. You still see it in some network protocols and file formats, but it is a workaround, not a general solution.

Timezones live in the display layer

Because a timestamp is UTC by definition, the timezone is never part of the stored value. It belongs entirely to the layer that formats the integer for a human. The same 1748000000 renders as a different wall-clock string in Tokyo, London, and New York, but it is the same instant and the same stored number.

The most common timezone bug is the inverse mistake: taking a local wall-clock time and storing its components as if they were already a UTC timestamp. If a server in America/New_York reads "3:00 PM" local and writes the seconds-from-epoch for "3:00 PM UTC," every consumer is off by the offset — five hours in winter, four in summer, shifting with daylight saving. The rule: convert to UTC the moment you capture an instant, store the timestamp, and apply a timezone only when rendering for a person.

Leap seconds: Unix time pretends they don't exist

UTC occasionally inserts a leap second to keep clocks aligned with the Earth's rotation; 27 have been added since 1972. Unix time ignores them. By definition, a Unix timestamp assumes every day is exactly 86400 seconds long. When a leap second occurs, Unix time does not get a new value — the clock typically repeats or smears a second so the count stays consistent with the 86400-per-day model.

The practical implications:

  • A Unix timestamp is not a true count of physical SI seconds since 1970. It is off by the number of leap seconds inserted.
  • Computing an exact duration between two timestamps spanning a leap second will be off by a second versus a real stopwatch.
  • During a leap second, two distinct real instants can map to the same Unix timestamp, so timestamps are not guaranteed strictly unique at sub-second boundaries.

For almost all application code this does not matter. For high- precision timing, trade sequencing, or scientific work it does, and you reach for TAI or a leap-smearing clock source instead.

ISO 8601 vs. raw timestamps

A Unix timestamp is ideal for machines and useless to humans — 1748000000 tells you nothing at a glance. ISO 8601 is the human-readable interchange format: 2025-05-23T11:33:20Z, where the trailing Z means UTC ("Zulu"), or with an explicit offset like 2025-05-23T07:33:20-04:00.

When to use which:

  • Store a Unix timestamp when the value is internal and you want the smallest, fastest representation for arithmetic or comparison.
  • Store or transmit ISO 8601 when humans or heterogeneous systems read the value, when you need an explicit offset, or when the field lands in a log, an API payload, or a config file someone will eyeball. As a bonus, ISO 8601 sorts lexicographically in UTC.

A reasonable default: store timestamps internally, serialize ISO 8601 at API boundaries.

Converting between them

When you are staring at a ten- or thirteen-digit number in a log line and need to know the instant — or need to produce a timestamp for a given date — our Timestamp Converter handles both directions, auto-detects seconds versus milliseconds, and shows the result in UTC and your local timezone side by side. It is the fastest way to settle an "off by 1000 or off by a timezone" question without opening a REPL.