Skip to main content
Back to all posts

Web Development

Unix Timestamps and Epoch Time: The Complete Developer Guide

A Unix timestamp counts seconds since 1970-01-01T00:00:00 UTC, ignoring leap seconds. Learn seconds vs milliseconds, the Year 2038 problem, and conversion.

MM H Tawfik11 min read

Time is the one data type every system gets wrong eventually. A log line lands an hour off. A "created yesterday" badge shows the wrong day for users in Tokyo. A subscription renews a day early in March. Nearly all of these trace back to a single, deceptively simple number: the Unix timestamp.

This guide is the working reference. We cover exactly what an epoch timestamp is, the seconds-versus-milliseconds bug that bites every JavaScript developer, the Year 2038 ceiling baked into 32-bit systems, why leap seconds are deliberately ignored, and how to convert cleanly in JavaScript, Python, and SQL without introducing the off-by-one-day errors that timezone math is famous for.

What is a Unix timestamp?

A Unix timestamp is the number of seconds elapsed since the Unix epoch1970-01-01T00:00:00 UTCnot counting leap seconds. It is a single integer that represents one exact instant in time. Because it is anchored to UTC, it is timezone-agnostic: the same timestamp refers to the same moment everywhere on Earth. Only the display changes per timezone, never the underlying number.

That last point is the whole reason timestamps exist. The string "2026-06-02 15:00" is ambiguous — 3 PM where? The integer 1780412400 is not. It is one instant, and a clock in London, Tokyo, and New York will each render it in their own local time without changing what it means.

The epoch origin (0) was chosen by the early Unix designers at Bell Labs in the early 1970s as a convenient recent zero point. POSIX formalised it. The authoritative definition lives in the POSIX / Open Group Base Specifications, "Seconds Since the Epoch", which defines the value as a count of seconds computed by a formula that treats every day as exactly 86,400 seconds. That formula is the source of both the timezone-independence and the leap-second quirk we cover below.

Seconds vs milliseconds: the 1000x bug

The single most common timestamp bug is a unit mismatch. Most backends, databases, and the POSIX time() call use seconds. JavaScript uses milliseconds. Date.now() and new Date().getTime() both return milliseconds since the epoch. Feed a seconds value into a JavaScript Date, or send a milliseconds value to a backend expecting seconds, and you are off by a factor of 1000 — which lands you in 1970 or in the year 56,000.

const seconds = 1780412400;          // from a typical backend / Unix `date +%s`

// WRONG — Date expects milliseconds, so this is interpreted as ~1970
new Date(seconds);                   // Wed Jan 21 1970 06:33:32 GMT...

// RIGHT — multiply seconds by 1000
new Date(seconds * 1000);            // Tue Jun 02 2026 ...

// Going the other way: convert JS milliseconds back to Unix seconds
const unixSeconds = Math.floor(Date.now() / 1000);

The quickest sanity check: a current Unix timestamp in seconds is 10 digits (it hit 10 digits in 2001 and stays there until 2286). In milliseconds it is 13 digits. If your number has 13 digits and you treated it as seconds, you just scheduled something for the year ~45,000. Always Math.floor(ms / 1000) to go down to seconds, and always * 1000 to bring seconds up into a JavaScript Date. If you ever need to eyeball a value, paste it into the timestamp converter — it auto-detects seconds vs milliseconds and shows both the UTC and local rendering side by side.

The Year 2038 problem

Many systems store the timestamp in a signed 32-bit integer. A signed 32-bit value maxes out at 2_147_483_647. As a count of seconds since the epoch, that maximum is reached at 03:14:07 UTC on Tuesday, 19 January 2038. One second later, the integer overflows and wraps to −2_147_483_648, which is interpreted as 20:45:52 UTC on 13 December 1901. This is the Year 2038 problem (sometimes "Y2038" or the "epochalypse").

 2147483647  →  2038-01-19T03:14:07Z   (last representable second, 32-bit signed)
-2147483648  →  1901-12-13T20:45:52Z   (after overflow — wraps to the past)

The fix is to store time in a 64-bit signed integer, which pushes the ceiling out roughly 292 billion years — comfortably past the heat death of the sun. Modern Linux kernels, 64-bit C libraries, and essentially every current language runtime already use 64-bit time. JavaScript was never exposed to this particular limit: it stores time as a 64-bit IEEE 754 double in milliseconds, safe to ±8,640,000,000,000,000 ms (about ±273,000 years). The risk today lives in legacy C code, old embedded firmware, 32-bit time_t columns, and protocols that hardcoded a 32-bit field. See the Wikipedia article on the Year 2038 problem for the full inventory of affected systems.

The practical takeaway for new code: never define a timestamp column as a 32-bit integer. Use BIGINT (or your platform's 64-bit time type), and you will never meet this bug.

Leap seconds: why Unix time ignores them

Unix time deliberately pretends leap seconds do not exist. Every day in Unix time is exactly 86,400 seconds, even though a handful of real-world days have had an extra second inserted to keep atomic clocks aligned with Earth's slightly irregular rotation. The International Earth Rotation and Reference Systems Service (IERS) has announced 27 leap seconds since 1972, all positive. Coordinated Universal Time (UTC) honours them; Unix time does not.

The consequence is that a Unix timestamp is not a true count of elapsed SI seconds since 1970 — it is short by the number of leap seconds inserted in that span. During a leap second, Unix time either repeats a value or jumps, depending on the system's handling (Google and others use "leap smearing" to spread the extra second across a day and avoid a discontinuity). For 99.99% of applications this is irrelevant: you are scheduling meetings, not navigating spacecraft.

This is also why UTC is the storage standard. UTC is the global reference timeline; it has no daylight-saving rules and no fixed offset to chase. You store the instant in UTC (as a Unix timestamp or an ISO 8601 UTC string), and you convert to a user's local timezone only at the moment of display. Storing local time is the root cause of an entire genre of bugs.

Converting epoch time to a human date and back

The two operations you need are "epoch → date" and "date → epoch." Here they are in the three environments you will hit most often. Note the ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ, the trailing Z meaning UTC) — it is the unambiguous interchange format and what every API should emit.

JavaScript

// Epoch (seconds) → ISO 8601 UTC string
const ts = 1780412400;
new Date(ts * 1000).toISOString();        // "2026-06-02T15:00:00.000Z"

// ISO 8601 string → epoch seconds
Math.floor(Date.parse("2026-06-02T15:00:00Z") / 1000);  // 1780412400

// Current Unix timestamp in seconds
Math.floor(Date.now() / 1000);

// Format in a specific timezone WITHOUT shifting the stored instant
new Intl.DateTimeFormat("en-US", {
  timeZone: "Asia/Tokyo",
  dateStyle: "medium",
  timeStyle: "short",
}).format(new Date(ts * 1000));           // "Jun 3, 2026, 12:00 AM"

Use Intl.DateTimeFormat (or the newer Temporal API as it lands) for display. Avoid toLocaleString without an explicit timeZone — it silently uses the runtime's local zone, which differs between your laptop and a UTC server.

Python

from datetime import datetime, timezone

ts = 1780412400

# Epoch (seconds) → aware UTC datetime → ISO 8601
datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()   # '2026-06-02T15:00:00+00:00'

# ISO 8601 string → epoch seconds
datetime.fromisoformat("2026-06-02T15:00:00+00:00").timestamp()  # 1780412400.0

# Current Unix timestamp
int(datetime.now(timezone.utc).timestamp())

Always pass tz=timezone.utc. The naked datetime.fromtimestamp(ts) and datetime.utcnow() produce naive datetimes that drag in the host's local zone (or none at all), which is the Python equivalent of the JavaScript local-time trap.

SQL

-- PostgreSQL: epoch seconds → timestamp, and back
SELECT to_timestamp(1780412400);                     -- 2026-06-02 15:00:00+00
SELECT EXTRACT(EPOCH FROM TIMESTAMPTZ '2026-06-02 15:00:00+00');  -- 1780412400

-- MySQL
SELECT FROM_UNIXTIME(1780412400);                    -- 2026-06-02 15:00:00 (server tz!)
SELECT UNIX_TIMESTAMP('2026-06-02 15:00:00');        -- 1780412400

Store the column as TIMESTAMPTZ in Postgres or TIMESTAMP in MySQL (which it stores in UTC internally) — not the bare TIMESTAMP WITHOUT TIME ZONE, which records wall-clock digits with no zone and forces every reader to guess.

Time zones: store UTC, convert at display

The golden rule: store every instant in UTC; convert to a local zone only when you render it to a human. A timezone is not a fixed offset — it is a set of rules (current offset, daylight-saving transitions, and a history of past changes) keyed by a name like America/New_York. Those rules live in the IANA Time Zone Database (the "tz database" or "Olson database"), maintained at iana.org/time-zones and updated several times a year as governments change their DST policies. Your runtime ships a copy; keep it current.

The reason offsets are not enough is daylight saving time, which creates two annual anomalies:

  • Spring-forward gap: clocks jump from 01:59 to 03:00, so a local time like 02:30 simply does not exist on that date. Constructing it is undefined behaviour.
  • Fall-back overlap: clocks fall from 01:59 back to 01:00, so a local time like 01:30 happens twice — it is ambiguous without an offset.

Store UTC and these anomalies never touch your data; they only affect the final formatting step, which Intl.DateTimeFormat and a current tz database handle correctly. To experiment with how one instant looks across zones, use the timezone converter.

Common timestamp bugs (and how to avoid them)

These four cover the overwhelming majority of date incidents in production:

  1. Milliseconds / seconds confusion (the 1000x bug). Sending JS milliseconds to a seconds API, or vice versa. Fix: standardise the unit at every boundary, document it, and remember the 10-digit (seconds) vs 13-digit (milliseconds) heuristic.
  2. Local-vs-UTC mixing. Reading a timestamp as local on one machine and UTC on another. Fix: store UTC end to end; make every Date/datetime timezone-aware; convert only at display.
  3. Naive date math across a DST boundary. Adding 86400 seconds to "9 AM today" and expecting "9 AM tomorrow" — but a DST transition makes that day 23 or 25 hours long, so you land an hour off. Fix: do calendar arithmetic (add "1 day") with a timezone-aware library, not raw second arithmetic, when wall-clock semantics matter.
  4. Off-by-one-day from timezone. Formatting a UTC timestamp in a user's local zone can roll the calendar date forward or back — 2026-06-02T23:00:00Z is already June 3rd in Tokyo and still June 2nd in New York. Fix: always format with an explicit timeZone; never extract the date portion of a UTC string and assume it is the user's date.

For human-facing duration questions, reach for purpose-built tools rather than hand-rolling the math: the date difference calculator counts days/months/years between two dates, and the age calculator does the same anchored to today, both handling the calendar edge cases for you.

Reference table: notable epoch values

A few timestamps worth memorising or bookmarking. All renderings are in UTC.

| Unix timestamp (seconds) | UTC date / time | Why it matters | |---|---|---| | 0 | 1970-01-01T00:00:00Z | The Unix epoch — the origin point | | 1000000000 | 2001-09-09T01:46:40Z | First 10-digit timestamp ("billennium"; widely celebrated) | | 1234567890 | 2009-02-13T23:31:30Z | Famous sequential-digit timestamp | | 1780412400 | 2026-06-02T15:00:00Z | A representative "now"-ish value | | 2147483647 | 2038-01-19T03:14:07Z | Last value a signed 32-bit time_t can hold (Y2038) | | -2147483648 | 1901-12-13T20:45:52Z | The value 32-bit time wraps to after overflow | | 253402300799 | 9999-12-31T23:59:59Z | Max date many databases / ISO 8601 parsers accept |

Negative timestamps are valid and represent instants before 1970 — -1 is 1969-12-31T23:59:59Z. Many languages handle them; some legacy systems and unsigned-int columns do not, so test before relying on pre-epoch dates.

TL;DR

A Unix timestamp is the number of seconds since 1970-01-01T00:00:00 UTC, ignoring leap seconds — one integer, one instant, the same everywhere on Earth. JavaScript uses milliseconds while most backends use seconds, so multiply or divide by 1000 at every boundary (10 digits = seconds, 13 digits = milliseconds). Store time in 64-bit columns to dodge the Year 2038 overflow, store everything in UTC, and convert to local zones — using the IANA tz database and an explicit timeZone — only at the moment you show it to a human. Do that, and the entire class of "the date is one off" bugs disappears.

Keep the right tools at hand: convert any value with the timestamp converter, check how an instant renders across zones with the timezone converter, measure spans with the date difference calculator and age calculator, or browse the full set of developer tools.