A Unix timestamp is, in theory, the simplest representation of time that exists: one integer counting seconds since 1970-01-01 00:00:00 UTC. No timezones, no calendars, no leap years to worry about. In practice, every one of those simplifications has caused a production incident, and most teams encounter at least three of the pitfalls below before they stop treating Unix time as "just a number."

This article walks through the four families of bugs that come from Unix timestamps: scale confusion (seconds vs milliseconds), the Y2038 overflow, leap-second weirdness, and timezone-and-DST mistakes around storage and display. Each section gives a concrete failure mode, a way to detect it, and the fix you actually want.

The Specification, Briefly

POSIX defines Unix time as the number of seconds elapsed since the epoch, 1970-01-01 00:00:00 UTC, ignoring leap seconds. The phrase "ignoring leap seconds" is not a footnote — it is the source of half the surprises later in this post. The value is always measured in UTC; any timezone you see is added by display code, not by the timestamp itself.

Three things are not part of Unix time, even though people often assume they are: the unit (seconds, milliseconds, microseconds, or nanoseconds), the integer width (32-bit signed, 32-bit unsigned, or 64-bit signed), and any awareness of leap seconds. Pinning down these three for every API boundary in your system removes most of the bugs in this post.

Pitfall 1: Milliseconds vs Seconds vs Nanoseconds

The first pitfall is the most common: two systems agree on "Unix time" but disagree on the unit. JavaScript's Date.now() returns milliseconds. Python's time.time() returns float seconds. Go's time.Now().Unix() returns seconds, while time.Now().UnixNano() returns nanoseconds. PostgreSQL's extract(epoch from now()) returns seconds as a numeric. Java's System.currentTimeMillis() returns milliseconds.

When two of these meet at an API boundary without a contract, you get classic symptoms: decoded dates in 1970, decoded dates in the year 53,000, or — worst of all — dates that look plausible but are off by a factor of a thousand and only fail on specific records.

The digit-count heuristic is the fastest way to disambiguate a single value:

Digits  Unit          Example                  Range (around 2026)
10      seconds       1_747_310_400            1971-2286
13      milliseconds  1_747_310_400_000        1971-2286
16      microseconds  1_747_310_400_000_000    1971-2286
19      nanoseconds   1_747_310_400_000_000_000 1971-2262

A clean defensive helper looks like this in TypeScript:

function normalizeToMs(value: number): number {
    if (value < 1e11) return value * 1000;       // seconds
    if (value < 1e14) return value;              // milliseconds
    if (value < 1e17) return Math.floor(value / 1000);     // microseconds
    return Math.floor(value / 1_000_000);        // nanoseconds
}

Even better: name the field epoch_ms or epoch_s at the boundary, and validate units on ingestion rather than guessing them. Guess-based normalization works until 2001, when 10-digit seconds and a tiny 13-digit millisecond value briefly overlap in suspicious ways for legacy data.

Pitfall 2: Y2038 — The 32-Bit Overflow

On 2038-01-19 at 03:14:07 UTC, a signed 32-bit Unix timestamp reaches its maximum value: 2,147,483,647. The next second wraps to -2,147,483,648, which decodes back through the epoch and lands on 1901-12-13 20:45:52 UTC. This is the Epochalypse: every system that still represents time as a signed 32-bit integer will simultaneously believe it is 1901.

Most modern operating systems migrated time_t to a 64-bit signed integer years ago. Linux on 64-bit platforms uses 64-bit time_t by default; 32-bit ARM glibc 2.34+ uses 64-bit time_t; macOS and Windows are unaffected. The places Y2038 still lives are less obvious:

  • Embedded devices with old toolchains compiled against 32-bit time_t: routers, industrial controllers, smart meters, satellites.
  • MySQL's TIMESTAMP column was a 32-bit unsigned integer until 8.0.28 (2022), and existing tables retain the old storage format until migrated. Use DATETIME for any timestamp that might exceed the 2038 boundary in scheduled or financial data.
  • Legacy C code that explicitly declared int32_t for "seconds since epoch" in a wire protocol or file format. Re-typing the variable does not fix any data already serialized to disk or transmitted over the network.
  • Languages with weak type defaults: ActionScript, older PHP on 32-bit platforms, and many database drivers that used a 32-bit integer regardless of the underlying column width.

Detection is direct: pick a date past 2038 and round-trip it through every component of the system. If 2038-02-01 comes back as 1901-12-15 or as a negative integer, you have a 32-bit truncation somewhere in the pipeline. The fix is always wider integers and migrated storage; there is no clever way to squeeze post-2038 dates into 32 bits while keeping the epoch.

Pitfall 3: Leap Seconds

Earth's rotation is gradually slowing, and roughly every few years the International Earth Rotation Service inserts a leap second into UTC to keep clocks aligned with astronomical time. The most recent (and probably last) insertion was 2016-12-31 23:59:60 UTC. POSIX time pretends leap seconds do not exist: a Unix day is exactly 86,400 seconds, no exceptions.

Two strategies are used to reconcile this fiction with reality:

  1. Step (traditional NTP): the clock either repeats the same second (23:59:59, then 23:59:59 again, then 00:00:00) or jumps backward by one second at midnight. Code that assumes monotonic forward motion can crash or produce duplicate primary keys based on timestamp.
  2. Smear (Google, AWS, Facebook, Akamai): the extra second is spread evenly across a 24-hour window (Google uses noon-to-noon UTC). During the smear, every real second is reported as 1 + 1/86400 seconds, so the system never sees 23:59:60. The cost is that machines on smeared clocks disagree with machines on stepped clocks by up to half a second during the smear window.

For most application code this is invisible. It bites when you compare timestamps across machines using different strategies (smeared cloud vs stepped on-prem NTP), when you do sub-second arithmetic across the boundary, or when you rely on a strict monotonic relationship between wall-clock time and physical time. High-precision use cases (trading timestamps, GPS, telescope control) use TAI or GPS time, both of which count real seconds without leap adjustments.

Pitfall 4: Timezones, DST, and the "Local Time" Trap

Unix timestamps are always UTC. The bug arises when a developer treats a local datetime string as a Unix timestamp by converting it without an offset:

# Python — silent bug
import time, datetime
naive = datetime.datetime(2026, 5, 15, 12, 0, 0)  # no tzinfo!
ts = time.mktime(naive.timetuple())  # treats naive as local time
# On a UTC server: 1747310400
# On a Berlin server: 1747303200
# Same code, same input, different output.

The fix is to attach a timezone at construction and convert explicitly:

from datetime import datetime, timezone
dt = datetime(2026, 5, 15, 12, 0, 0, tzinfo=timezone.utc)
ts = int(dt.timestamp())  # 1747310400 everywhere

DST makes this worse. Local time has 23-hour and 25-hour days twice a year. On the spring-forward boundary, the local clock jumps from 01:59 to 03:00 — the time 02:30 does not exist. On fall-back, the local clock repeats 01:00-01:59 — the time 01:30 exists twice. Cron jobs scheduled in local time around these instants either fire twice or not at all. The cure is to run schedulers in UTC, or to use a scheduler that explicitly documents DST behaviour, such as systemd timers.

IANA Zones Beat Fixed Offsets

When you do need to display or schedule in a human timezone, use IANA zone names like Europe/Berlin or America/New_York, never fixed offsets like +01:00 or abbreviations like CET. Two reasons:

  • IANA zones encode full DST history. Europe/Berlin in January is UTC+1; in July it is UTC+2. A fixed-offset +01:00 is wrong in July.
  • Abbreviations are ambiguous. CST is Central Standard Time in the US, China Standard Time, and Cuba Standard Time, depending on context. Software almost never disambiguates correctly.

Politicians change DST rules on short notice — Russia, Turkey, Brazil, parts of the US, and the EU have all changed rules in the past decade. The IANA tzdata package ships these updates; your code consuming it gets the new rules on the next OS update. A hardcoded offset stays wrong forever.

Database Storage: TIMESTAMP vs DATETIME vs TIMESTAMPTZ

SQL databases historically disagree on what timestamp columns mean, and the words look deceptively similar across vendors:

  • MySQL TIMESTAMP: stored as UTC, displayed in the session timezone. Range 1970-2038 (32-bit) prior to 8.0.28, 1970-2106 after. Implicit conversion can change displayed values when sessions migrate between zones.
  • MySQL DATETIME: stored verbatim with no timezone awareness. Range 1000-9999. Means whatever the application code says it means — usually a source of bugs unless every code path agrees on UTC.
  • PostgreSQL TIMESTAMP (without time zone): stored verbatim, no timezone. Like MySQL's DATETIME.
  • PostgreSQL TIMESTAMPTZ: stored as UTC internally, but input and output values are translated using the session's TIMEZONE setting. This is almost always what you want.

The pragmatic default for new schemas is: PostgreSQL TIMESTAMPTZ for any instant-in-time field; DATE or TIMESTAMP only when you genuinely mean a local wall-clock value with no zone, like a birthday or a recurring meeting time.

Examples Across Languages and Tools

Getting a current Unix timestamp safely in major languages:

# JavaScript — milliseconds
Date.now()                     // 1747310400000
Math.floor(Date.now() / 1000)  // 1747310400 (seconds)

# Python — seconds (float)
import time; time.time()       // 1747310400.123456
int(time.time())               // 1747310400

# Go — seconds and nanoseconds
import "time"
time.Now().Unix()              // 1747310400 (seconds)
time.Now().UnixMilli()         // 1747310400000 (milliseconds, Go 1.17+)
time.Now().UnixNano()          // 1747310400123456789

# Rust — std::time::SystemTime
use std::time::{ SystemTime, UNIX_EPOCH };
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();

# Bash — convert a Unix timestamp to ISO 8601
date -u -d @1747310400 +%Y-%m-%dT%H:%M:%SZ
# 2026-05-15T12:00:00Z

# Bash — current Unix timestamp
date +%s

# PostgreSQL — current and conversion
SELECT extract(epoch from now())::bigint;
SELECT to_timestamp(1747310400);  -- returns TIMESTAMPTZ

For one-off conversions and verification — checking what a 13-digit number actually decodes to, or working out the Unix timestamp for a given ISO 8601 string — paste it through the Timestamp Converter. It detects units automatically and shows the result in UTC, local time, and ISO 8601 side by side, so unit mistakes become obvious before they reach production.

A Defensive Checklist

  • Name every timestamp field with its unit: created_at_epoch_ms, not created_at.
  • Store and transmit in UTC. Convert to local timezones at the display layer, not the storage layer.
  • Use 64-bit integers (or a native datetime type) for any new code. Audit legacy 32-bit TIMESTAMP columns and migrate before 2038.
  • Use IANA zone names (Europe/Berlin), never fixed offsets or abbreviations.
  • Run cron and other schedulers in UTC. If a job must run at a local time, document the DST behaviour explicitly.
  • For high-precision timing, do not use Unix time — use a monotonic clock for intervals and TAI/GPS for absolute precision.

If you encounter related issues at the data-encoding layer — invisible characters in timestamps copied from spreadsheets, BOM markers in config files specifying timezones, or CRLF line endings in shell scripts that set TZ — the companion article on whitespace bugs that break production deploys catalogues the most common culprits.