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:
- Sub-second precision in seconds. A floating-point seconds value like
1700000000.123has 10 integer digits but is fractional, and naive digit-counting on its string form misfires. - Millisecond values with trailing zeros. A clock truncated to whole seconds but stored as milliseconds (
1700000000000) is 13 digits — fine — but1700000000also validly occurs as ms when the true instant is near the epoch. - The boundary moves. The 10→11 digit transition for seconds happened in 2001; the 13→14 transition for milliseconds happens in 2286. Code that hard-codes "10 or 13" assumes the present era forever.
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.
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;
}
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));
}
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');
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.
- Treating digit count as a contract.
const isMs = String(value).length === 13; // WRONG: breaks across eras and on fractions
toInstant(value, sourceUnit); // RIGHT: pass the known unit
-
Silent auto-detection with no guard. A bare
value < 1e11 ? value*1000 : valuewill happily produce a year-50000 date from a mislabelled value. Always validate the result against a date window so a wrong guess throws. -
Rounding instead of truncating when downscaling.
Math.round(ms / 1000)can bump the second; useMath.trunc/Math.floorwhen you intend to drop sub-second precision.
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.