Unix Timestamps Explained: Seconds vs Milliseconds, Time Zones and the Year 2038 Problem
A Unix timestamp is the number of seconds that have elapsed since 1970-01-01 00:00:00 UTC, the "epoch". That one sentence covers most of what you need, but the details around it cause real production bugs: dates in 1970, events shifted by five and a half hours, and columns that will overflow in January 2038.
This guide covers how to identify the unit of a timestamp, why a timestamp has no time zone, where the 2038 limit still lurks, and the exact calls for getting and converting epoch time in the languages and databases most teams use.
Seconds, milliseconds, or microseconds: count the digits
Different systems use different units, and nothing in the number itself says which one it is. The fastest check is digit count. For any date between September 2001 and November 2286, a timestamp in seconds has exactly 10 digits. Milliseconds have 13, microseconds 16, and nanoseconds 19.
For reference, 1700000000 is 2023-11-14 22:13:20 UTC, and midnight UTC on 3 November 2026 is 1793664000. If a value you are debugging is in that ballpark with 10 digits, it is seconds.
- 10 digits (1793664000): seconds. Unix tools, PHP time(), Python int(time.time()), most REST APIs, JWT exp and iat claims.
- 13 digits (1793664000000): milliseconds. JavaScript Date.now(), Java System.currentTimeMillis(), many analytics and logging tools.
- 16 digits: microseconds. PostgreSQL internals, some tracing systems, Python time.time_ns() // 1000.
- 19 digits: nanoseconds. Go time.Now().UnixNano(), Python time.time_ns(), OpenTelemetry.
The two most common unit bugs
Bug one: passing seconds where milliseconds are expected. JavaScript new Date(1700000000) does not throw. It treats the value as milliseconds and returns 1970-01-20T16:13:20Z. Any date that shows up as mid-January 1970 is almost certainly this bug. Multiply by 1000.
Bug two: the reverse. Passing a 13-digit millisecond value to a function that expects seconds produces a date tens of thousands of years in the future, or an out-of-range error. Python datetime.fromtimestamp(1700000000000) raises "year is out of range".
The durable fix is to name the unit in the field or variable, such as createdAtMs or expires_at_s, and to convert at the boundary where data enters your system rather than deep inside business logic.
Timestamps have no time zone
A Unix timestamp identifies an instant. 1793664000 is the same moment in Mumbai, London, and San Francisco. Time zones only appear when you format that instant for a human, so "convert this timestamp to IST" really means "display this instant using the Asia/Kolkata offset".
Most time-zone bugs come from mixing the two layers. The usual culprit is a function that reads or writes local time without saying so. In Python, datetime.fromtimestamp(ts) returns local time of the server; pass tz=timezone.utc to be explicit. datetime.utcfromtimestamp() is deprecated as of Python 3.12 because it returns a naive datetime that is easy to misinterpret. In JavaScript, getHours() is local and getUTCHours() is UTC; toISOString() always outputs UTC with a Z suffix.
Store and transmit timestamps or UTC ISO 8601 strings. Convert to a named zone (Asia/Kolkata, America/New_York) only for display, and use zone names rather than fixed offsets so daylight saving transitions are handled for regions that observe them.
Leap seconds and what Unix time ignores
Unix time assumes every day has exactly 86,400 seconds. Leap seconds are not counted, so Unix time is not a true count of elapsed SI seconds since 1970. In practice this rarely matters for application code: subtracting two timestamps gives the elapsed time to within the handful of leap seconds that have been inserted, and the international timekeeping community has resolved to stop adding leap seconds by 2035.
Where it can matter is in high-precision distributed systems. Large providers handle leap seconds by "smearing" the extra second across many hours, which keeps clocks monotonic. If you compare timestamps from systems with different leap-second handling, expect sub-second disagreements around those dates.
The Year 2038 problem is still around
A signed 32-bit integer can hold values up to 2,147,483,647. As a Unix timestamp in seconds, that is 2038-01-19 03:14:07 UTC. One second later, a signed 32-bit counter wraps to a large negative number, which most code interprets as December 1901.
Modern 64-bit operating systems and languages use 64-bit time values, so the core clock is fine. The risk is in storage and interfaces that still use 32 bits: database columns, binary file formats, embedded firmware, and APIs that declare int32 fields. MySQL TIMESTAMP columns, for example, have a documented upper limit of 2038-01-19 03:14:07 UTC; use DATETIME or a BIGINT epoch column for dates beyond that. Long-dated data reaches 2038 today: a 15-year loan, a 20-year warranty, or a certificate expiry date entered now will already cross it.
An easy audit: search your schemas and protobuf or Thrift definitions for 32-bit integer fields named like *_at, *_time, or expires, and try inserting 2147483648 in a test environment.
Getting and converting epoch time in common languages
These are the calls that return the current time and convert a timestamp back to a readable UTC date. Note the units: several languages default to milliseconds.
# Bash (GNU date; on macOS use: date -u -r 1793664000)
date +%s
date -u -d @1793664000
// JavaScript (milliseconds by default)
Math.floor(Date.now() / 1000)
new Date(1793664000 * 1000).toISOString()
# Python 3
import time, datetime
int(time.time())
datetime.datetime.fromtimestamp(1793664000, tz=datetime.timezone.utc)
// Java 8+
Instant.now().getEpochSecond();
Instant.ofEpochSecond(1793664000L);
// Go
time.Now().Unix()
time.Unix(1793664000, 0).UTC()
// PHP
time();
gmdate('c', 1793664000);
-- PostgreSQL
SELECT EXTRACT(EPOCH FROM now())::bigint;
SELECT to_timestamp(1793664000);
-- MySQL
SELECT UNIX_TIMESTAMP();
SELECT FROM_UNIXTIME(1793664000);A checklist for working with epoch time
When a date looks wrong, paste the raw value into the Neemtools Epoch Time Converter: it shows the UTC and local interpretation side by side and makes a seconds-versus-milliseconds mix-up obvious immediately. For ongoing work, these habits prevent most incidents:
- Put the unit in the name: createdAtMs, expires_at_s.
- Store UTC instants (timestamps, or ISO 8601 with Z); convert to a named time zone only in the UI.
- Use 64-bit integers or native date-time types for anything that could exceed 2038.
- Pass an explicit time zone to every formatting and parsing call; never rely on the server default.
- If you generate IDs, consider UUIDv7: it embeds a 48-bit millisecond Unix timestamp, so IDs sort by creation time.