X Xerobit

Unix Timestamps and Timezones: What Can Go Wrong

Unix timestamps are always UTC — but displaying, parsing, and storing them is where most timezone bugs live. The common pitfalls and exactly how to avoid them.

Mian Ali Khalid · · 8 min read
Use the tool
Timestamp Converter
Convert Unix timestamps, epoch seconds/milliseconds, and ISO 8601 dates.
Open Timestamp Converter →

A Unix timestamp is the number of seconds (or milliseconds in most programming languages) that have elapsed since 1970-01-01T00:00:00Z — the Unix epoch. The Z is critical: it’s Zulu time, UTC, no offset, no DST. A Unix timestamp is always UTC. Always.

Despite this, timezone bugs in timestamp handling are endemic. They show up as:

  • A user in New York sees “Created yesterday” when the record was created 2 hours ago in their timezone
  • A query for “records created today” returns nothing because “today” is being computed in UTC when the user expects local time
  • A cron job fires an hour late twice a year

Use the Timestamp Converter to visualize timestamps across timezones while reading this.

The fundamental rule

Store UTC. Convert for display.

That’s it. The timestamp itself has no timezone. It’s a count of seconds. Store that count. Only convert when you need to show it to a human in their local time.

Violating this rule in either direction causes bugs:

  • Storing local time as an epoch: the epoch value is now timezone-dependent, meaning the same moment is represented differently by different systems
  • Displaying UTC directly: users see times in a timezone that may be meaningless to them

The JavaScript Date pitfall

JavaScript’s Date constructor has a well-documented but persistently surprising parsing behavior:

new Date('2026-01-15')         // → 2026-01-15T00:00:00.000Z (UTC midnight)
new Date('2026-01-15T00:00:00') // → 2026-01-15T00:00:00.000-05:00 (LOCAL midnight, EST)

Date-only strings (YYYY-MM-DD) are parsed as UTC. Date-time strings without an explicit offset are parsed as local time. This is specified in ECMAScript. It is counterintuitive.

The consequence:

// User selects "January 15, 2026" in a date picker
const picked = '2026-01-15';

new Date(picked).toISOString()
// → "2026-01-15T00:00:00.000Z" — correct UTC midnight

const pickedWithTime = '2026-01-15T00:00:00';
new Date(pickedWithTime).toISOString()
// In EST: "2026-01-15T05:00:00.000Z" — 5 hours off!

If your date picker produces 2026-01-15T00:00:00 and you store that as a UTC epoch, users in UTC-5 have their records attributed to the wrong calendar day when viewed in UTC.

Fix: always append Z when constructing a Date from a date-time string that is meant to be UTC:

new Date('2026-01-15T00:00:00Z').toISOString()
// → "2026-01-15T00:00:00.000Z" ✓

Or use a library like date-fns or Temporal (Stage 3 proposal) that is explicit about timezone:

import { parseISO } from 'date-fns';
import { zonedTimeToUtc } from 'date-fns-tz';

// Interpret the date as the user's local timezone
const utc = zonedTimeToUtc('2026-01-15T00:00:00', 'America/New_York');

Milliseconds vs seconds

Unix time is defined in seconds. But:

  • JavaScript’s Date.now() returns milliseconds
  • Python’s time.time() returns seconds (float)
  • Go’s time.Unix() takes seconds
  • Java’s System.currentTimeMillis() returns milliseconds
  • PostgreSQL’s EXTRACT(epoch FROM ...) returns seconds (float)
  • MySQL’s UNIX_TIMESTAMP() returns seconds

Mixing ms and s is a constant source of bugs. The timestamp 1704067200000 (ms) represents 2024-01-01T00:00:00Z. The timestamp 1704067200 (s) represents the same moment. Treated as seconds, 1704067200000 represents the year 55977 AD.

Fix: be explicit at every boundary.

// Explicit ms to s conversion
const epochSeconds = Math.floor(Date.now() / 1000);

// Explicit s to ms conversion
const date = new Date(epochSeconds * 1000);
import time
epoch_ms = int(time.time() * 1000)  # explicit

If you store timestamps in a database, decide once: seconds or milliseconds. Document it. Enforce it via column name convention (created_at_ms vs created_at_s).

The “midnight in which timezone” query bug

A query for “all records created today” is only unambiguous if you define “today” in a specific timezone:

-- Bug: "today" in UTC, which is wrong for users in UTC-5 at 8 PM local
SELECT * FROM events
WHERE created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day';

-- Fix: "today" in the user's timezone
SELECT * FROM events
WHERE created_at AT TIME ZONE 'America/New_York' >= DATE_TRUNC('day', NOW() AT TIME ZONE 'America/New_York')
AND created_at AT TIME ZONE 'America/New_York' < DATE_TRUNC('day', NOW() AT TIME ZONE 'America/New_York') + INTERVAL '1 day';

The second version is verbose but correct. In PostgreSQL, timestamps stored with TIMESTAMP WITH TIME ZONE (a.k.a. TIMESTAMPTZ) are internally UTC and can be correctly converted via AT TIME ZONE.

A cleaner pattern: compute the UTC range for “today in timezone X” in application code, then pass UTC bounds to the query:

import { startOfDay, endOfDay } from 'date-fns';
import { zonedTimeToUtc } from 'date-fns-tz';

const tz = 'America/New_York';
const todayStart = zonedTimeToUtc(startOfDay(new Date()), tz);
const todayEnd = zonedTimeToUtc(endOfDay(new Date()), tz);

// Then: WHERE created_at >= ? AND created_at < ?

DST and the “one hour off” symptom

The classic symptom: times are consistently one hour off for half the year. This is almost always a timezone offset being hardcoded (e.g., -05:00 for EST) instead of using a named IANA timezone that handles DST automatically.

// Bug: hardcoded offset, breaks during EDT
const tz = '-05:00';

// Fix: named timezone handles both EST and EDT
const tz = 'America/New_York';

IANA timezone names are in the form Region/City (e.g., America/New_York, Europe/London, Asia/Tokyo). They encode the full DST transition history, not just a static offset.

Both browser JavaScript (Intl.DateTimeFormat) and Node.js handle IANA timezones:

const fmt = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  year: 'numeric', month: 'numeric', day: 'numeric',
  hour: 'numeric', minute: 'numeric',
});
fmt.format(new Date()); // "5/24/2026, 9:00 AM"

The Y2K38 problem

32-bit signed integers overflow at 2147483647 seconds after epoch = 2038-01-19 at 03:14:07 UTC. Any system still storing Unix timestamps as 32-bit integers will malfunction on that date.

Most modern databases and languages use 64-bit integers for timestamps (the range extends to year 292,278,994). PostgreSQL TIMESTAMPTZ is 64-bit. MySQL’s TIMESTAMP type is still 32-bit in some versions — DATETIME is the safe choice. If you’re on embedded systems or using languages/libraries that default to 32-bit, verify.

Storing timestamps: the checklist

  • Store as UTC in the database (TIMESTAMP WITH TIME ZONE in PostgreSQL, DATETIME in MySQL, TIMESTAMP in SQLite)
  • Never store local time as an epoch — store the offset or timezone name alongside if you need to reconstruct the original local time
  • Agree on ms vs s in your codebase and enforce by convention
  • Append Z when constructing Date objects from date-time strings
  • Use IANA timezone names, never hardcoded offsets
  • Convert to local time only at the display layer

The Timestamp Converter converts between Unix epochs, ISO 8601, and human-readable times across any timezone — useful for debugging mismatched timestamps.

Further reading


Related posts

Related tool

Timestamp Converter

Convert Unix timestamps, epoch seconds/milliseconds, and ISO 8601 dates.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.