Xerobit
Dev Productivity

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.

MA Mian Ali Khalid 8 min read
Free tool
Timestamp Converter
Convert Unix timestamps, epoch seconds/milliseconds, and ISO 8601 dates.
Open tool →
On this page ⌄
  1. The fundamental rule
  2. The JavaScript Date pitfall
  3. Milliseconds vs seconds
  4. The "midnight in which timezone" query bug
  5. DST and the "one hour off" symptom
  6. The Y2K38 problem
  7. Storing timestamps: the checklist
  8. Further reading

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

Keep reading

Cron Pitfalls: Timezones, DST, and Missed Runs

Your cron job will run twice or skip entirely when DST changes. Here's why, how UTC solves it, and how to configure timezone-aware schedulers correctly.

Date to Unix Timestamp: Convert Any Date to Unix Time

Converting a date to a Unix timestamp gives you the seconds since January 1, 1970 UTC. Here's how to convert dates to timestamps in JavaScript, Python, SQL, and why timezone...

Epoch Time Explained: What Is Unix Epoch and Why Does It Start January 1, 1970?

Unix epoch time counts seconds from January 1, 1970 00:00:00 UTC. Learn why 1970 was chosen, how to work with epoch time in JavaScript and Python, the Year 2038 problem, and...

ISO 8601 Date Format: The Standard for Dates in APIs and Databases

ISO 8601 is the international standard for representing dates and times. Learn the format, timezone designators, durations, intervals, and why ISO 8601 is mandatory for APIs,...

JavaScript Date Formatting: Intl.DateTimeFormat, date-fns, and Custom Formats

Format dates in JavaScript using the built-in Intl.DateTimeFormat API, date-fns format(), or manual formatting. Covers ISO 8601, locale-aware formatting, relative time (3 days...

Moment.js Alternatives: date-fns, Day.js, Luxon, and Temporal

Moment.js is deprecated. Compare modern alternatives: date-fns (tree-shakable functions), Day.js (2KB Moment drop-in), Luxon (immutable API), and the TC39 Temporal proposal....

Timestamp to Date: Convert Unix Timestamps to Human-Readable Dates

Converting a Unix timestamp to a date requires knowing whether it's seconds or milliseconds and what timezone to display in. Here's how to convert timestamps in code and why...

Timezone Conversion in JavaScript: Intl API, date-fns-tz, and Luxon

Convert dates between timezones in JavaScript using the Intl.DateTimeFormat API, date-fns-tz, and Luxon. Covers UTC offset vs named timezone, DST handling, user timezone...

Unix Timestamp: What Epoch Time Is and How to Convert It

A Unix timestamp is seconds since January 1, 1970 UTC. It's the standard time representation for APIs, databases, and logs. Here's how to convert timestamps and avoid timezone...

Unix Timestamps in Python: datetime, time, and Arrow

Work with Unix timestamps in Python using the datetime module, time.time(), and the arrow library. Covers converting timestamps to datetime objects, timezone-aware timestamps,...

Related tools

Written by Mian Ali Khalid. More guides in Dev Productivity.