dst-transition-reference
Pure-reference catalog of Daylight Saving Time (DST) transition patterns and their canonical bug classes. Covers the spring-forward (skipped hour: 02:00 → 03:00 local) and fall-back (repeated hour: 02:00 → 01:00 local) transitions, the historical irregularity of DST (different jurisdictions, transitions on different dates, some regions abolish DST or never adopted it), the IANA timezone database (tz / Olson DB) as the canonical source, and the testable behaviors DST creates (duplicate / missing local timestamps, cron jobs that fire 0 or 2 times, billing periods that miss / double-count, recurring meetings on transition days). Per-jurisdiction DST-rule tables, refreshable per-region test-data fixtures, and the leap-second reference (23:59:60 insertion, time_t stalls, leap-smear vs step, monotonic-clock fixes) live in references/. Use when designing or auditing time-handling code or test cases, or when auditing leap-second assumptions.
Install with skills.sh (any agent)
npx skills add testland/qa --skill dst-transition-referencedst-transition-reference
Overview
DST transitions cause a large share of production time-bugs: spring-forward creates non-existent local times, fall-back creates duplicate local times. The IANA Time Zone Database (iana.org/time-zones (opens in new window)) is the canonical source of historical and current DST rules.
How to use this reference
When to use
DST mechanics
Spring-forward (skipped hour)
Per en.wikipedia.org/wiki/Daylight_saving_time (opens in new window), in US Eastern: on the 2nd Sunday of March, at 02:00 local time the clock jumps to 03:00. The 02:00-02:59 hour does not exist in local time.
Tests against 2026-03-08 02:30 America/New_York produce ambiguous or invalid results depending on library:
| Library | Behaviour at non-existent local time |
|---|---|
Python pytz (legacy) | pytz.exceptions.NonExistentTimeError |
Python zoneinfo (3.9+) | Returns the "would-be" time + 1h (=03:30 EDT) |
Java ZonedDateTime | Constructor takes a resolver: STRICT / SMART_BACKWARD / SMART_FORWARD |
| JS Intl | Browsers vary; often returns the post-transition time |
Fall-back (repeated hour)
In US Eastern: 1st Sunday of November at 02:00 local time, the clock falls back to 01:00. The 01:00-01:59 hour occurs twice - once as EDT (UTC-4), once as EST (UTC-5).
2026-11-01 01:30 America/New_York is ambiguous. Libraries either:
Worked example - a spring-forward assertion
Goal: prove the code under test handles a non-existent local time deterministically.
Per-jurisdiction differences
Per-region DST rules (US, EU, Australia, and the growing list of regions that abolished DST) and refreshable 2026 fixture timestamps live in references/jurisdictions-and-fixtures.md. Per IANA, rules change frequently - test against current zoneinfo, not assumptions.
Common bug classes
Cron jobs
A "daily at 02:30" cron in America/New_York:
Mitigation:
Billing periods
"Bill on the 1st of each month at 00:00 local time":
Mitigation: bill at UTC, or at a local hour known to be safe (e.g., 06:00).
Duration arithmetic
tomorrow_same_time = today_same_time + Duration("24 hours"):
Mitigation: distinguish "24 hours from now" (Duration) from "this time tomorrow" (calendar addition).
Recurring meeting
"Every Monday at 09:00 local time":
Storage
Storing wall-clock-local strings ("2026-03-08 02:30") is unsafe across DST. Always store UTC + zone identifier.
Testable behaviours
| Behaviour | Test |
|---|---|
| Code handles non-existent local time | Construct 2026-03-08 02:30 America/New_York; library raises or normalises; assert expected |
| Code handles ambiguous local time | Construct 2026-11-01 01:30 America/New_York; library raises or picks; assert |
| Cron-equivalent fires 0 / 1 / 2 times | Simulate clock across the transition; count invocations |
| Duration vs calendar addition consistent | Assert difference on transition day |
| Storage uses UTC + zone | Parse stored value; expect ISO format with offset or Z |
Per timezone-test-matrix-builder, the test matrix combines (zone, transition-type, library-version).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Storing local times as strings | Ambiguous on fall-back; nonexistent on spring-forward | UTC + zone, or RFC 3339 with explicit offset |
| Assuming all jurisdictions observe DST | Half the world doesn't | Per-zone testing |
| Using "24 hours" for "tomorrow" | Off by 1 hour on transition days | Calendar arithmetic primitives |
| Pinning to a specific year's transition date | Rules change annually | Use IANA zoneinfo dynamically |
Crossing DST with naive datetime | Behaviour undefined | Always tz-aware |
| Cron in local time without DST testing | Misses / duplicates jobs | Test transition days |
| Hardcoded UTC offset (-5:00) | Wrong when DST is in effect | Use zone identifier |
Limitations
References
Per-jurisdiction DST rules and test-data fixtures
View source (opens in new window)Per-jurisdiction DST rules and test-data fixtures
Deep reference for dst-transition-reference SKILL.md. Consult when choosing which zones to cover and for refreshable per-region fixture timestamps.
Per-jurisdiction differences
| Region | DST behaviour |
|---|---|
| US (most) | Spring-forward 2nd Sun March; fall-back 1st Sun November |
| EU | Last Sun March; last Sun October (one hour earlier) |
| Australia (most of NSW/VIC) | First Sun October; first Sun April (Southern hemisphere - reversed) |
| Australia (QLD, NT, WA, NT) | No DST |
| Japan, China, India | No DST |
| Russia | Abolished DST in 2011 |
| Iran | Abolished DST in 2022 |
| Mexico | Abolished mainland DST in 2022 |
| Brazil | Abolished DST in 2019 |
Per IANA: rules change frequently. Test against current zoneinfo, not assumptions.
Test data fixtures
Useful canonical timestamps per region (refresh against IANA):
| Region | Spring-forward 2026 | Fall-back 2026 |
|---|---|---|
| America/New_York | 2026-03-08 02:00 → 03:00 EDT | 2026-11-01 02:00 → 01:00 EST |
| Europe/London | 2026-03-29 01:00 → 02:00 BST | 2026-10-25 02:00 → 01:00 GMT |
| Australia/Sydney | 2026-10-04 02:00 → 03:00 AEDT | 2026-04-05 03:00 → 02:00 AEST |
These dates change year to year (some); commit a current fixture and refresh annually.
Leap-second mechanics and bug classes
View source (opens in new window)Leap-second mechanics and bug classes
A leap second is an extra second (23:59:60 UTC) inserted to keep UTC within 0.9s of UT1. Per IERS Bulletin C (datacenter.iers.org/data/latestVersion/bulletinC.txt (opens in new window)), insertions get ~6 months of notice. 27 were inserted 1972-2016 (most recent 2016-12-31); none since, and per the 27th CGPM resolution (2022) leap seconds will be abolished by 2035. Audit this surface only when second-granular progress matters: financial timestamping, distributed logs, NTP-sensitive schedulers.
Mechanics
| Property | Detail |
|---|---|
| Frequency | Irregular; announced by IERS Bulletin C |
| Insertion point | Last second of UTC June 30 or December 31 |
| Wire format | 23:59:60 UTC (a real 61st second of the minute) |
POSIX time_t | Does NOT include leap seconds; it stalls or jumps back 1s on insertion |
| NTP | Carries a leap indicator; client handling varies (2012 Linux kernel-hang incident) |
Whether a host inserts a real 23:59:60 or smears it over 24 hours (Google / AWS) is per-host - see smear-strategies-and-history.md (opens in new window). A smear is invisible to applications; a real insertion exposes the discontinuities below.
Bug classes
Testable behaviours
| Behaviour | Test |
|---|---|
| Durations use a monotonic clock | time.monotonic() deltas stay >= 0 across the leap |
| Sortable timestamps don't collide | Sequence numbers / sub-second resolution beside stalled time_t |
Cron at 00:00:00 UTC of leap day | Fires exactly once |
| Per-host absorption strategy known | Verify step vs smear per host before comparing cross-node timestamps |
Worked assertion - negative durations
start = time.monotonic()
do_work() # crosses 2016-12-31 23:59:60 UTC
elapsed = time.monotonic() - start
assert elapsed >= 0 # holds; the time.time() form can tripSimulate by pinning a fake clock to the last real leap second (freeze_time('2016-12-31 23:59:59 UTC') - see the fake-clock-testing skill) and advancing across it. Caveat: fake clocks can't replay the OS-level leap indication - this asserts the code's clock choice, not the kernel's behaviour; a real leap needs an OS-level test.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
time.time() - start for durations | Wall clock; affected by leap | time.monotonic() |
Treating time_t as continuous | Historical insertions broke it | Per IANA leap-seconds.list (opens in new window) |
| Hardcoding 86400 seconds-per-day | Only sometimes true | Calendar arithmetic |
| Assuming all servers smear | Some step | Verify per-host strategy |
References
Leap-smear strategies and the historical leap-second record
View source (opens in new window)Leap-smear strategies and the historical leap-second record
Deep reference for leap-seconds.md (opens in new window). Consult when comparing how platforms absorb a leap second and when you need the factual insertion history.
Leap-smear
Per Google's "Time, technology and leaping seconds": googleblog.blogspot.com/2011/09/time-technology-and-leaping-seconds.html (opens in new window), Google "smears" the leap second rather than stepping the clock. The published standard is a "24-hour linear smear from noon to noon UTC" (Google Public NTP: Leap Smear (opens in new window)), adding a small fraction to each second so the total adds up to 1 second of slowdown, with no actual 23:59:60.
Leap second strategy comparison:
| Approach | What happens |
|-----------------------|------------------------------------|
| IERS spec | 23:59:60 UTC inserted (real second)|
| Linux kernel default | Real insertion; time_t stalls 1s |
| Google leap-smear | Distributed over 24h |
| AWS leap-smear | Linear over 24h |
| NTP "step" | Jump 1s; subsequent time_t differs |The smear is operationally invisible to applications; the spec exposes the discontinuity.
Historical leap seconds
Per IERS, 27 leap seconds were inserted between 1972 and 2026. Most recent: 2016-12-31 23:59:60 UTC. None have been added since: IERS Bulletin C 72 (6 July 2026) states "from 2017 January 1, 0h UTC, until further notice : UTC-TAI = -37 s" and that "NO leap second will be introduced at the end of December 2026" (datacenter.iers.org, Bulletin C (opens in new window)). None expected before 2035 abolition.
Related skills
fake-clock-testing
Fake clocks / freeze time in tests across every mainstream runtime: freezegun (Python), Jest fake timers + Sinon @sinonjs/fake-timers (JS/TS), timecop (Ruby), java.time.Clock / InstantSource injection (JVM), .NET TimeProvider / FakeTimeProvider, and libfaketime (LD_PRELOAD for any native binary). Covers the language-agnostic discipline - inject or patch the clock, freeze vs tick vs advance vs set-system-time semantics, teardown so fake clocks never leak between tests - plus the shared anti-pattern table (real sleep under a frozen clock, leaked clock state, timezone-dependent assertions). Per-library setup, API, and CI recipes live in references/{python,js,ruby,jvm,dotnet,libfaketime}.md. Use when tests need deterministic control of now(), timers, or timeouts in any language, or when choosing the right fake-clock tool for a stack.
timezone-test-matrix-builder
Builds a timezone, daylight saving time (DST), and leap year / leap second test matrix from wherever a codebase reads or formats dates and times. Finds time-handling code (grep for datetime / Date / Instant / time.time / timezone), sorts each spot into storage, business-logic, display, cron, or billing, picks the edge cases that matter (DST spring-forward / fall-back, ambiguous local time, leap day Feb 29, ISO 8601 / RFC 3339 round-trip, zone-database updates), and emits per-spot test stubs wired to the language's fake-clock (mock-time) library. Use when a codebase needs timezone, DST, and leap-year test coverage derived from its own date/time usage.