What is a Unix timestamp, and why seconds vs milliseconds?
Unix Timestamps Explained
A Unix timestamp is the number of seconds that have elapsed since 1970-01-01 00:00:00 UTC, the Unix epoch, counting every day as exactly 86,400 seconds. It identifies an instant, not a wall-clock time, so it has no time zone: 1790409600 is 08:00 in London, 13:30 in Mumbai and 04:00 in New York at the same moment. The seconds-versus-milliseconds question exists because different platforms chose different units for the same count. C, Unix tools, PostgreSQL and most HTTP APIs use seconds; JavaScript and Java use milliseconds; Go and some databases go down to microseconds or nanoseconds. A 10-digit number today is almost certainly seconds and a 13-digit number milliseconds, and mixing them up gives you dates in January 1970 or in the year 58,705.
What POSIX actually defines
The definition is more precise, and more surprising, than "seconds since 1970". POSIX.1 (Base Definitions, "Seconds Since the Epoch") specifies it as arithmetic on the broken-down UTC time:
tm_sec + tm_min*60 + tm_hour*3600 + tm_yday*86400 +
(tm_year-70)*31536000 + ((tm_year-69)/4)*86400 -
((tm_year-1)/100)*86400 + ((tm_year+299)/400)*86400Reading it line by line:
tm_yearcounts from 1900, sotm_year-70is years since 1970, each worth 365 days (31,536,000 seconds).- The three integer divisions add a day for every Gregorian leap year since 1970: one per four years, minus one per century, plus one per 400 years.
tm_ydayis the zero-based day of the year, and each day contributes exactly 86,400 seconds.- There is no term for leap seconds. The standard states that the relationship between the actual time of day and the current value of seconds since the Epoch is unspecified.
So a Unix timestamp is not a count of physical seconds. It is a label for a UTC calendar time computed as if leap seconds did not exist. That is exactly what makes it convenient: you can compute a date from it with integer arithmetic, and every day divides evenly.
Seconds, milliseconds, microseconds, nanoseconds
| Platform | Current time | Unit | Digits today |
|---|---|---|---|
| Unix shell | date +%s |
seconds | 10 |
| C / POSIX | time(NULL) |
seconds | 10 |
| Python | time.time() |
seconds (float) | 10 before the point |
| Python | time.time_ns() |
nanoseconds (int) | 19 |
| JavaScript | Date.now() |
milliseconds | 13 |
| Java | System.currentTimeMillis() |
milliseconds | 13 |
| Java | Instant.now().getEpochSecond() |
seconds | 10 |
| Go | time.Now().Unix() / UnixMilli() / UnixNano() |
s / ms / ns | 10 / 13 / 19 |
| PostgreSQL | extract(epoch from now()) |
seconds (numeric, with fraction) | 10 before the point |
| MySQL | UNIX_TIMESTAMP() |
seconds | 10 |
Seconds values have had 10 digits since 2001-09-09 01:46:40 UTC (1,000,000,000) and will keep 10 digits until 2286-11-20. That makes digit count a reliable heuristic for current dates: 10 digits for seconds, 13 for milliseconds, 16 for microseconds, 19 for nanoseconds. The Unix Timestamp Converter uses this to detect the unit, counting only the digits before a decimal point — so a Python float such as 1790409600.5 is read as seconds — and tells you which one it chose. When the result falls outside 1900–2100, it adds a notice asking you to check the unit. The heuristic fails for values far from the present: a 12-digit seconds value (the year 5138 or later) reads as milliseconds from the 1970s.
The classic unit bug
Passing seconds to an API that expects milliseconds does not throw. It silently produces a date in January 1970:
new Date(1790409600) // 1970-01-21T17:20:09.600Z (wrong: treated as ms)
new Date(1790409600 * 1000) // 2026-09-26T08:00:00.000ZThe reverse mistake, milliseconds into a seconds API, lands tens of thousands of years in the future, which usually does throw or overflow. If you see a date in the first weeks of 1970, suspect a factor of 1,000.
Precision limits
JavaScript numbers are IEEE 754 doubles with 53 bits of integer precision (up to 9,007,199,254,740,991). Milliseconds since the epoch fit comfortably for hundreds of thousands of years. Nanoseconds do not: 1790409600123456789 becomes 1790409600123456800 as a JavaScript number. Keep nanosecond timestamps as strings or BigInt in JavaScript, and as 64-bit integers everywhere else.
The year 2038 problem
Where a timestamp is stored as a signed 32-bit integer, the largest value is 2,147,483,647. Try it in the converter:
2147483647The UTC and ISO lines of the result are:
UTC: Tue, 19 Jan 2038 03:14:07 UTC
ISO 8601: 2038-01-19T03:14:07.000ZOne second later, a signed 32-bit counter wraps to −2,147,483,648, which is 1901-12-13 20:45:52 UTC. Unsigned 32-bit counters last until 2106-02-07 06:28:15 UTC.
| Boundary | Value | UTC |
|---|---|---|
| Epoch | 0 | 1970-01-01 00:00:00 |
| One billion | 1,000,000,000 | 2001-09-09 01:46:40 |
| Signed 32-bit maximum | 2,147,483,647 | 2038-01-19 03:14:07 |
| Signed 32-bit minimum | −2,147,483,648 | 1901-12-13 20:45:52 |
| Unsigned 32-bit maximum | 4,294,967,295 | 2106-02-07 06:28:15 |
| Eleven digits begin | 10,000,000,000 | 2286-11-20 17:46:40 |
JavaScript Date maximum |
8.64 × 10^15 ms | +275760-09-13 |
64-bit operating systems have used a 64-bit time_t for years, so the risk today lives in specific places: 32-bit embedded devices, file formats and network protocols with 32-bit time fields, database columns declared as 32-bit integers, and MySQL's TIMESTAMP column type, whose documented range ends at 2038-01-19 03:14:07 UTC. Anything that stores expiry dates, mortgages, certificates or long-term schedules meets 2038 well before 2038. Audit schemas now: INT columns holding epoch seconds should be BIGINT, and TIMESTAMP columns that may hold far-future dates should be DATETIME in UTC.
Leap seconds
Since 1972, 27 leap seconds have been inserted into UTC to keep it within 0.9 seconds of the Earth's rotation, the last one at 2016-12-31 23:59:60 UTC. Unix time cannot represent 23:59:60. Python's calendar.timegm shows what happens:
calendar.timegm((2016, 12, 31, 23, 59, 60, 0, 0, 0)) # 1483228800
calendar.timegm((2017, 1, 1, 0, 0, 0, 0, 0, 0)) # 1483228800Two different UTC times, one timestamp. In practice operating systems either repeat a second, or "smear" the extra second across many hours so the clock never jumps, which is what large cloud providers do. The consequences for application code:
- Subtracting two timestamps gives elapsed time that can be off by the number of leap seconds in between. For almost all business purposes that is irrelevant; for scientific timing, use TAI or GPS time.
- Most date libraries, including JavaScript's
Date, reject23:59:60when parsing. - The General Conference on Weights and Measures resolved in 2022 to widen the permitted gap between UTC and the Earth's rotation by 2035, which ends the insertion of leap seconds, so this problem is closing rather than growing.
Time zones: a timestamp has none
A timestamp is the same number everywhere. Time zones matter only at the edges, when you convert to or from a wall-clock representation, and that is where the bugs are:
- Parsing a local time without a zone.
2026-09-26 14:05means different instants in different places. Converting it to a timestamp requires choosing a zone, and code that silently uses the server's zone produces different results on a laptop and in production. - Python's naive datetimes.
datetime.fromtimestamp(ts)returns local wall time with no zone attached.datetime.utcfromtimestamp(ts)returns UTC wall time, also with no zone attached, and is deprecated since Python 3.12. Usedatetime.fromtimestamp(ts, tz=timezone.utc). - SQL session zones. MySQL's
FROM_UNIXTIME()returns a value in the session time zone. PostgreSQL'sto_timestamp()returnstimestamptz, which is displayed in the session'sTimeZonesetting. Two clients with different settings see different text for the same row. - Daylight saving time. Local times in a DST transition either do not exist (the spring gap) or occur twice (the autumn overlap). Timestamps have neither problem, which is the strongest argument for storing them, or UTC
timestamptzvalues, rather than local times.
To see one instant in several zones, use the Time Zone Converter; to render it in a particular pattern, use the Date Formatter.
Conversions in each language
| Task | JavaScript | Python | Java | SQL |
|---|---|---|---|---|
| Now, seconds | Math.floor(Date.now() / 1000) |
int(time.time()) |
Instant.now().getEpochSecond() |
PostgreSQL extract(epoch from now())::bigint; MySQL UNIX_TIMESTAMP() |
| Seconds to date | new Date(s * 1000) |
datetime.fromtimestamp(s, tz=timezone.utc) |
Instant.ofEpochSecond(s) |
PostgreSQL to_timestamp(s); MySQL FROM_UNIXTIME(s) |
| Milliseconds to date | new Date(ms) |
datetime.fromtimestamp(ms / 1000, tz=timezone.utc) |
Instant.ofEpochMilli(ms) |
PostgreSQL to_timestamp(ms / 1000.0) |
| Date to seconds | Math.floor(d.getTime() / 1000) |
int(dt.timestamp()) |
instant.getEpochSecond() |
PostgreSQL extract(epoch from ts) |
One more trap: spreadsheets do not use Unix time. Excel stores dates as serial day numbers counted from the start of 1900 (complete with a nonexistent 29 February 1900, copied from Lotus 1-2-3 for compatibility), so converting a seconds value in cell A1 needs =A1/86400 + DATE(1970,1,1) formatted as a date, and the result is in UTC.
Practical rules
- Store instants as 64-bit integers or as a UTC-aware timestamp type, never as local time.
- Put the unit in the name:
created_at_ms,expires_at_s. It costs nothing and prevents the factor-of-1,000 bug. - Serialise for humans and APIs as ISO 8601 / RFC 3339 with an explicit offset, and keep epoch numbers for storage and arithmetic.
- Convert to a zone only when displaying, and name the zone with an IANA identifier such as
Asia/Kolkata, not an abbreviation such as IST, which means different offsets in India, Ireland and Israel.