Skip to content

Unix timestamps: seconds vs milliseconds, the 2038 problem and time zones

By · Timestamp, Date & Time · 6 min read · Updated

Time bugs are some of the most common and most embarrassing in software: reports that are off by a day, reminders that fire an hour late after a clock change, tokens that are expired on one server and valid on another, and a date of 1 January 1970 shown to a customer. Almost all of them come from mixing up a handful of concepts. This guide covers the three that cause most of the damage, units, the 2038 limit and time zones, with code you can reuse.

What a Unix timestamp is

A Unix timestamp counts seconds since 1970-01-01T00:00:00Z, the Unix epoch. At any instant it is the same number everywhere on Earth, so it carries no time zone. That makes it ideal for storing and comparing instants: sort by it to order events, subtract two to get a duration.

new Date('2026-10-09T14:30:00Z').getTime() / 1000   // 1791556200
date -u -d @1791556200                               # Fri Oct  9 14:30:00 UTC 2026 (GNU date)

Unix time ignores leap seconds; every day is exactly 86,400 seconds. For application code this almost never matters, but it means the number is not a precise count of physical seconds.

Seconds, milliseconds, microseconds, nanoseconds

Platforms disagree about the unit, and mixing them up is the most common timestamp bug of all:

UnitDigits todayTypical sources
Seconds10Unix tools, JWT exp/iat, PostgreSQL extract(epoch from …), most REST APIs
Milliseconds13JavaScript Date.now(), Java System.currentTimeMillis(), Elasticsearch, Kafka
Microseconds16PostgreSQL internals, BigQuery, some log pipelines
Nanoseconds19Go time.Now().UnixNano(), Python time.time_ns(), OpenTelemetry

The symptoms are easy to recognise once you have seen them. A date in January 1970 means seconds were read as milliseconds: new Date(1791556200) is 21 January 1970. A date tens of thousands of years in the future, or an "Invalid Date", means milliseconds were read as seconds: new Date(1791556200000 * 1000) lands in the year 58742.

When you receive timestamps from systems you do not control, detect the unit by magnitude:

function toDate(ts) {
  const n = Math.abs(Number(ts));
  if (n < 1e11) return new Date(ts * 1000);   // seconds (good until the year 5138)
  if (n < 1e14) return new Date(Number(ts));  // milliseconds
  if (n < 1e17) return new Date(ts / 1e3);    // microseconds
  return new Date(ts / 1e6);                  // nanoseconds, precision already lost
}

This heuristic is what the Unix Timestamp Converter uses to guess the unit. Use it at boundaries and in debugging tools. In your own systems, do not guess: put the unit in the name, as in created_at_ms or timeout_seconds. One more JavaScript trap: nanosecond values exceed Number.MAX_SAFE_INTEGER, so parse them as BigInt or as strings if you need the precision.

The 2038 problem

Many older systems store Unix time in a signed 32-bit integer. The largest value it can hold is 2,147,483,647, which is 2038-01-19T03:14:07Z. One second later the counter wraps to −2,147,483,648, which is 1901-12-13T20:45:52Z. You can watch it happen in any language with fixed-width integers:

const t = new Int32Array([2147483647]);
t[0]++;
t[0]                                   // -2147483648
new Date(t[0] * 1000).toISOString()    // '1901-12-13T20:45:52.000Z'

"That is twelve years away" is the wrong reaction, for three reasons. First, software computes future dates today: a 15-year certificate, a 20-year mortgage schedule or a far-future "never expires" sentinel already crosses 2038. Second, embedded devices installed now, such as meters, cars and industrial controllers, will still be running then. Third, the 32-bit values hide in places you would not think to look:

  • MySQL TIMESTAMP columns are limited to 2038-01-19 03:14:07 UTC. DATETIME or a BIGINT epoch column does not have this limit.
  • Code that casts, such as Java (int) (System.currentTimeMillis() / 1000) or C int32_t fields in a binary protocol or file format.
  • 32-bit Linux userlands built before the kernel (5.6) and glibc (2.34, with _TIME_BITS=64) gained 64-bit time support.

The fix is always the same: 64-bit integers for seconds or milliseconds, and date types without the limit. Then add a test that uses a date after 2038.

Instants versus local times

An instant is a point on the global timeline: "the payment was captured". A local date-time is what a wall clock shows, "09:00 on 3 March", and it only becomes an instant when combined with a time zone. Log entries, created_at, token expiry and event ordering are instants, so store them as UTC timestamps. Opening hours, a recurring 9 a.m. meeting and a birthday are local concepts, so store the local value plus the zone it belongs to.

Offsets are not time zones

An offset like +01:00 is a fixed difference from UTC. A time zone like Europe/London is a set of rules deciding which offset applies on which date, and governments change those rules. The same timestamp shown in two zones:

const fmt = (d, timeZone) =>
  new Intl.DateTimeFormat('en-GB', { timeZone, dateStyle: 'medium', timeStyle: 'long' }).format(d);

fmt(new Date(1791556200 * 1000), 'Asia/Kolkata')       // '9 Oct 2026, 20:00:00 GMT+5:30'
fmt(new Date(1791556200 * 1000), 'America/New_York')   // '9 Oct 2026, 10:30:00 GMT-4'

For future events, store the zone name, not the offset. A meeting at "10:00 +01:00" next July will be an hour wrong in London once summer time starts; "10:00 Europe/London" will not. Avoid abbreviations like IST or CST, which mean different things in different countries.

Daylight saving traps

In the UK in 2026, clocks jump from 01:00 to 02:00 on 29 March, so 01:30 local time that night does not exist. On 25 October they fall back, so 01:30 happens twice. The two moments below are an hour apart, but show the same wall-clock time:

fmt(new Date('2026-10-25T00:30:00Z'), 'Europe/London')   // '25 Oct 2026, 01:30:00 BST'
fmt(new Date('2026-10-25T01:30:00Z'), 'Europe/London')   // '25 Oct 2026, 01:30:00 GMT'

Consequences: a day is not always 24 hours, "add one day" and "add 86,400 seconds" can differ, and a job scheduled at 01:30 local time may run twice or not at all. Schedule server jobs in UTC. Kubernetes CronJobs use the controller's zone unless you set spec.timeZone.

Parsing traps in JavaScript

new Date('2026-10-09') is parsed as UTC midnight, but new Date('2026-10-09T00:00') is parsed as local midnight. In India the second is 2026-10-08T18:30:00Z, the previous day in UTC. That is the classic "off by one day" report bug. When you send a time as text, always include the offset in ISO 8601 form, such as 2026-10-09T14:30:00Z, and never send locale formats like 10/09/2026.

Rules that prevent most bugs

  1. Store instants in UTC: PostgreSQL timestamptz, a 64-bit epoch value, or ISO 8601 with Z.
  2. Convert at the edges: parse input to an instant early, and format to the user's zone late, ideally in the UI.
  3. Name your units, and never guess them inside your own systems.
  4. Use 64-bit integers, and test a date after 2038.
  5. Keep the user's IANA zone name for anything scheduled.
  6. Run NTP everywhere, because token validation and distributed locks assume clocks agree.
  7. Use a real time library, such as java.time, Python's zoneinfo, or Intl and Temporal in JavaScript where available, instead of arithmetic on seconds.

Then test the dates that break naive code: DST transitions in your users' zones, 29 February, year boundaries, and a user at UTC+14 such as Pacific/Kiritimati. Inject the clock so tests can freeze it. Bugs found there cost far less than ones found by customers.