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.
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 ZONEin PostgreSQL,DATETIMEin MySQL,TIMESTAMPin 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
Zwhen 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
- Cron Pitfalls: Timezones, DST, and Missed Runs — how timezone bugs affect scheduled jobs
- The Temporal proposal — the upcoming JavaScript API that fixes Date’s timezone handling
- IANA Time Zone Database — the authoritative source for timezone rules
- RFC 3339 — the timestamp format that mandates explicit timezone offsets
Related posts
- Date to Unix Timestamp — Convert Any Date to Unix Time — Converting a date to a Unix timestamp gives you the seconds since January 1, 197…
- 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…
- ISO 8601 Date Format — The Standard for Dates in APIs and Databases — ISO 8601 is the international standard for representing dates and times. Learn t…
- Cron Pitfalls: Timezones, DST, and Missed Runs — Your cron job will run twice or skip entirely when DST changes. Here's why, how …
Related tool
Convert Unix timestamps, epoch seconds/milliseconds, and ISO 8601 dates.
Written by Mian Ali Khalid. Part of the Dev Productivity pillar.