Unix timestamps: seconds vs milliseconds, the 2038 problem and time zones
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:
| Unit | Digits today | Typical sources |
|---|---|---|
| Seconds | 10 | Unix tools, JWT exp/iat, PostgreSQL extract(epoch from …), most REST APIs |
| Milliseconds | 13 | JavaScript Date.now(), Java System.currentTimeMillis(), Elasticsearch, Kafka |
| Microseconds | 16 | PostgreSQL internals, BigQuery, some log pipelines |
| Nanoseconds | 19 | Go 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
TIMESTAMPcolumns are limited to 2038-01-19 03:14:07 UTC.DATETIMEor aBIGINTepoch column does not have this limit. - Code that casts, such as Java
(int) (System.currentTimeMillis() / 1000)or Cint32_tfields 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
- Store instants in UTC: PostgreSQL
timestamptz, a 64-bit epoch value, or ISO 8601 withZ. - Convert at the edges: parse input to an instant early, and format to the user's zone late, ideally in the UI.
- Name your units, and never guess them inside your own systems.
- Use 64-bit integers, and test a date after 2038.
- Keep the user's IANA zone name for anything scheduled.
- Run NTP everywhere, because token validation and distributed locks assume clocks agree.
- Use a real time library, such as
java.time, Python'szoneinfo, orIntland 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.