Unix Timestamps: Seconds vs Milliseconds, and How to Tell Which You Have

By ··12 min read·timestampsunixdatetime

How to tell a seconds timestamp from a milliseconds one by counting digits, convert between them in JavaScript, Python, Java, Go, and SQL, and avoid the 1970 and far-future bugs that come from guessing the unit wrong.

A Unix timestamp is one of the simplest ideas in computing: count the number of seconds that have elapsed since midnight UTC on 1 January 1970, and you have an unambiguous, time-zone-free instant. It is the format you find in HTTP headers, log lines, JWT exp claims, database columns, and almost every API that needs to express "when did this happen." And yet timestamps are a reliable source of bugs, almost always for one of three reasons: the unit is ambiguous — seconds vs milliseconds — the epoch is misread, or a time zone sneaks in where it should not. This article covers how to tell the units apart, how to convert between them in JavaScript, Python, Java, Go, and SQL, and the handful of classic failure modes worth knowing by heart.

Seconds or milliseconds?

The original Unix convention is seconds since the epoch. But JavaScript's Date.now() returns milliseconds, and so do many JVM and .NET APIs. The two differ by a factor of 1000, which means a value interpreted in the wrong unit is not slightly off — it is off by three orders of magnitude. Read a millisecond timestamp as seconds and a 2026 date becomes the year 58505; read a second timestamp as milliseconds and it collapses to a few weeks after the epoch, in January 1970.

There is no bit in the number that tells you which unit it is, so you have to infer it from magnitude. For any date in the current era, the digit count is the tell:

DigitsUnitExample (2026-07-15 UTC)CoversTypical sources
9seconds9999999991973 – Sep 2001old data, archives
10seconds1784073600Sep 2001 – Nov 2286C time(), Python time.time(), JWT exp, most APIs
13milliseconds17840736000002001 – 2286JS Date.now(), Java currentTimeMillis(), Kafka, MongoDB
16microseconds17840736000000002001 – 2286PostgreSQL internals, tracing systems
19nanoseconds17840736000000000002001 – 2262Go UnixNano(), ClickHouse DateTime64(9), OpenTelemetry

This is exactly the heuristic the Timestamp Converter uses: it looks at the size of the number, guesses the unit, and shows you the resulting date so you can sanity-check it at a glance. If the date comes out as 1970 or some absurd far-future year, the unit guess was wrong — and that is almost always the bug.

Detecting the unit programmatically

When your code receives timestamps from a source that is inconsistent about units — a third-party webhook, a CSV export, a log mixture — you can apply the same magnitude heuristic in code. Any value below about 1011 is seconds (that threshold is the year 5138, safely beyond any plausible seconds value), anything between 1011 and 1014 is milliseconds:

function toMillis(ts) {
  if (ts < 1e11) return ts * 1000;  // seconds
  if (ts < 1e14) return ts;         // already milliseconds
  if (ts < 1e17) return ts / 1e3;   // microseconds
  return ts / 1e6;                  // nanoseconds
}

The same idea in Python:

def to_seconds(ts: float) -> float:
    if ts < 1e11:   # seconds
        return ts
    if ts < 1e14:   # milliseconds
        return ts / 1e3
    if ts < 1e17:   # microseconds
        return ts / 1e6
    return ts / 1e9  # nanoseconds

Guessing is a last resort, not a design. Pydantic did exactly this kind of automatic seconds-vs-milliseconds inference and it caused enough surprise that users filed issues asking for it to be removed. If you control the contract, document the unit explicitly (or use ISO-8601 strings) and reserve the heuristic for data you do not control.

Converting in practice, language by language

JavaScript

JavaScript is milliseconds-native: Date.now() and new Date(n) both speak milliseconds. The classic bug is passing a seconds value straight into the constructor:

Date.now();                      // 1784073600000 (milliseconds)
Math.floor(Date.now() / 1000);   // 1784073600 (seconds, e.g. for a JWT)

const ts = 1784073600;           // seconds from an API
new Date(ts * 1000).toISOString(); // "2026-07-15T00:00:00.000Z" ✓
new Date(ts).toISOString();        // "1970-01-21T15:34:33.600Z" ✗ (treated as ms)

Python

Python is seconds-native, with floats carrying sub-second precision. Feed datetime.fromtimestamp() a milliseconds value and, depending on the platform, you get an OverflowError or a date in the far future:

import time
from datetime import datetime, timezone

time.time()      # 1784073600.123456 (float seconds)
time.time_ns()   # 1784073600123456000 (nanoseconds)

datetime.fromtimestamp(1784073600, tz=timezone.utc)
# datetime(2026, 7, 15, 0, 0, tzinfo=timezone.utc)

# A milliseconds input must be divided first:
datetime.fromtimestamp(1784073600000 / 1000, tz=timezone.utc)

Always pass tz=timezone.utc; the naive variant applies the machine's local zone, which is how "works on my laptop, wrong on the server" bugs are born.

Java / Kotlin

The JVM is milliseconds-native historically, but java.time.Instant makes the unit explicit in the method name — use those names and the ambiguity disappears:

System.currentTimeMillis();       // 1784073600000
Instant.now().getEpochSecond();   // 1784073600

Instant.ofEpochSecond(1784073600L);    // 2026-07-15T00:00:00Z
Instant.ofEpochMilli(1784073600000L);  // 2026-07-15T00:00:00Z

Go

Go returns seconds from Unix() and has explicit constructors and accessors for every unit since Go 1.17:

time.Now().Unix()       // 1784073600 (seconds)
time.Now().UnixMilli()  // 1784073600000
time.Now().UnixNano()   // 1784073600000000000

time.Unix(1784073600, 0)          // from seconds
time.UnixMilli(1784073600000)     // from milliseconds

SQL: PostgreSQL and MySQL

Database conversion functions are seconds-only, and they do not warn you when the input is actually milliseconds — PostgreSQL will happily produce a date fifty millennia away:

-- PostgreSQL
SELECT to_timestamp(1784073600);          -- 2026-07-15 00:00:00+00 ✓
SELECT to_timestamp(1784073600000);       -- year 58505 ✗ (ms passed as s)
SELECT to_timestamp(1784073600000 / 1000.0);  -- divide first ✓
SELECT extract(epoch FROM now());         -- current time as float seconds

-- MySQL
SELECT from_unixtime(1784073600);         -- 2026-07-15 00:00:00
SELECT unix_timestamp();                  -- current seconds
SELECT from_unixtime(1784073600000);      -- NULL (out of range)

Note the asymmetry: MySQL returns NULL for an out-of-range input, which at least fails loudly in most pipelines. PostgreSQL's silent far-future date can sit in a table for months before anyone notices a "58505" in a report.

Which unit do real systems use?

Once you start looking, the split is fairly predictable: protocol-level standards and Unix-lineage tools count seconds, while JavaScript-adjacent and JVM-adjacent ecosystems count milliseconds. A field guide to the ones you are most likely to meet:

  • JWT claims (exp, iat, nbf) — seconds. RFC 7519 defines them as NumericDate, "seconds from 1970-01-01T00:00:00Z UTC." Signing a token with Date.now() un-divided is one of the most common JWT bugs; the token claims to expire around the year 58505, and strict validators reject it.
  • Stripe, Twilio, and most REST APIs — seconds, in fields like created and in webhook signatures.
  • Kafka message timestamps — milliseconds, matching its JVM heritage.
  • MongoDB — milliseconds inside Date values (BSON stores a 64-bit millisecond count).
  • Prometheus — floating-point seconds in the data model, milliseconds in some HTTP API parameters. Even single ecosystems mix units, which is why the digit check stays useful.
  • Redis — both, explicitly: EXPIRE takes seconds, PEXPIRE takes milliseconds. The leading P is the only difference.
  • HTTP — neither, mostly: Date and Last-Modified headers use RFC 7231 date strings, and Cache-Control: max-age is relative seconds, not an epoch. But X-RateLimit-Reset-style headers are usually epoch seconds.

The pattern worth internalizing: never assume the unit from one field and apply it to another, even within the same API. Check the docs field by field, and when the docs are silent, count the digits.

Epoch numbers or ISO-8601 strings?

Epoch timestamps are not the only way to serialize an instant, and they are not always the best one. An ISO-8601 string like 2026-07-15T00:00:00Z is self-describing: it cannot be misread by a factor of 1000, it is human-readable in a log line without a converter, and it sorts lexicographically when zero-padded and kept in UTC. The epoch integer wins on compactness, on arithmetic (subtracting two epochs is a duration; subtracting two strings is a parsing exercise), and on being immune to the parsing quirks that plague date strings — no MM/DD-vs-DD/MM ambiguity, no missing-offset guesswork.

A reasonable rule: use epoch integers inside systems (databases, queues, caches, tokens) where machines produce and consume them, and prefer ISO-8601 with an explicit offset at human boundaries — API responses developers read, log lines, config files. Whichever you pick, the two rules that prevent nearly all grief are the same: always UTC, and one unit per system, stated in the field name (created_at_ms beats timestamp) or the schema docs.

The epoch is UTC — the display is not

A Unix timestamp has no time zone. It is a count of seconds from a fixed instant in UTC, full stop. Time zones only enter the picture when you convert that instant into a human-readable calendar date and clock time, because "3:00" means different absolute instants in Tokyo, London, and New York.

Most timestamp bugs are really display bugs. The stored number is correct, but somewhere in the pipeline a library formatted it in the server's local zone instead of UTC, or parsed a local date string as if it were UTC. The symptom is an event that appears to happen a few hours before or after it actually did — and the offset conveniently matches someone's time-zone difference from UTC.

The defensive habit: store and transmit instants as UTC epoch values (or ISO-8601 strings with an explicit Z or offset), and convert to local time only at the very edge, when you render for a human. Never do arithmetic on local wall-clock times.

Off-by-one-hour: daylight saving time

Even when you keep everything in UTC internally, DST bites at the conversion boundary. Adding "one day" to a local timestamp is not always adding 86,400 seconds: on the days a region springs forward or falls back, a local day is 23 or 25 hours long. If you compute "tomorrow at 9am" by adding 86,400 seconds to a UTC instant and then formatting locally, you will be an hour off twice a year.

The fix is to do calendar arithmetic with a real date library that is zone-aware (one backed by the IANA tz database), rather than treating a day as a fixed number of seconds. Epoch math is correct for measuringdurations; it is wrong for reasoning about calendars.

The year 2038 problem

Systems that store the timestamp in a signed 32-bit integer overflow on 19 January 2038, when the second count exceeds 2,147,483,647. After that the value wraps to a negative number and the date jumps back to 1901. This is not hypothetical — it still lurks in old C code, embedded firmware, and a few database column types. The remedy is a 64-bit integer, which pushes the overflow roughly 292 billion years out. If you control the schema, store epoch values in a 64-bit column and never think about it again.

Nanosecond timestamps hit the 64-bit ceiling much sooner: a signed 64-bit count of nanoseconds overflows in the year 2262. That is why systems that need both range and precision (databases, columnar formats) usually store seconds plus a separate fractional field instead of one big nanosecond integer.

Leap seconds, briefly

Unix time deliberately ignores leap seconds: it assumes every day is exactly 86,400 seconds. Real UTC occasionally inserts a leap second to stay aligned with the Earth's rotation. For almost all application code this discrepancy is invisible and you should not try to correct for it — the operating system and NTP smear the difference for you. It matters only for high-precision scientific timing, and if you are in that world you already know it.

A practical checklist

  • Confirm the unit before you convert. Ten digits is seconds; thirteen is milliseconds. If the date looks like 1970, you guessed wrong.
  • Store and pass instants in UTC. Convert to local time only when rendering for a person.
  • Use a zone-aware date library for calendar arithmetic; use plain epoch math only for durations.
  • Use 64-bit storage for timestamps to sidestep the 2038 problem.
  • When an API is inconsistent, normalize at the boundary with a magnitude check, and log when the heuristic fires so silent guessing never becomes load-bearing.

When you are staring at a raw epoch value in a log and need to know what instant it actually represents, paste it into the Timestamp Converter — it detects the unit, shows both the UTC and local rendering, and lets you go the other way to turn a date back into an epoch for a query or a test fixture.

Frequently Asked Questions