Skip to main content

Unix Timestamp Explained: Seconds, Milliseconds, and the 2038 Problem

A practical guide to reading and converting a Unix timestamp — seconds vs milliseconds, the UTC-versus-local trap, negative epochs, and the Year 2038 overflow that breaks 32-bit systems.

Published By 李雷
#unix timestamp #epoch time #developer tools #timezones #debugging

Unix Timestamp Explained: Seconds, Milliseconds, and the 2038 Problem

A Unix timestamp is just a count: the number of seconds that have elapsed since 1970-01-01 00:00:00 UTC, a moment the standard calls the Unix epoch. That single integer is how most databases, log files, JWTs, and APIs record "when" without dragging timezones, calendars, or daylight saving into the storage layer. The trade-off is that a raw number like 1700000000 tells a human nothing until you decode it. This guide walks through the four traps that turn that decoding into a bug, and shows how to read any epoch value correctly the first time.

If you just want the answer for a specific number, paste it into the Unix Timestamp Converter and read the UTC and local columns side by side. The rest of this article explains why those columns matter.

What a Unix Timestamp Actually Counts

The epoch is fixed at 1970-01-01 00:00:00 UTC — this anchor comes from the POSIX standard (IEEE Std 1003.1), which defines "seconds since the Epoch" in those exact terms. Every timestamp is an offset from that instant. Positive numbers move forward; the value grows by exactly 1 every second, everywhere on Earth, with no timezone applied.

Here is a concrete conversion. The value 1700000000 resolves to:

  • UTC: Tue, 14 Nov 2023 22:13:20 GMT
  • ISO 8601: 2023-11-14T22:13:20Z
  • Local (UTC+08:00): 2023-11-15 06:13:20

Notice the date even flips between UTC and a +08:00 zone — the UTC clock says the 14th, the local clock says the 15th. The number itself never changed. Only the human label did. That is the whole point of storing epochs: the instant is unambiguous, and you decide which wall clock to render it against at display time.

Seconds vs Milliseconds: The 10-Digit Slip

This is the single most common epoch bug. Different ecosystems disagree about the unit:

  • Seconds are expected by Postgres to_timestamp(), the Linux date -d @... command, Go's time.Unix(s, 0), and most JWT exp claims.
  • Milliseconds are expected by JavaScript's new Date(ms), Java's Instant.ofEpochMilli, and the majority of JSON APIs.

The values look almost identical but differ by a factor of 1,000. 1719820800 (10 digits, seconds) is July 1, 2024. The same instant in milliseconds is 1719820800000 (13 digits). Feed the millisecond value into a seconds-based parser and you land in the year 56,489 — JavaScript multiplies an already-millisecond number by 1,000 a second time, and your date silently rockets 54,000 years into the future.

A reliable rule of thumb: 10-digit values (anything under 1e10, which covers up to roughly November 2286) are almost always seconds; 13-digit values are milliseconds. When I'm debugging a data import and a row shows a date far in the future, I don't reach for documentation anymore — I paste the raw number, let the auto-detect flag it as 13 digits, and the fix is "divide by 1000 before insert." One paste replaces a guessing game.

The UTC-versus-Local Trap

The second classic bug is assuming an epoch carries a timezone. It never does — a Unix timestamp is always UTC. The confusion shows up when a token "expires early." Say a JWT's exp claim is 1719820800 and your gateway rejects it as expired even though your watch says you have hours left. That value is 2024-07-01 08:00:00 UTC, which in a -08:00 zone is midnight — not the local 8 a.m. you assumed. The code read a UTC epoch as if it were local time, an 8-hour error that looks like a clock-skew mystery until you put the two columns next to each other.

The fix is procedural, not clever: before you ever subtract or add hours by hand, look at the absolute UTC value first, then convert deliberately to the zone you care about. A good converter shows the IANA local zone offset (like +08:00) right beside the UTC reading so the gap is visible in one glance.

The Year 2038 Problem

Here is the boundary that ages out a generation of systems. A 32-bit signed integer can hold a maximum of 2³¹ − 1 = 2147483647. Interpreted as seconds since the epoch, that value is 2038-01-19 03:14:07 UTC. One second later, a 32-bit signed time_t overflows and wraps to a negative number — the clock jumps back to December 1901. This is the Year 2038 problem, the spiritual successor to Y2K, and it is real for any embedded firmware, legacy C program, or filesystem still storing time in int32.

Modern environments dodge it by widening the type. JavaScript's Number is a 64-bit IEEE 754 double, so the converter linked above handles dates hundreds of thousands of years out with no overflow at all. The risk lives in the long tail: routers, industrial controllers, and old binaries compiled against a 32-bit time_t. If you maintain anything in that category, 2038 is your migration deadline, and you can verify a candidate value by converting it and checking whether it lands where you expect.

Negative Timestamps and the Pre-1970 World

Because the epoch is an offset, negative numbers are perfectly valid — they point before 1970. A legacy mainframe export might store a birthdate as -2208988800, which resolves to 1900-01-01 00:00:00 UTC. Many naïve parsers choke on negative input or quietly clamp it to the epoch, so you see a wall of "Dec 31 1969" records and assume the data is corrupt. It usually isn't — the source system simply used a pre-1970 reference, and once you confirm the real date you can shift the whole column correctly instead of throwing it away.

This is also why a date that "comes back as 1970" is a tell, not a dead end: it almost always means a 0 (or a swallowed negative) reached your formatter. Convert the raw value and the sign tells you the rest of the story.

Working With Timestamps Day to Day

Most timestamp pain is avoidable with two habits. First, never hand-type "now" as a constant into a test fixture — a frozen 1719820800 rots within days and your assertions drift; pull a live current-epoch value instead. Second, when you filter logs by a time window, convert your two human dates to epochs once, then drop them straight into a query like WHERE ts BETWEEN 1719847800 AND 1719849600 rather than doing timezone arithmetic in your head.

If your work also touches scheduled jobs, the same UTC-versus-local thinking applies to cron — a job that "runs at the wrong time" is often a server timezone mismatch, and the Crontab Helper is the companion tool for reading those expressions. For one-off epoch conversions, keep the timestamp converter open in a tab: paste, read UTC and local together, and confirm the unit before you write anything to a column you can't easily undo.


Made by Toolora · Updated 2026-06-13