Telling Seconds from Milliseconds in Epoch Values

The reliable way to tell a seconds epoch from a milliseconds epoch is to know the source and convert at the boundary; digit-counting is only a last-resort heuristic. Part of Unix Timestamps & Epoch Conversion.

Why This Scenario Is Tricky

Seconds-versus-milliseconds is the most common epoch bug precisely because both units are "Unix timestamps" and both produce plausible-looking integers, so nothing about a bare number announces which one it is. A seconds timestamp for a recent date is a 10-digit number; the same moment in milliseconds is 13 digits. They differ only by a factor of 1000, and a factor-of-1000 error produces a date that is either decades early (seconds read as milliseconds lands in 1970) or millennia late (milliseconds read as seconds), depending on the direction. Because the failure is a wrong value rather than an error, it slips through unless something downstream sanity-checks the date.

The core difficulty is that the unit is metadata, not part of the value, and different systems made different choices: JavaScript, Java, and many APIs use milliseconds, while Unix tooling, Postgres extract(epoch …), Go's Unix(), and countless log formats use seconds. When data crosses between these worlds, the unit has to be declared and converted at the boundary, because there is no getting it back from the number afterward. Treating the unit as something you know and pin down, rather than something you infer, is what keeps the two straight.

A raw integer carries no unit. 1700000000 and 1700000000000 both look like "a timestamp", but one is seconds and one is milliseconds, and JavaScript's new Date() blindly treats either as milliseconds. The popular shortcut — "10 digits means seconds, 13 means milliseconds" — works today but is not a contract. It quietly breaks on real data:

The correct mental model: the unit is metadata about the source, not something recoverable from the value. Capture it once, at the point of ingestion, and carry it explicitly.

Seconds versus milliseconds scale comparisonTwo stacked scales, one labelled seconds and one milliseconds, showing the same instant at different magnitudes and a caution that digit count is not a reliable unit signal.Same instant, two magnitudesseconds (×1)1700000000 — 10 digitsmilliseconds (×1000)1700000000000 — 13 digitsDigit count distinguishes them only within today's era — prefer the known source unit.

Minimal Working Solution

When you control the source, convert explicitly and stop there:

// You KNOW this column is seconds (e.g. Postgres extract(epoch from ts)).
const seconds = 1700000000;
const date = new Date(seconds * 1000); // declare the unit by scaling at the boundary

When you genuinely cannot know the unit, use a magnitude heuristic — but anchor it to a date window, not a digit count, and document it as a fallback:

// Fallback only: assume any value below the year-2001-in-seconds threshold,
// when read as MILLISECONDS, would predate ~1970 absurdly — so treat large
// values as ms and small values as seconds, using a fixed cutover instant.
const MS_CUTOVER = 1e11; // 1970-01-01 + 1e11 ms ≈ 2001; values above are almost surely ms
function coerceToMs(value: number): number {
  return value < MS_CUTOVER ? value * 1000 : value;
}

Seconds versus milliseconds by magnitudeTen-digit epoch values are seconds; thirteen-digit values are millisecondsDigit count tells the unit171051300010 digits → SECONDS171051300000013 digits → MILLISECONDSAround 2001-09-09, epoch seconds crossed 10^9; ms values are ~1000× larger.

Full Production Version

The disciplined approach is to normalize at every boundary to one internal unit and to declare the incoming unit explicitly rather than sniffing it. When you know a column is seconds — because you know the database function that produced it — scale by 1000 at read time with a clearly-named conversion, and when you know it is milliseconds, pass it through. Doing this at ingestion means the rest of the system operates on a single, known representation and never has to re-decide. Naming the conversion (secondsToMs, msToDate) makes the unit visible in code review, where a bare * 1000 or its absence is easy to overlook.

A magnitude-based fallback — treating values below a chosen threshold as seconds and above it as milliseconds — is sometimes used when a feed genuinely mixes units, but it is a last resort with a real failure window, so it must be documented and its cutover instant chosen deliberately. It works only because a seconds timestamp and a milliseconds timestamp for realistic dates fall in non-overlapping ranges around a fixed boundary, and it silently breaks for dates outside the assumed range. Prefer explicit unit metadata from the source; reach for the heuristic only when the source truly cannot tell you, and log when it fires so a wrong guess is visible rather than silent.

The robust design makes the unit an explicit parameter and treats the heuristic as an opt-in fallback that logs when it has to guess. It validates the result lands inside a sane date window so a misclassification fails loudly.

import { Temporal } from '@js-temporal/polyfill';

type EpochUnit = 'seconds' | 'milliseconds' | 'auto';

const MS_CUTOVER = 1e11; // ~year 2001 in ms; below this, a ms value would be pre-2001
const MIN_MS = Temporal.Instant.from('2000-01-01T00:00:00Z').epochMilliseconds;
const MAX_MS = Temporal.Instant.from('2100-01-01T00:00:00Z').epochMilliseconds;

function toInstant(value: number, unit: EpochUnit = 'auto'): Temporal.Instant {
  if (!Number.isFinite(value)) {
    throw new TypeError(`Epoch value must be finite, got ${value}`);
  }
  let ms: number;
  if (unit === 'seconds') {
    ms = value * 1000;
  } else if (unit === 'milliseconds') {
    ms = value;
  } else {
    // 'auto' is a last resort — scale by magnitude and record that we guessed.
    ms = value < MS_CUTOVER ? value * 1000 : value;
    console.warn(`toInstant: guessed unit for ${value}${ms}ms; pass an explicit unit`);
  }
  if (ms < MIN_MS || ms > MAX_MS) {
    // Out-of-window result means the unit was almost certainly wrong.
    throw new RangeError(`Resolved ${ms}ms is outside 2000–2100; check the source unit`);
  }
  return Temporal.Instant.fromEpochMilliseconds(Math.trunc(ms));
}

Normalize any epoch to millisecondsThreshold on 1e11: below is seconds (×1000), above is already msNormalize any epoch to millisecondsraw epochabs < 1e11?→ seconds× 1000if secondsms value→ new Date

Verification Snippet

import { Temporal } from '@js-temporal/polyfill';

// Explicit units resolve to the same instant — this is the contract you want.
const fromSec = Temporal.Instant.fromEpochMilliseconds(1700000000 * 1000);
const fromMs = Temporal.Instant.fromEpochMilliseconds(1700000000000);
console.assert(fromSec.equals(fromMs), 'scaled seconds must equal the ms value');

// The 'auto' heuristic classifies modern values correctly...
console.assert(
  toInstant(1700000000, 'auto').epochSeconds === 1700000000,
  'modern 10-digit value should be read as seconds',
);
console.assert(
  toInstant(1700000000000, 'auto').epochSeconds === 1700000000,
  'modern 13-digit value should be read as milliseconds',
);

// ...but a misclassified unit is rejected, proving the window guard works.
let threw = false;
try { toInstant(1700000000000, 'seconds'); } catch { threw = true; }
console.assert(threw, 'ms value labelled as seconds should fall outside 2000–2100');

Normalization assertionsBoth units normalize to the same millisecond valueAssertions that prove the edge casetoMs(1710513000)1710513000000toMs(1710513000000)1710513000000toMs(0)0boundary near 1e11stable

Common Pitfalls

The first pitfall is assuming a timestamp's unit instead of confirming it, then applying the wrong scaling — the factor-of-1000 error that lands you in 1970 or the far future. Declare the unit from the source and convert at the boundary. The second is auto-detecting the unit by digit count or magnitude as a primary strategy, when the ranges can overlap for out-of-range dates; use explicit metadata and treat the heuristic only as a documented fallback. The third is normalizing inconsistently — scaling in some code paths and not others — so the same value is milliseconds in one place and seconds in another; pick one internal unit and convert once at ingestion.

A fourth pitfall is losing precision silently: a millisecond source that is truncated to seconds (integer-dividing by 1000) drops sub-second detail that may matter for ordering, and a seconds source multiplied to milliseconds gains only spurious zeros. Be explicit about the precision each unit carries. Finally, when the value comes from an external API, do not trust an undocumented assumption about its unit across versions — pin it, validate a known timestamp against the expected date at integration time, and add a test so a future unit change on the provider's side is caught immediately rather than shipping as a decades-off date.

const isMs = String(value).length === 13; // WRONG: breaks across eras and on fractions
toInstant(value, sourceUnit);             // RIGHT: pass the known unit

Seconds/ms pitfallsAssuming a fixed unit or thresholding on the wrong magnitudeWrongRightassume ms alwaysseconds land in 1970detect by magnitude (1e11)robust for both unitsthreshold on 1e10misclassifies near boundarycarry the unit in the typeno guessing

Frequently Asked Questions

How can I tell if a timestamp is in seconds or milliseconds?

Reliably, you cannot from the value alone — the unit is a property of the source. Capture it at ingestion and pass it explicitly. If you must guess, scale by magnitude against a fixed cutover instant rather than counting digits, and validate the result lands in a sane date range so a wrong guess fails loudly.

Is the "10 digits vs 13 digits" rule safe?

Only within the current era. Seconds crossed to 10 digits in 2001 and milliseconds reach 14 digits in 2286, so the rule has hard boundaries and also misbehaves on fractional-second values. Use it only as a documented fallback with a range check, never as a contract.

How do I tell if a Unix timestamp is in seconds or milliseconds?

You generally cannot tell from the number alone — the unit is metadata from the source, not part of the value. Determine it from documentation: JavaScript, Java, and many APIs use milliseconds; Unix tooling, Postgres extract(epoch), and Go's Unix() use seconds. As a rough rule, a recent-date timestamp is 10 digits in seconds and 13 in milliseconds, but rely on the source's declared unit and convert at the boundary rather than guessing.

How do I convert between seconds and milliseconds epoch values?

Multiply seconds by 1000 to get milliseconds, and integer-divide milliseconds by 1000 to get seconds. Do the conversion once at the boundary with a clearly-named helper, and normalize to a single internal unit so the rest of your code never re-decides. Note that dividing milliseconds to seconds drops sub-second precision, which may matter for ordering, so convert deliberately and document which unit each layer uses.

Is it safe to auto-detect the unit by the number's size?

Only as a documented last resort. A magnitude threshold works because seconds and milliseconds timestamps for realistic dates fall in non-overlapping ranges, but it silently breaks for dates outside the assumed range and hides a wrong guess. Prefer explicit unit metadata from the source, convert at ingestion, and if you must use the heuristic, choose the cutover instant deliberately and log when it fires so a bad guess is visible.