Timezone & DST Safety — UTC Discipline and Library Guide
Complete reference for timezone-safe datetime handling. Read when auditing TZ/DST code or selecting a library.
The UTC Discipline
A single rule: store UTC, render local.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Input │───▶│ Parse to │───▶│ Store as │───▶│ Render in │
│ (user input │ │ UTC │ │ UTC │ │ user's TZ │
│ or event) │ │ │ │ │ │ at the edge │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
↑ │
└────────────── round-trip should be lossless ──────────┘Storage rules
- PostgreSQL: Always
timestamptz(=timestamp with time zone). Internally stored as UTC; converts to session TZ on SELECT. Never usetimestamp(no TZ) for event times — it silently loses the TZ on write. - MySQL: No native TZ-aware type. Use
DATETIME+ application-level UTC convention, orTIMESTAMP(which auto-converts viatime_zonesession variable — footgun). Store explicitly in UTC. - SQLite: No native type; store ISO 8601 UTC strings or Unix epoch integer.
- MongoDB:
DateBSON type is UTC milliseconds since epoch. Safe by default; be careful with driver auto-conversions. - Redis: Store as Unix epoch seconds/milliseconds; strings preserve what you write.
Wire-format rules
- REST/JSON: ISO 8601 with offset:
2026-04-22T10:00:00+09:00or2026-04-22T01:00:00Z. Never emit a TZ-naive string (2026-04-22 10:00:00) — ambiguous. - gRPC/Protobuf: Use
google.protobuf.Timestamp(UTC; seconds + nanos since epoch). - Kafka/events: Store epoch milliseconds in payload; include a separate
occurred_at_tzfield if the local time matters semantically (e.g., "user's local workday").
Rendering rules
- Determine user TZ from:
- User profile preference (explicit > implicit).
- Browser:
Intl.DateTimeFormat().resolvedOptions().timeZone(returns IANA name likeAsia/Tokyo). - Fallback: UTC + explicit label (never silently default to server TZ).
- Render with locale-aware formatter:
Intl.DateTimeFormat, LuxonDateTime.toLocaleString, Pythonbabel.
IANA tz Database
The canonical source for timezone rules is the IANA tz database (tzdata), updated several times per year as governments change DST rules.
Naming
- Format:
Region/CityorRegion/Subregion/City. - Examples:
Asia/Tokyo,America/New_York,Europe/London,Pacific/Auckland,America/Argentina/Buenos_Aires. - Deprecated names (still work, but avoid):
Asia/Calcutta→Asia/Kolkata;Europe/Kiev→Europe/Kyiv.
Why abbreviations are dangerous
| Abbreviation | Possible meanings |
|---|---|
CST |
Central Standard Time (US, UTC-6) OR China Standard Time (UTC+8) OR Cuba Standard Time (UTC-5) |
IST |
India Standard Time (UTC+5:30) OR Irish Standard Time (UTC+1) OR Israel Standard Time (UTC+2/+3) |
EST |
Eastern Standard Time (US, UTC-5) OR Eastern Standard Time (Australia, UTC+10) |
BST |
British Summer Time (UTC+1) OR Bangladesh Standard Time (UTC+6) |
Never accept user input as an abbreviation. Always normalize to IANA or reject.
tzdata updates
- Distributed via OS packages (
tzdataon Debian/Ubuntu), PythonzoneinfoviatzdataPyPI package on Windows, JVM$JAVA_HOME/lib/tzdb.dat, browser/Node.js via embedded ICU. - Container gotcha: A long-lived container may ship with stale tzdata. Update base image or mount
/etc/timezone+/usr/share/zoneinfofrom host. - Schedule a quarterly review of tzdata version in production.
2026 DST Policy Watch
DST 2026 in the US runs 2026-03-08 (forward) → 2026-11-01 (back). Most of Arizona and all of Hawaii stay on standard time year-round.
US Sunshine Protection Act (S.29 / H.R.139, 119th Congress, 2025-2026): The House Energy and Commerce Committee advanced the proposal as part of the Motor Vehicle Modernization Act in May 2026, with President Trump publicly supporting permanent DST. As of 2026-05-23, the bill has not been signed into law — DST transitions still apply until IANA tzdata reflects any change. Sources: Congress.gov S.29, Time.com 2026-05-22.
EU: The European Parliament voted to end DST in 2019, but the Council of the EU remains deadlocked — member states cannot agree on permanent summer vs standard time. As of March 2026, the European Commission considers it the Member States' responsibility to reach a common position; clock changes continue in all EU member states in 2026. Source: timeanddate.com EU DST status.
IANA tzdata 2026a (released 2026-04-22): corrects Moldova's upcoming clock transition. Also note: tzdata 2025c added America/Coyhaique (Chile's Aysén Region, now permanent -03); tzdata 2025a reflected Paraguay adopting permanent -03. Source: IANA tz-announce 2026a.
Operational rule until the law changes:
- Treat permanent DST / permanent standard time as possible future tzdata changes, never as current truth. A scheduler that hard-codes "US/Eastern shifts twice a year" must keep doing so until the IANA
tzdatarelease flips it. - Subscribe to the IANA
tz-announcemailing list (lists.iana.org/hyperkitty/list/tz-announce@iana.org/). Treat a tzdata release that flips a major-country DST rule as an emergency-grade update — re-deploy containers, refresh JDKtzdb.dat, refresh browser bundles. Do NOT wait for the next base-image refresh cycle. - Audit hard-coded "spring/fall" boundary checks at the application layer — these become silently wrong the moment a jurisdiction abolishes DST. The correct check is always
zoneinfolookup, never a hand-rolledif month == 3.
DST Boundary Pitfalls
Spring-forward (gap)
At DST start, the clock jumps forward — typically 02:00 → 03:00 local. The wall-clock interval 02:00–02:59 does not exist.
Example: America/New_York, 2026-03-08 (second Sunday of March):
- 01:59:59 EST (UTC-5)
- 03:00:00 EDT (UTC-4) ← jumped; 02:00-02:59 never happens
A schedule set for 02:30 daily:
- Most days: fires at 02:30 local.
- Mar 8, 2026: undefined unless the scheduler has an explicit policy.
Library behavior:
- Python
zoneinfo: Constructingdatetime(2026, 3, 8, 2, 30, tzinfo=ZoneInfo("America/New_York"))succeeds, but the resulting instant corresponds to 03:30 EDT (the "next valid" interpretation). To reject, usefold=0and checkdatetime.utcoffset()consistency. - Temporal API:
Temporal.ZonedDateTime.from({...}, {disambiguation: 'reject'})throws. - Luxon:
DateTime.fromObject({...}, {zone: 'America/New_York'}).isValidreturns false with reason'unsupported_zone';.invalidReason === 'unsupported_zone'or similar. - Quartz: Skips the missing hour; fires at the next valid time (03:30 on the transition day).
Fall-back (overlap)
At DST end, the clock goes back — typically 02:00 → 01:00 local. The interval 01:00–01:59 happens TWICE.
Example: America/New_York, 2026-11-01 (first Sunday of November):
- 01:59:59 EDT (UTC-4)
- 01:00:00 EST (UTC-5) ← the hour repeats
A schedule set for 01:30 daily:
- Most days: fires at 01:30 local.
- Nov 1, 2026: fires twice unless guarded.
Library behavior:
- Python
zoneinfo: Defaultfold=0picks the EARLIER (EDT) instance;fold=1picks the LATER (EST) instance. - Temporal API:
disambiguationoption:'earlier'(EDT),'later'(EST),'compatible'(POSIX: earlier for gaps, later for overlaps),'reject'. - Luxon: No direct disambiguation for overlap in older versions; recent versions respect
keepCalendarTime. - Quartz: Fires once (at the first valid occurrence — the EDT instance).
Resolution strategies
| Strategy | Applies to | Python | Temporal | Luxon |
|---|---|---|---|---|
| Earlier instance (overlap) | Fall-back | fold=0 |
disambiguation: 'earlier' |
zone default |
| Later instance (overlap) | Fall-back | fold=1 |
disambiguation: 'later' |
explicit set |
| Skip (gap) | Spring-forward | not applicable | disambiguation: 'reject' |
.invalid |
| Clamp forward (gap) | Spring-forward | construct normally | disambiguation: 'compatible' |
default |
| Reject | Both | manual check | disambiguation: 'reject' |
check .isValid |
Zones with unusual DST rules
- Australia:
Australia/Lord_Howehas a 30-minute DST shift (not 60). Wall-clock arithmetic assumptions break. - Antarctica:
Antarctica/Trollshifts by 2 hours at DST. - Chile:
America/SantiagoDST at midnight — a schedule at 00:30 can skip. - No DST:
Asia/Tokyo,Asia/Shanghai,Asia/Kolkata(+5:30),Pacific/Honolulu. Safe for wall-clock scheduling.
Temporal API (ECMAScript)
ECMAScript Stage 4 (TC39 advanced 2026-01-20; included in ES2026 spec). Ships natively in Firefox 139+, Chrome 144+, Edge 144+, and Node.js 26 (unflagged). Safari has not shipped as of May 2026. For older runtimes, use polyfill @js-temporal/polyfill. Sources: TC39 Stage 4 announcement, Node.js 26 release notes.
Types
| Type | Represents | Use for |
|---|---|---|
Temporal.Instant |
A specific moment in time, UTC-ish | Event timestamps, DB primary keys |
Temporal.PlainDate |
Calendar date, no time, no TZ | Birthdays, deadlines |
Temporal.PlainTime |
Wall time, no date, no TZ | Daily alarm times |
Temporal.PlainDateTime |
Date + time, no TZ | Local-only events (meeting "3pm", TZ TBD) |
Temporal.ZonedDateTime |
Instant + TZ + calendar | User-facing schedules |
Temporal.Duration |
Amount of time | Retry delays, intervals |
Example: ambiguous-time handling
import { Temporal } from '@js-temporal/polyfill';
// Spring-forward day in NY
const zdt = Temporal.ZonedDateTime.from({
year: 2026, month: 3, day: 8,
hour: 2, minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'reject' });
// throws RangeError
const deferred = Temporal.ZonedDateTime.from({
year: 2026, month: 3, day: 8,
hour: 2, minute: 30,
timeZone: 'America/New_York',
}, { disambiguation: 'compatible' });
// ZonedDateTime 2026-03-08T03:30:00-04:00[America/New_York]Best practice for new JS/TS projects
- Use
Temporal.ZonedDateTimefor scheduled events. - Use
Temporal.Instantfor storage and transport. - Convert to
Temporal.PlainDate/PlainTimeonly when the TZ is genuinely irrelevant.
Luxon
Mature alternative to Temporal; production-ready today. DateTime.setZone(zone, {keepLocalTime?}) is the workhorse.
import { DateTime } from 'luxon';
const now = DateTime.now().setZone('Asia/Tokyo');
const utc = now.toUTC();
const fromISO = DateTime.fromISO('2026-04-22T10:00:00+09:00', { setZone: true });Common mistakes
DateTime.local()uses the runtime's system TZ — avoid; useDateTime.now().setZone(...)explicitly..setZone('X', { keepLocalTime: true }): changes the TZ but reinterprets the wall-clock at the new TZ (12:00 JST → 12:00 EST, different instant). WithoutkeepLocalTime, the instant is preserved and only the display changes.- Omitting
{ setZone: true }onfromISOcauses the result to be in the runtime TZ after parsing. DateTime.fromJSDate(new Date())loses any TZ info —Dateis always UTC instant.
date-fns v4 + @date-fns/tz
date-fns v4.0 (released September 2024) added first-class timezone support via two new packages: @date-fns/tz (IANA timezone support) and @date-fns/utc (UTC helpers). These replace the older date-fns-tz companion. Source: date-fns v4.0 announcement.
import { format, formatISO } from 'date-fns';
import { TZDate, tz } from '@date-fns/tz';
// Create a date in a specific timezone
const tokyoDate = new TZDate(2026, 3, 22, 10, 0, 'Asia/Tokyo');
// Format with timezone context
format(tokyoDate, 'yyyy-MM-dd HH:mm:ssXXX');
// Use tz() helper to pass timezone to date-fns functions
format(new Date(), "yyyy-MM-dd HH:mm", { in: tz('Asia/Tokyo') });Migration from date-fns-tz
The date-fns-tz companion still works with date-fns v3 but is superseded by @date-fns/tz for date-fns v4+.
| date-fns-tz (legacy) | @date-fns/tz (v4+) |
|---|---|
formatInTimeZone(date, tz, fmt) |
format(date, fmt, { in: tz(zone) }) |
zonedTimeToUtc(str, tz) |
new TZDate(str, zone).toDate() |
utcToZonedTime(date, tz) |
new TZDate(date, zone) |
Legacy date-fns-tz notes (for pre-v4 codebases)
utcToZonedTimereturns aDatewhose numeric value is shifted — do NOT.toISOString()the result. UseformatInTimeZonefor rendering instead.zonedTimeToUtcinterprets string inputs with the given TZ; usenew Date(iso)for ISO 8601 strings with explicit offset.
Moment.js — Legacy
Do not use in new code. Moment is in maintenance mode since 2020:
- Mutable API —
moment().add(1, 'day')mutates; surprising bugs. - Large bundle (~70KB minified + moment-timezone data).
- Not tree-shakable.
- Official recommendation: migrate to Luxon, date-fns-tz, Day.js, or Temporal.
Migration targets
| From Moment | To Luxon | To date-fns-tz |
|---|---|---|
moment().tz('Asia/Tokyo') |
DateTime.now().setZone('Asia/Tokyo') |
utcToZonedTime(new Date(), 'Asia/Tokyo') |
moment(iso).utc() |
DateTime.fromISO(iso).toUTC() |
new Date(iso) |
moment().format('YYYY-MM-DD') |
DateTime.now().toFormat('yyyy-MM-dd') |
format(new Date(), 'yyyy-MM-dd') |
moment(a).diff(b, 'hours') |
a.diff(b, 'hours').hours |
differenceInHours(a, b) |
Migrate file-by-file with regression tests around DST dates.
Python Libraries
zoneinfo (stdlib, 3.9+) — preferred
from datetime import datetime
from zoneinfo import ZoneInfo
tokyo = datetime(2026, 4, 22, 10, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
utc = tokyo.astimezone(ZoneInfo("UTC"))- On Windows,
zoneinforequires thetzdataPyPI package. fold=0|1ondatetimefor fall-back disambiguation.
pytz — legacy but common
import pytz
from datetime import datetime
tokyo = pytz.timezone("Asia/Tokyo")
# WRONG — does not apply DST correctly:
dt_wrong = datetime(2026, 4, 22, 10, 0, tzinfo=tokyo)
# RIGHT — use .localize() to attach TZ:
dt_right = tokyo.localize(datetime(2026, 4, 22, 10, 0))Replace with zoneinfo in new code.
Clock and Testing
Freeze / mock the clock in tests
| Language | Library | Example |
|---|---|---|
| Node.js | @sinonjs/fake-timers |
FakeTimers.install({ now: new Date('2026-03-08T06:30:00Z') }) |
| Python | freezegun |
@freeze_time("2026-03-08 06:30:00") |
| Java | Inject java.time.Clock |
Clock.fixed(Instant.parse("2026-03-08T06:30:00Z"), ZoneOffset.UTC) |
| Ruby | timecop |
Timecop.freeze(Time.utc(2026, 3, 8, 6, 30)) |
Required test scenarios for any scheduled code
- DST spring-forward day in the target TZ.
- DST fall-back day in the target TZ.
- Feb-29 in a leap year (2028, 2032).
- Year-end rollover (Dec 31 → Jan 1).
- Last day of 28/29/30/31-day months.
- Timezone with non-hour offset (India +5:30, Nepal +5:45).
- TZ with reverse DST (Southern Hemisphere — Australia's April / October).
User-preferred TZ detection in the browser
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// e.g., "Asia/Tokyo"Store this on the user profile at signup. Prefer an explicit setting over re-detecting on every request (users may travel).
Checklist: TZ audit
- Every
timestampcolumn istimestamptz(or explicit UTC convention documented). - No
datetime.now()/new Date()/time.time()without TZ awareness in scheduling code. - Every IANA TZ name (not abbreviation) stored and transported.
- DST policy documented for every local-wall-clock schedule.
- Tests cover at least one DST boundary per relevant TZ.
- tzdata version pinned in container images; refresh cadence documented.
- No Moment.js in new code; existing Moment usage has a migration ticket.
- User TZ preference stored, not inferred per-request.
Library Choice Matrix (SKILL.md excerpt)
| Library | State | Recommendation |
|---|---|---|
| Temporal API | ECMAScript Stage 4 (ES2026); native in Node 26+, Firefox 139+, Chrome 144+; polyfill @js-temporal/polyfill |
New TS/JS code — preferred |
| Luxon | Mature, IANA-aware | Excellent for current production JS/TS |
date-fns v4 + @date-fns/tz |
v4.0 (Sep 2024) first-class TZ via @date-fns/tz / @date-fns/utc packages |
Preferred for date-fns codebases |
| date-fns-tz | Pre-v4 companion; @date-fns/tz is the successor |
Legacy — migrate on v4+ |
| Moment.js | Maintenance mode since 2020 | Do NOT use in new code |
Python zoneinfo |
Stdlib 3.9+, IANA-backed | Preferred over pytz |
| pytz | Footguns (use .localize(), not constructor) |
Replace with zoneinfo |