Timezone Handling in Software: DST, Offsets, and Common Bugs

9 min2026年6月3日

Why Timezones Are Harder Than You Think

Most developers assume there are 24 timezones — one per hour offset from UTC. In reality, there are 38 distinct UTC offsets in use today, including half-hour and quarter-hour variations. India uses +05:30, Nepal uses +05:45, and the Chatham Islands use +12:45. A naive "hours from UTC" model breaks immediately when you encounter these offsets, and roughly a billion people live in regions that use non-integer-hour offsets.

Timezones are political decisions, not geographic ones. China spans enough longitude for five time zones but uses a single offset (UTC+08:00) nationwide. Spain is geographically aligned with the UK but uses Central European Time (+01:00) because Franco aligned with Germany in 1940 — and that decision persists. Indiana had counties on different timezone rules until 2006. The mapping from longitude to timezone is arbitrary and subject to political change at any moment.

Timezone rules change more often than you expect. The IANA timezone database (tzdata) — the authoritative source used by operating systems, programming languages, and databases — receives multiple updates per year. In 2023 alone there were 7 releases. Governments announce DST changes with as little as a few weeks of lead time (Egypt reintroduced DST in 2023 with minimal notice). Morocco adjusts its DST schedule around Ramadan each year, making transitions unpredictable years in advance.

This means software that handles future dates must treat timezone rules as mutable runtime data, not compile-time constants. A recurring event scheduled for "9 AM local time" next year might correspond to a different UTC offset than it does today if the local government changes DST rules between now and then. Systems that pre-compute UTC timestamps for future events without storing the original timezone intent will silently produce wrong results after a tzdata update.

UTC Offsets vs IANA Timezone Names

A UTC offset like +05:30 tells you the current clock difference from UTC at one specific instant. It does not tell you whether DST will shift that offset in six months, what the offset was last year, or which historical rules apply to the region. Two locations can share the same offset today but diverge tomorrow. In winter, America/New_York is -05:00 and America/Bogota is also -05:00. But New York shifts to -04:00 in summer while Bogota stays at -05:00 year-round. Knowing only "-05:00" makes it impossible to predict the summer offset.

IANA timezone names like "Asia/Kolkata" or "America/Los_Angeles" encode the complete history and future rules for a specific region. They reference a lookup table that knows when DST starts and stops (if it applies at all), what the offset was before the country last changed its rules, and how to convert any timestamp — past, present, or future — to the correct local time. This is why timezone handling guidance always reduces to: store the IANA name, not just the offset.

A common mistake in API design: returning timestamps with an offset (e.g., "2026-06-04T09:00:00+05:30") and assuming the client can reconstruct timezone-aware behavior from that. The offset captures a snapshot, not an identity. If you need to display "this event recurs at 9 AM in the user's timezone," you need to know the timezone is "Asia/Kolkata" — not merely that the offset happened to be +05:30 when the event was first created.

Storing offsets alone also fails for historical queries. Russia has changed its UTC offset multiple times — most recently in 2014 when it abolished DST and fixed at permanent winter time. If you stored "+04:00" for a Moscow user in 2012 (when Russia was on permanent summer time), that becomes wrong in 2014 when Moscow moved to +03:00. A stored "Europe/Moscow" IANA name handles all eras correctly through the database rules. A stored "+04:00" cannot adapt.

Daylight Saving Time: The Bugs It Creates

Spring forward creates non-existent times. On March 10, 2024 at 2:00 AM in the US Eastern timezone, clocks jumped directly to 3:00 AM. The timestamp "2024-03-10T02:30:00 America/New_York" never existed on any clock. If a user schedules an alarm, a cron job, or a recurring meeting at 2:30 AM on that specific day, what should the system do? Some implementations throw an error. Others silently pick 1:30 AM or 3:30 AM. Most frameworks advance to the next valid time (3:00 AM), but the behavior varies across languages and libraries.

Fall back creates ambiguous times. On November 3, 2024 at 2:00 AM Eastern, clocks moved back to 1:00 AM. Every local time between 1:00 AM and 2:00 AM occurred twice — once in EDT (-04:00) and once in EST (-05:00). If a log entry says an event happened at "2024-11-03T01:30:00 America/New_York," you cannot determine the exact UTC instant without additional disambiguation. Was it the first 1:30 AM or the second? Systems that store only local time lose this information permanently.

Scheduled tasks and cron jobs fail silently during DST transitions. A cron job set to run at "2:30 AM daily" will skip entirely on spring-forward day (the time does not exist) and may run twice on fall-back day (the time occurs twice). Production outages from this pattern are common enough that operations teams in DST-observing regions routinely verify scheduled job execution on transition days. The safer pattern: run recurring jobs on a UTC schedule, or use a scheduler that understands timezone transitions and applies a disambiguation policy.

DST date changes have caused widespread incidents. In 2007, when the US moved its DST start date from the first Sunday in April to the second Sunday in March, systems running outdated timezone data "lost" an hour of appointments. Medical scheduling systems showed patients at the wrong time slots. Airlines dealt with booking anomalies. Every time a government changes DST rules, a cohort of systems with stale tzdata breaks in the same predictable way — events shift by one hour in one direction.

Converting Between Timezones Correctly

The fundamental rule: always route conversions through UTC. To convert "3 PM in Tokyo" to "the equivalent time in London," the correct path is: Tokyo local time → UTC → London local time. Never convert directly between two non-UTC zones by subtracting their offsets, because that requires knowing the offset relationship at that specific instant — a calculation that only works if you account for whether either zone is currently in DST.

Store timestamps in UTC at the point of capture, and convert to local time only at display time. This applies to logs, event records, audit trails, and any "when did this happen" data. UTC has no DST transitions, no political changes, and sorts chronologically without conversion. Your database stores UTC. Your API transmits UTC. Your frontend converts to the viewer's timezone for display. This pattern eliminates an entire class of comparison and sorting bugs.

Common conversion mistakes include: converting twice (applying an offset that was already applied), losing sub-second precision during string formatting roundtrips, and assuming a fixed offset for a named timezone. The most insidious bug: constructing a Date object from a formatted string without specifying the timezone, letting the runtime use its local timezone (which might be different in development, CI, and production). Always be explicit about timezone at every parsing boundary.

The Intl.DateTimeFormat API in JavaScript provides correct timezone conversion by delegating to the system IANA database. You construct a formatter with a timeZone option and format a Date (which is internally UTC). This gives you the correct local representation in any timezone the system knows about, without manual offset arithmetic. For programmatic access to the parts (year, month, day, hour), use formatToParts() which returns structured data you can reassemble.

Scheduling Across Timezones

There are two fundamentally different meanings of "schedule this for 3 PM." One means "3 PM in the user's local timezone, wherever they are" — a human-centric wall-clock time. The other means a fixed instant on the UTC timeline that happens to display as 3 PM in some zone. Confusing these two is the root cause of most scheduling bugs. Calendar applications use the first model (wall-clock intent). Batch processing and deployment systems typically use the second (fixed instants).

Meeting planners must work across multiple timezones simultaneously, and DST makes coordination treacherous. A weekly meeting at "10 AM New York time" shifts relative to London twice a year, because the US and UK change clocks on different dates (US in March/November, UK in March/October). For approximately three weeks in March and one week in November, the relative offset is 4 hours instead of the usual 5. Any system displaying "this meeting is at 3 PM your time" must recompute that display for each occurrence, not cache it.

Recurring events that span DST transitions require careful modeling. If your team has a daily standup at 9 AM America/Chicago, the UTC equivalent shifts from 15:00 to 14:00 when CDT starts in spring. The correct approach: store the recurrence rule as local time plus timezone name (09:00 + America/Chicago), and compute the next UTC occurrence on demand using current tzdata. Never pre-compute and store a series of UTC timestamps for future recurrences — those go stale when DST rules change.

Flight departure and arrival times illustrate the distinction between local intent and UTC instants. A flight departing Tokyo at 10:00 AM JST and arriving Los Angeles at 6:00 AM PDT "the same day" actually moved forward in time (10:00 AM + 9.5 hours flight) but moved backward on the clock (crossing the date line). Airlines store departure in the departure city's timezone and arrival in the arrival city's timezone. The elapsed duration is calculated by converting both to UTC and subtracting.

Database Storage: Best Practices

PostgreSQL's TIMESTAMPTZ type is the correct default for "when did this happen" columns. Despite its name suggesting it stores a timezone, it actually converts all input to UTC for internal storage and converts back to the session timezone on output. This means values are stored consistently and can be compared correctly regardless of the inserting session's timezone setting. The plain TIMESTAMP (without timezone) stores the literal numeric values you provide — no conversion, no normalization, no consistency guarantees across differently-configured sessions.

MySQL's TIMESTAMP type auto-converts to UTC on storage and back to the connection timezone on retrieval, similar to PostgreSQL TIMESTAMPTZ. Its DATETIME type stores the literal value with no conversion, similar to PostgreSQL TIMESTAMP. Use TIMESTAMP for event times, audit trails, and anything representing a moment in time. Use DATETIME only for values where you intentionally want to store the face value without timezone logic — like "this coupon expires at midnight on 2027-01-01" where midnight is defined by business rules, not a specific UTC instant.

Store the user's IANA timezone name in a separate column — never embedded in the timestamp itself. A schema like (created_at TIMESTAMPTZ, user_timezone TEXT) gives you full flexibility: display the time in the user's timezone, recompute if timezone rules change, and still sort and compare in UTC. The anti-pattern: storing pre-formatted strings like "June 4, 2026 3:00 PM EDT." These cannot be sorted numerically, cannot be recalculated for other timezones, and embed locale assumptions (English language, 12-hour clock format) into your data layer.

When migrating legacy data that has timezone-naive timestamps, you must determine what timezone the application server was in when those records were created. This is often undocumented and sometimes inconsistent (if the server moved between clouds or datacenters). The safest approach: add a migration note documenting your assumption ("all timestamps before 2024-03-01 assumed to be America/Chicago based on known server location"), convert to UTC based on that assumption, and store in a TIMESTAMPTZ column going forward.

JavaScript Date vs Temporal API vs Libraries

The built-in Date object in JavaScript has well-documented problems for timezone work. It represents instants (milliseconds since Unix epoch) but exposes local-time getters (getHours(), getMonth()) that depend on the runtime's system timezone. There is no way to construct a Date "in" a specific timezone — you can only parse UTC strings (with Z suffix) or local-time strings. The month is 0-indexed. Parsing a date-only string ("2026-06-04") uses UTC, but parsing a datetime string ("2026-06-04T00:00:00") uses local time. These inconsistencies create bugs in every codebase that touches dates.

The Temporal API (TC39 Stage 3 as of 2026) is designed to replace Date for timezone-aware programming. It introduces distinct types for different concepts: Temporal.Instant (a point on the UTC timeline), Temporal.ZonedDateTime (an instant pinned to a timezone), Temporal.PlainDate (a calendar date with no timezone), and Temporal.PlainTime (a wall-clock time with no date). This type separation prevents the category errors that plague Date-based code. Browser implementations are landing throughout 2026, and polyfills exist for production use today.

Until Temporal ships in all target environments, date-fns-tz and Luxon are the leading timezone-aware libraries. date-fns-tz works with native Date objects and adds timezone conversion functions (zonedTimeToUtc, utcToZonedTime, format with timezone token). It is tree-shakeable and adds minimal bundle weight. Luxon (from a Moment.js maintainer) provides its own DateTime class with built-in timezone support, immutable operations, and Intl-based formatting. Both rely on the system IANA timezone data rather than bundling their own.

Choosing in 2026: use date-fns-tz if you want minimal bundle size and are comfortable with native Dates augmented by utility functions. Use Luxon if you prefer a self-contained DateTime type with method chaining and can accept the extra weight (~70KB minified). Use the Temporal polyfill if starting a new project and can accept minor spec changes before final standardization. For server-side Node.js, all three work — Temporal is available natively in Node 22+ behind a flag. Day.js with its timezone plugin is another lightweight option, though its timezone support is less comprehensive than Luxon's.

// Converting between timezones — always go through UTC

// Using Intl.DateTimeFormat (works in all modern browsers and Node.js)
function formatInTimezone(utcDate, timezone) {
  return new Intl.DateTimeFormat('en-US', {
    timeZone: timezone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
    timeZoneName: 'short',
  }).format(utcDate);
}

// Start with a UTC instant
const event = new Date('2026-06-04T14:00:00Z');

console.log(formatInTimezone(event, 'America/New_York'));
// → "06/04/2026, 10:00:00 EDT"

console.log(formatInTimezone(event, 'Asia/Tokyo'));
// → "06/04/2026, 23:00:00 JST"

console.log(formatInTimezone(event, 'Asia/Kolkata'));
// → "06/04/2026, 19:30:00 GMT+5:30"

console.log(formatInTimezone(event, 'Pacific/Chatham'));
// → "06/05/2026, 02:45:00 GMT+12:45"

// Using Temporal API (Stage 3 — available via polyfill or Node 22+)
// const instant = Temporal.Instant.from('2026-06-04T14:00:00Z');
// const inTokyo = instant.toZonedDateTimeISO('Asia/Tokyo');
// console.log(inTokyo.toString());
// → "2026-06-04T23:00:00+09:00[Asia/Tokyo]"
//
// const inNewYork = inTokyo.withTimeZone('America/New_York');
// console.log(inNewYork.toString());
// → "2026-06-04T10:00:00-04:00[America/New_York]"

Edge Cases: Half-Hour Offsets, Historical Changes, Abolished Zones

India (UTC+05:30) and Nepal (UTC+05:45) demonstrate that offsets are not restricted to whole hours or even half hours. The Chatham Islands off New Zealand use UTC+12:45 in standard time and UTC+13:45 during their daylight saving period. Lord Howe Island (Australia) is UTC+10:30 in winter and UTC+11:00 in summer — gaining only 30 minutes during DST instead of the typical 60. These offsets break systems that store timezone data as a signed integer of hours, or that assume offsets are always multiples of 30 minutes.

Samoa (the independent state, not American Samoa) skipped December 30, 2011 entirely. At midnight on December 29, clocks jumped directly to December 31 as the country crossed the International Date Line — moving from UTC-11:00 to UTC+13:00. This aligned Samoa with Australia and New Zealand for business purposes. Any date arithmetic library that cannot handle a missing calendar day for a specific timezone has a latent bug. The IANA database encodes this transition correctly under "Pacific/Apia."

Countries abolishing or adopting DST is a regular occurrence and each change invalidates cached assumptions. Russia abolished DST in 2011 (fixing at summer time +04:00), then switched to permanent winter time in 2014 (+03:00 for Moscow). Turkey abolished DST in 2016, fixing at UTC+03:00 year-round. Argentina has changed its DST policy multiple times across different decades. Each change triggers an IANA database update, and systems running stale timezone data produce wrong results for affected regions. Automating tzdata updates is a correctness requirement for globally-deployed applications.

Historical timezone changes extend further back than most developers realize. Before standardized timezones (adopted between 1884 and the 1950s depending on country), every city kept its own solar mean time based on local noon. The IANA database tracks these historical offsets — "America/New_York" has records going back to 1883 when US railroads imposed standard time zones. If your application handles historical dates (shipping records, archival data, genealogy research), these pre-standardization offsets matter. A timestamp from 1920 in a given city may use a UTC offset that differs by minutes from the modern value for that same location.