🌐Web Development & DevOps

Unix Timestamps, Timezones, and the Bugs They Cause β€” A Practitioner's Guide (Y2038 Included)

Why your date is off by one day, why 'store everything in UTC' is almost right, why EST is not a timezone, what actually breaks in January 2038, and the handful of rules that prevent the entire genre of time bugs. With the milliseconds-vs-seconds table taped above every on-call desk.

Published July 8, 2026
9 min read
By Toolsana Team

Time bugs have a distinctive signature: they pass every test, survive code review, run correctly for months β€” and then fail on one specific night in March, or for users in one hemisphere, or by exactly one hour, or one day, in one direction. Nobody writes if (march) break_everything(), yet every team has shipped it.

The underlying causes are a short list. This is that list β€” the model, the bugs, and the handful of rules that prevent the entire genre.

What a Unix timestamp actually is

A Unix timestamp counts seconds since the epoch: 1970-01-01 00:00:00 UTC. It's a single integer, timezone-free by construction β€” 1783000000 denotes the same instant in Tokyo, Toronto, and on the ISS. That's precisely its virtue: arithmetic works (later - earlier = elapsed seconds), sorting works, and no locale can misinterpret it.

One honest asterisk: Unix time pretends every day has exactly 86,400 seconds, which is a small lie β€” reality inserted 27 leap seconds between 1972 and 2016. Unix time absorbs them by effectively repeating a second, big providers smear them across hours so clocks never jump, and the standards bodies voted in 2022 to abolish leap seconds by 2035. For applications, the right amount of attention to this is zero; it just explains why "seconds since epoch" and "atomic clock seconds" disagree by 27.

The mistake people actually make with timestamps is far more mundane: the unit.

UnitDigits (today)ExampleWho uses it
seconds101783000000Unix date +%s, PHP, Postgres epoch, most APIs
milliseconds131783000000000JavaScript Date.now(), Java
microseconds161783000000000000Python's finer APIs, some DBs
nanoseconds191783000000000000000Go UnixNano(), high-perf tracing

Feed milliseconds to a seconds-expecting parser and events happen in the year ~58,000; do the reverse and everything occurred on January 21, 1970. Both bugs announce themselves β€” if a human looks. When a timestamp seems implausible, drop it into the converter before assuming the data is corrupt; nine times out of ten it's a unit mismatch, visible instantly. (The epoch converter handles batch conversions and the other direction.)

Offsets are not timezones

Here's the distinction that produces the March-and-November bug class:

  • An offset is a number: UTC-5. It says nothing about when it applies.
  • A timezone is a ruleset with a name: America/New_York means UTC-5 in winter (EST), UTC-4 in summer (EDT), transition dates as legislated, plus the entire historical record of every rule the region ever had.

Configure a system with "EST" and it's correct from November to March and silently wrong β€” by one hour β€” the other eight months. The bug appears at the spring transition, vanishes at the fall one, and in between everyone learns to distrust the reporting dashboard. The three-letter abbreviations are also ambiguous on their face: CST is Central US and China Standard Time; IST is India, Ireland, and Israel.

The IANA (tz) database names β€” Continent/City β€” are the only identifiers that carry the full rules. Use them in every config, every API, every database session. Abbreviations are display strings, nothing more. When you need to see how offsets between zones actually shift across the year β€” say, the Sydney–London gap, which changes twice because their DST calendars are inverted β€” the timezone comparison tool lays it out hour by hour.

And the rules themselves move. tzdata ships several releases a year because governments change their minds β€” sometimes with weeks of notice (Lebanon's 2023 last-minute DST reversal split the country's phones into two clocks for a weekend; Kazakhstan collapsed two zones into one in 2024; Turkey, Morocco, and Chile have all rewritten their rules in the past decade). A stale tzdata on your servers is a correctness bug with a countdown you can't see. Patch it like you patch OpenSSL.

"Store everything in UTC" β€” the rule and its one real exception

The standard advice is right for 95% of data: anything that already happened gets stored as a UTC instant. Logs, orders, sensor readings, created_at β€” UTC sorts globally, dedupes cleanly, and never gets re-interpreted. Convert to the viewer's local time at the last possible moment, in the UI layer, and nowhere else.

The exception is future events anchored to local wall-clock time. Store "March 2028 team meeting, 9:00 AM Berlin" as a UTC instant today and you've frozen today's conversion rules into the data. If the EU actually implements its long-debated DST abolition before then, 9:00 AM Berlin maps to a different UTC instant than the one you stored β€” and the meeting silently shifts an hour. The people showing up at 9 are right; your database is wrong.

So the storage rule has two branches:

  • Past instants β†’ UTC (Postgres timestamptz, or epoch millis in a BIGINT).
  • Future wall-clock commitments β†’ local time + IANA zone (2028-03-15 09:00 + Europe/Berlin), resolved to UTC as late as possible, against current tzdata.

Recurring schedules ("every day at 9 AM Sydney") are the second branch on repeat β€” compute each occurrence shortly before it happens, never months ahead. This, incidentally, is the same reason your "daily at 9" cron job on a UTC server drifts an hour off local expectations twice a year β€” a problem we dissected in the cron guide.

Database fine print worth knowing: Postgres timestamptz does not store a timezone β€” it stores a UTC instant and converts on the way in and out (the name misleads everyone once). MySQL's TIMESTAMP also normalizes to UTC but ends at 2038 (more below); its DATETIME stores an uninterpreted wall time, which is fine only if your application enforces the UTC discipline itself.

The off-by-one-day bug, dissected

The most-reported date bug in JavaScript, and it reproduces in one line:

new Date('2026-07-08')            // date-only string β†’ parsed as UTC midnight
  .toLocaleDateString()           // rendered in New York (UTC-4)
// β†’ "7/7/2026"  ...the 7th. Off by one day.

A date-only ISO string parses as UTC midnight; any negative-offset timezone renders that instant as the evening before. Birthdays shift, invoices dated the 1st print as the 31st, and the bug only affects users west of Greenwich β€” which is why the European team can't reproduce what the American users keep reporting.

The same API hides a second trap in plain sight:

new Date(2026, 6, 8)   // July 8 β€” because months are zero-indexed. 6 = July.

The durable fixes: treat calendar dates (birthdays, due dates) as dates, not instants β€” store the YYYY-MM-DD string and never let it near a timezone conversion; when you must construct a Date, do it from explicit components in a known zone; and prefer a real library (date-fns, Luxon) over hand-rolled arithmetic. The long-term fix is the Temporal API β€” Temporal.PlainDate, Temporal.ZonedDateTime, immutable, explicit, and designed by people who had felt all of this pain. Firefox already ships it, the other engines are close behind flags/polyfills, and it will eventually make Date a legacy API. Adopt it as soon as your support matrix allows.

Two adjacent classics from the same family: week numbers (ISO 8601 weeks start Monday and week 1 contains the first Thursday β€” US calendars count differently, so "week 34" means different days on each side of the Atlantic; check any date's ISO week), and leap years (divisible by 4, except centuries, except every 400 years β€” 2000 was, 2100 won't be; the leap year checker knows the rule so your age = (now - birth) / 31536000 doesn't have to be wrong). For human-facing durations β€” exact ages, "days between" β€” the date calculator and age calculator do the calendar-aware version of arithmetic that seconds-division gets subtly wrong.

January 19, 2038, 03:14:07 UTC

At that moment, a signed 32-bit seconds counter hits 2,147,483,647, overflows, and wraps to December 13, 1901. It's Y2K's quieter sibling, with one difference: Y2K was a formatting problem, this is an arithmetic one, and it fires early β€” any code doing 32-bit math on dates twenty-plus years out (mortgage schedules, certificate expiries, pension projections) can overflow today.

Where it's already solved: 64-bit operating systems, current Linux kernels (64-bit time_t even on 32-bit hardware since kernel 5.6), modern filesystems (ext4's extended timestamps reach the year 2446), and essentially every mainstream language runtime.

Where it still lives:

  • MySQL TIMESTAMP β€” capped at 2038-01-19 03:14:07 UTC to this day. Schemas storing expiry dates in it are a time bomb with a published detonation date. Use DATETIME (plus app-enforced UTC) or Postgres.
  • Binary formats and protocols with 32-bit time fields, of which the world has an uncatalogued abundance.
  • Embedded systems β€” meters, controllers, routers manufactured this decade will still be running in 2038, and nobody will remember which ones do 32-bit time math.

For ordinary web applications the action item is one grep: find TIMESTAMP columns and 32-bit time fields, and test your system once with a date in 2039. Cheap now; a compliance fire drill in 2037.

The rules, compressed

Run everything in UTC; store past events as UTC instants and future local commitments as wall time + IANA zone; transmit RFC 3339 with the offset always present (2026-07-08T14:30:00Z β€” a timestamp without an offset is a bug in serialized form); convert to local only at the display edge; write America/New_York, never EST; keep tzdata patched; know your units before you parse (10 digits = seconds, 13 = milliseconds); and test the four cursed moments β€” both DST nights, February 29, and something past 2038. Time will still be complicated. Your bugs don't have to be.

Frequently Asked Questions

Is a Unix timestamp in seconds or milliseconds?

The Unix timestamp proper is seconds since 1970-01-01T00:00:00 UTC β€” that's what date +%s, PHP's time(), and most databases mean. But JavaScript's Date.now() and Java's System.currentTimeMillis() return milliseconds, and some systems use micro- or nanoseconds. The reliable tell is digit count: a current timestamp in seconds has 10 digits (~1.78 billion), milliseconds 13, microseconds 16, nanoseconds 19. Mixing them up produces unmistakable symptoms β€” interpret milliseconds as seconds and you get a date around the year 58,000; interpret seconds as milliseconds and everything happened on January 21, 1970. If a date in your system is absurdly far in the future or suspiciously close to the epoch, check the unit before anything else.

Should I always store timestamps in UTC?

For anything that already happened β€” logs, transactions, audit trails, created_at columns β€” yes, without exception: UTC is unambiguous, sorts correctly, and never shifts under DST. The one genuine exception is future events tied to local wall-clock time. A meeting at 9:00 AM in Berlin in March 2028 is a promise about a wall clock, not about a UTC instant: if the EU changes its DST rules between now and then (it has repeatedly threatened to), the correct UTC instant changes with them. Convert to UTC at storage time and the meeting silently moves an hour. For future local events, store the local time plus the IANA zone name ('2028-03-15 09:00, Europe/Berlin') and resolve to UTC as late as possible.

What's the difference between EST and America/New_York?

EST is a fixed offset β€” UTC-5, nothing more. America/New_York is an IANA timezone: a named ruleset covering the region's entire history of offsets, including the annual switch between EST (UTC-5) and EDT (UTC-4) and every past rule change. Code configured with 'EST' shows times an hour off for the eight months of the year New York observes daylight time β€” a bug that surfaces every March and quietly disappears every November, which makes it maddening to track down. The same trap exists for 'IST' (India? Ireland? Israel?) and 'CST' (Central US? China?). Always configure systems with IANA names (Continent/City); treat three-letter abbreviations as display formatting only.

What is the Year 2038 problem, and should I care?

At 03:14:07 UTC on January 19, 2038, a signed 32-bit seconds counter overflows and wraps to December 1901. Modern 64-bit systems, current Linux kernels (64-bit time_t even on 32-bit architectures since 5.6), and most languages are fine. The places it still lives: MySQL's TIMESTAMP column type, which tops out at exactly that moment even on modern versions β€” use DATETIME (with app-level UTC discipline) or switch databases; old file formats and binary protocols with 32-bit time fields; and embedded devices β€” routers, meters, industrial controllers β€” that will still be running in 2038. If you write software with a decade-plus horizon or handle 20-year expiry dates (mortgages, certificates, pensions), 2038 arithmetic can overflow today, not in 2038.

Why is my JavaScript date off by exactly one day?

Because date-only strings parse as UTC midnight, and displaying them in a negative-offset timezone rolls back into the previous day. new Date('2026-07-08') creates 2026-07-08T00:00:00Z; in New York (UTC-4) that instant is 8:00 PM on July 7, so the calendar widget shows the 7th. The reverse trap: new Date(2026, 6, 8) uses local time β€” and month 6, because JavaScript months are zero-indexed, one of the language's most reliable bug generators. Fixes: parse date-only values with explicit components rather than strings, format birthdays and other calendar dates in UTC, or use a library (date-fns, Luxon) β€” and watch the Temporal API, which fixes all of this at the language level and is already shipping in Firefox.

Do I need to worry about leap seconds?

Almost certainly not, and soon not at all. Unix time pretends leap seconds don't exist β€” each day is exactly 86,400 seconds β€” so the 27 leap seconds added since 1972 never appear in timestamps; the clock effectively repeats a second. Large providers (Google, AWS) 'smear' the extra second across the surrounding hours so no clock ever jumps, which is why your servers never noticed. Historical trivia is becoming the whole story: the 2022 General Conference on Weights and Measures resolved to abandon leap seconds by 2035, and none have been inserted since 2016. Unless you write GPS, astronomy, or high-precision distributed-systems code, the correct amount of leap-second handling in your application is none.

What's the difference between ISO 8601 and RFC 3339?

ISO 8601 is the big international standard β€” it includes calendar dates, week dates (2026-W28-3), ordinal dates (2026-189), durations (P3Y6M4D), intervals, and reduced-precision forms. RFC 3339 is the internet profile of it: just full timestamps like 2026-07-08T14:30:00Z or 2026-07-08T14:30:00+02:00, with the offset mandatory. For APIs and storage, RFC 3339 is what you actually want β€” when someone says 'just use ISO 8601' they nearly always mean RFC 3339. The rule that prevents bugs: never emit a timestamp without an offset. A bare '2026-07-08T14:30:00' is a different moment in every timezone that reads it, and each parser will guess differently.

How do I schedule something for '9 AM in the user's timezone'?

Store the recurrence as wall-clock time plus IANA zone ('09:00, Australia/Sydney'), then compute each occurrence's UTC instant shortly before it happens using an up-to-date tzdata β€” never precompute months of UTC instants in advance. Two forces make the naive approaches fail: DST shifts the UTC equivalent of 9 AM twice a year in most inhabited zones, and governments change the rules themselves β€” tzdata ships multiple updates a year, sometimes with only weeks of notice. This is also why 'daily at 9 AM' cron jobs on UTC servers drift an hour off local expectations every spring and fall. Compute late, keep tzdata patched, and test the two transition days explicitly.

Share this post: