By Ether DataRequest data sample
All sections

DST Handling

Daylight saving time creates a nonexistent local time at spring-forward and an ambiguous one at fall-back; both must be resolved by a declared, explicit policy rather than a hardcoded transition hour.

stable5 min read
Source time
local_datetime, iana_zone
Destination time
instant

Purpose

Daylight saving time (DST) breaks the assumption that local wall-clock time maps one-to-one onto UTC instants. Twice a year, in every zone that observes it, the mapping becomes either undefined (a wall time that never occurs) or two-valued (a wall time that occurs twice). Every local-to-slot conversion in this KB — timestamp-to-slot, broadcast day, daypart-to-slots — must detect these two conditions explicitly and apply a declared policy, never a silent guess.

The spring-forward gap

When clocks move forward (e.g. America/New_York, 2026-03-08: 02:00 local jumps straight to 03:00), every wall time in the skipped hour — 02:00 through 02:59 — does not exist as a local reading in that zone on that date. Only one true instant exists on the far side of the gap.

The fall-back fold

When clocks move back (e.g. America/New_York, 2026-11-01: 02:00 local becomes 01:00 again), every wall time in the repeated hour — 01:00 through 01:59 — occurs twice: once at the pre-transition (larger) UTC offset and once at the post-transition (smaller) offset. These are two distinct UTC instants, one hour apart, that read identically on a local clock.

Detection and policy

import { localToSlot } from "@/lib/time/slot";

// GAP: 2026-03-08 02:30 does not exist in America/New_York.
const gap = localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, "America/New_York", "earliest");
// -> { wasNonexistent: true, wasAmbiguous: false, disambiguationApplied: "earliest",
//      utc: "2026-03-08T07:30:00.000Z", offsetMinutes: -240 }
// Only one valid instant exists on either side of the gap; both "earliest" and
// "latest" resolve to it (the post-transition instant, EDT/-04:00).

// FOLD: 2026-11-01 01:30 occurs twice in America/New_York.
const earliest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, "America/New_York", "earliest");
// -> wasAmbiguous: true, offsetMinutes: -240 (EDT, pre-transition, FIRST occurrence)

const latest = localToSlot({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, "America/New_York", "latest");
// -> wasAmbiguous: true, offsetMinutes: -300 (EST, post-transition, SECOND occurrence)
// Same wall time, same zone -> two different UTC instants one hour apart,
// and therefore two different hour-of-week slots.

// REJECT: refuse to silently pick, useful where the caller must be forced to disambiguate.
try {
  localToSlot({ year: 2026, month: 3, day: 8, hour: 2, minute: 30 }, "America/New_York", "reject");
} catch (e) {
  // "Nonexistent local time (spring-forward gap): ... is skipped."
}

localToSlot returns wasNonexistent and wasAmbiguous flags plus disambiguationApplied on every call, so a gap or fold is never invisible even when a default policy quietly resolved it — the requested-vs-executed record makes the disambiguation auditable (see Requested vs Executed Time).

Never hardcode the transition

02:00 is a US convention, not a rule

It is tempting to hardcode "DST transitions happen at 02:00 local." They do not, universally. The transition hour and date vary by zone: some transition at 00:00, 01:00, or 03:00 local, or at 23:00 the prior day; dates differ by country even within the same broad region; and the Southern Hemisphere transitions in the opposite calendar months from the Northern Hemisphere (Australia's DST begins in October and ends in April). Always resolve transitions through the IANA tz database (luxon here), never a constant.

Partial-hour shifts

Not every DST transition moves the clock by a full hour. Lord Howe Island (Australia) shifts by only 30 minutes (+10:30 standard to +11:00 DST), and several historical transitions elsewhere used 20- or 40-minute shifts. A 30-minute gap or fold is genuinely half an hour of nonexistent or ambiguous wall time, not a full slot's worth — compute the shift magnitude from the tz database's actual transition data rather than assuming 60 minutes, since a policy built for a one-hour fold will misapportion a 30-minute one.

Reversed and absent DST

Southern-Hemisphere reversed DST: because the DST calendar flips by hemisphere, the same UTC slot corresponds to a different local season (and often a different local hour) in Sydney versus New York at the same time of year — local-experience comparisons (see Measurement Semantics) must never assume a shared DST calendar across hemispheres. Non-DST region inside a DST country: Arizona observes no DST while the rest of US Mountain time does; Queensland differs from New South Wales within Australia. A "Mountain Time" or country label is ambiguous for roughly half the year in these cases — resolve by the specific IANA zone (America/Phoenix vs America/Denver), never by a country or a generic offset name.

Quality and provenance

Every conversion through a gap or fold should carry, at minimum: wasNonexistent, wasAmbiguous, disambiguationApplied, and the resolved offsetMinutes — enough for a downstream consumer to know not just which instant was chosen but that a choice was necessary at all. lossless (in lib/time/provenance.ts) is false whenever either flag is set, marking the conversion as one where requested and executed cannot both hold exactly.

Edge cases

Spring-forward gap and fall-back fold are this page's core subject. Partial-hour DST shift, DST transition time varies by zone, Southern-Hemisphere reversed DST, and non-DST region inside a DST country are the specific failure modes of assuming a single, universal DST rule.

Python parity

Python's zoneinfo + datetime handle the same two hazards via the fold attribute (PEP 495) rather than a returned flag: fold=0 selects the first (pre-transition) occurrence of an ambiguous fold, fold=1 selects the second, and a nonexistent (gap) time is silently normalized forward when .astimezone() is called on it.

from datetime import datetime
from zoneinfo import ZoneInfo

zone = ZoneInfo("America/New_York")

# FOLD: fold=0 = earliest (EDT, pre-transition); fold=1 = latest (EST, post-transition).
earliest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0)
latest = datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1)
print(earliest.utcoffset(), latest.utcoffset())  # -4:00:00 then -5:00:00

# GAP: 2026-03-08 02:30 does not exist; .astimezone() resolves it forward.
nonexistent = datetime(2026, 3, 8, 2, 30, tzinfo=zone)
resolved = nonexistent.astimezone(ZoneInfo("UTC"))

fold is Python's disambiguation policy equivalent to this KB's "earliest"/"latest" parameter; there is no built-in "reject" behavior in zoneinfo — an application that needs to refuse ambiguous input must detect the fold explicitly (compare the UTC offsets at fold=0 and fold=1; if they differ, the wall time is ambiguous) before deciding, the same detection localToSlot performs internally. The tested reference implementation remains the TypeScript in lib/time/slot.ts.

Edge cases affecting this page

Timestamp to SlotCanonical slotTimezone DatabaseTime systems & zones