Unix Timestamps in Production: Seconds vs Milliseconds, the 2038 Problem, and Timezone Bugs
Every production system eventually logs, stores, or transmits a Unix timestamp. And every production system eventually ships a bug involving one — a date that renders as January 1970, an event scheduled 49 years in the past, an API integration where one side expects milliseconds and the other sends seconds. These bugs are boring, common, and entirely preventable once you know the handful of rules that govern how timestamps actually work.
This guide covers the practical core: recognizing timestamp units by digit count, the 2038 problem and its real status today, the bug patterns that actually occur, and the correct way to get a timestamp in the languages you’re likely using.
What a Unix timestamp is (and isn’t)
A Unix timestamp counts the number of seconds since 1970-01-01 00:00:00 UTC — the “Unix epoch.” Two properties matter for everything that follows:
- It has no timezone. The timestamp
1785235837refers to one specific instant everywhere on Earth simultaneously. Timezones only enter when you display a timestamp to a human or construct one from local wall-clock components. If you internalize one sentence from this article, make it that one: timestamps are UTC instants; timezones are a rendering concern. - It ignores leap seconds. Unix time assumes every day has exactly 86,400 seconds. When a leap second is inserted, POSIX time effectively repeats or smears it (behavior varies by system; Google’s “leap smear” spreads it across the day). For most application code this is irrelevant; for interval calculations spanning a leap second it means your “exactly N seconds” assumption can be off by one.
Also note the epoch choice means negative timestamps are valid: -1 is 1969-12-31 23:59:59 UTC. Code that treats negative timestamps as errors breaks on dates before 1970 — a real bug pattern for systems handling historical data (birth dates, archival records).
Seconds, milliseconds, microseconds: the digit-count heuristic
Timestamps arrive in different units from different ecosystems, and misidentifying the unit is the single most common timestamp bug. The quick field test is counting digits:
| Unit | Example (mid-2026) | Digits | When it hits this width |
|---|---|---|---|
| Seconds | 1785235837 | 10 | 10 digits since 2001-09-09; 11 digits from 2286 |
| Milliseconds | 1785235837000 | 13 | 13 digits since 2001-09-09 |
| Microseconds | 1785235837000000 | 16 | 16 digits |
| Nanoseconds | 1785235837000000000 | 19 | exceeds int64 range in 2262 |
Rules of thumb for the present era (2001–2286):
- 10 digits → seconds. Always safe to assume.
- 13 digits → milliseconds. JavaScript’s
Date.now(), Java’sSystem.currentTimeMillis(), and most logging/metrics systems emit these. - 16 digits → microseconds. Python’s
time.time()multiplied out, some databases, some tracing systems. - 19 digits → nanoseconds. Go’s
time.Now().UnixNano(), and this one is a trap: 19 digits exceeds the signed 64-bit maximum (9,223,372,036,854,775,807) after 2262, but today a 19-digit number fits in int64 — just barely. If you store nanosecond timestamps, don’t do arithmetic that assumes headroom.
A fast sanity check when debugging: paste the number into a Timestamp Converter and see if the date looks sane. If a “seconds” interpretation yields 1970 and a “milliseconds” interpretation yields a plausible recent date, you’ve identified the unit in two seconds.
The corresponding bug pattern: treating milliseconds as seconds (or vice versa). Multiply a seconds timestamp by 1000 unnecessarily and 1785235837 becomes 1785235837000 seconds — a date roughly 56,000 years in the future, which most renderers clamp or wrap into garbage. Divide a millisecond timestamp by 1000 when it was already seconds and you get 1970-01-21 — which is why so many broken systems display dates in January 1970. When you see 1970 in production, suspect a unit mismatch first.
The 2038 problem
Classic Unix time_t was a signed 32-bit integer counting seconds. Its maximum value, 2,147,483,647, corresponds to 2038-01-19 03:14:07 UTC. One second later, a signed 32-bit counter wraps to −2,147,483,648, which renders as 1901-12-13 20:45:52 UTC. This is the “Y2038” or “Epochalypse” problem — structurally identical to Y2K, but rooted in a specific C type rather than two-digit year fields.
Where it actually still matters, as of 2026:
- 32-bit embedded systems and firmware. Devices with long service lives — industrial controllers, automotive systems, medical devices, infrastructure — running 32-bit kernels or 32-bit
time_tABIs. This is the main residual risk class, precisely because these systems are hard to patch and easy to forget. - Old file formats and protocols that specified 32-bit second counters. Some are fixed by reinterpreting the field as unsigned (pushing the limit to 2106); others are simply legacy.
- Databases with epoch-limited types. The well-known example: MySQL’s
TIMESTAMPtype historically ranged only to2038-01-19 03:14:07 UTCbecause it stored seconds as an unsigned 32-bit integer. (DATETIMEhas a range to year 9999 and is unaffected.) If your schema usesTIMESTAMP, know this limit.
What has already been fixed:
- 64-bit Linux and modern 64-bit systems use a 64-bit
time_t, good for ~292 billion years. - The Linux kernel’s
time64work (completed in kernel 5.6, 2020) and glibc’s 64-bit time support (glibc 2.34’s_TIME_BITS=64) give 32-bit Linux userland a migration path, though binaries must be rebuilt with the new ABI — old compiled binaries on 32-bit systems remain vulnerable. - JavaScript, Python, Go, Java all use 64-bit (or float64) time representations and don’t overflow in any human-relevant timeframe. JavaScript’s float64 milliseconds go ~285,000 years either direction.
Practical advice: if you’re writing new code on a 64-bit platform with a mainstream language, 2038 is not your problem. If you maintain embedded firmware, legacy C on 32-bit targets, or schemas full of INT epoch columns and MySQL TIMESTAMP fields, audit now — systems installed in the 2020s will still be running in 2038.
The bug patterns that actually happen
1. Unit confusion (seconds vs milliseconds)
Covered above: the most common and the easiest to spot (dates in 1970 or the year 58000). Fix by convention: name your variables and fields with the unit — created_at_ms, expires_s — and validate at API boundaries. A timestamp outside, say, 10^9–10^10 seconds is suspicious for a “seconds” field in this century.
2. Local time mistaken for UTC
The inverse of the “timestamps have no timezone” rule. Classic manifestation: a server in Shanghai and a server in Virginia compute “the same” deadline differently because one side built the timestamp from local wall-clock components. Any code path that goes local date/time fields → timestamp must specify the timezone of those fields explicitly. new Date(2026, 6, 28) in JavaScript means midnight in the browser’s local timezone, which is a different instant for every user.
3. Daylight saving time edges
DST creates two kinds of broken wall-clock times:
- Nonexistent times. In US timezones, 2026-03-08 02:30 local does not exist — clocks jump 02:00 → 03:00. A cron-style scheduler set to “run at 02:30 daily” will either skip, double-run, or behave platform-dependently that day.
- Ambiguous times. On 2026-11-01, 01:30 local happens twice in US timezones as clocks fall back. Storing “01:30” without an offset or timezone cannot distinguish which one you meant.
The robust fixes: schedule recurring jobs in UTC, store instants as timestamps, and only convert to local time at display. If you must store future local wall-clock times (e.g., “user’s 9 AM meeting”), store the local time plus the IANA timezone name (America/New_York) and resolve to a timestamp at render time — because governments change DST rules, and a timestamp computed today for a 2028 local meeting may be wrong after a law change.
4. JavaScript’s zero-indexed months
new Date(2026, 6, 28) is July 28, not June 28 — months run 0–11 while days run 1–31. This inconsistency has produced a generation of off-by-one-month bugs. Mitigations: use Date.UTC(...) when you mean UTC, use ISO strings (new Date("2026-07-28T00:00:00Z")) for clarity, or use a library (Temporal, when it lands broadly, fixes this properly with 1-indexed months).
5. Float precision on seconds
Python’s time.time() returns a float. A float64 has 53 bits of mantissa; at current epoch magnitudes (~1.79 × 10^9 seconds), the resolution is fine (~240 nanoseconds), so this rarely bites in Python — but the same reasoning does bite in systems that carry microsecond or nanosecond timestamps as float64: a 16-digit microsecond value needs 54 bits to represent exactly, so float64 microseconds are already imprecise today. Use integers for anything finer than milliseconds.
6. String parsing without an offset
new Date("2026-07-28") in JavaScript parses as UTC midnight, but new Date("2026-07-28 00:00:00") (no T, no Z) parses as local midnight — a silent timezone flip based on string format details. Always include an explicit Z or numeric offset in serialized datetimes. Prefer ISO 8601 with offset: 2026-07-28T10:00:00+08:00.
Getting the timestamp correctly, per language
JavaScript / TypeScript — Date.now() returns milliseconds; divide for seconds:
const ms = Date.now(); // 1785235837123
const seconds = Math.floor(Date.now() / 1000); // 1785235837
// From a specific instant, always specify UTC explicitly:
const ts = Date.UTC(2026, 6, 28, 0, 0, 0) / 1000; // months 0-indexed!
Python — time.time() returns float seconds; use datetime with explicit UTC for anything you’ll store:
import time
from datetime import datetime, timezone
seconds = int(time.time()) # 1785235837
ns = time.time_ns() # nanoseconds, exact integer
ts = int(datetime.now(timezone.utc).timestamp())
Go — the time package gives you each unit explicitly:
now := time.Now()
seconds := now.Unix() // int64 seconds
millis := now.UnixMilli() // int64 milliseconds
nanos := now.UnixNano() // int64 nanoseconds — mind int64 range
Java — avoid legacy Date/Calendar; use java.time:
long millis = System.currentTimeMillis();
long seconds = Instant.now().getEpochSecond();
long nanos = Instant.now().getNano(); // nano-of-second, not epoch nanos!
Bash — GNU vs macOS (BSD) date differ on parsing:
date +%s # current epoch seconds (both)
date -d @1785235837 # GNU: timestamp → human date
date -r 1785235837 # macOS/BSD: timestamp → human date
date -u -d "2026-07-28 00:00:00 UTC" +%s # GNU: date → timestamp
date -u -j -f "%Y-%m-%d %H:%M:%S" "2026-07-28 00:00:00" +%s # macOS
Note the recurring theme: every correct API either gives you the epoch value directly or forces you to name the timezone. Every dangerous API lets you omit it.
Working with timestamps interactively
When debugging a timestamp from a log or API response, don’t do the math in your head. Paste it into our Timestamp Converter: it shows the instant in UTC and your local timezone, and auto-detects seconds vs milliseconds — which is exactly the ambiguity check described above. It also converts in the other direction (human date + timezone → timestamp) for building test fixtures.
Summary
- A Unix timestamp is seconds (or ms/µs/ns) since 1970-01-01 UTC. It has no timezone and no leap seconds.
- Digit count identifies the unit in the modern era: 10 = seconds, 13 = milliseconds, 16 = microseconds, 19 = nanoseconds.
- 2038 overflows signed 32-bit
time_tat 2038-01-19 03:14:07 UTC. 64-bit systems and mainstream languages are safe; embedded firmware, legacy 32-bit binaries, and MySQLTIMESTAMPcolumns are the residual risk. - The real-world bug patterns: unit confusion, local-vs-UTC mistakes, DST edges, JS zero-indexed months, float64 precision loss on µs/ns, and offset-less string parsing.
- Store instants as integer timestamps, name fields with units, schedule in UTC, convert to local only at display — and keep a Timestamp Converter bookmarked for the debugging that slips through anyway.