How to test daylight saving time

Step UTC instants across a transition and print the local clock beside each one. In America/New_York on 2026-03-08 the local time goes from 01:30 EST straight to 03:00 EDT, and on 2026-11-01 the clock reads 01:30 twice. Check what your code does with both.

Why check this

Run this before release on anything that schedules, bills by the hour, expires a session or reports a daily total, and again when tzdata changes. It prevents a job configured for 02:30 that does not run on one day a year, and a nightly reconciliation that sees two rows with the same local timestamp and drops one as a duplicate.

You do not need to wait for March. A transition is a property of the zone data, so any instant either side of it can be constructed today.

Prerequisites

// walk.mjs
const zone = process.argv[2];
const startIso = process.argv[3];
const steps = Number(process.argv[4] ?? 8);
const f = new Intl.DateTimeFormat('en-CA', {
  timeZone: zone, hour12: false, dateStyle: 'short', timeStyle: 'long',
});
let t = Date.parse(startIso);
console.log(`${zone}  (step 30 min of real time)`);
console.log('UTC instant           local wall clock');
for (let i = 0; i < steps; i += 1) {
  const d = new Date(t);
  console.log(d.toISOString().slice(0, 16).padEnd(22), f.format(d));
  t += 30 * 60 * 1000;
}
// dstparse.mjs
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const f = new Intl.DateTimeFormat('en-CA', { timeZone: zone, hour12: false, dateStyle: 'short', timeStyle: 'long' });
console.log('process zone', zone);
for (const s of ['2026-03-08T01:30:00', '2026-03-08T02:30:00', '2026-11-01T01:30:00']) {
  const d = new Date(s);
  console.log(s, '->', d.toISOString(), '->', f.format(d), ' offset', d.getTimezoneOffset());
}
console.log('the other 01:30 ->', new Date('2026-11-01T06:30:00Z').toISOString(), '->', f.format(new Date('2026-11-01T06:30:00Z')));
// dstmath.mjs
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const f = new Intl.DateTimeFormat('en-CA', { timeZone: zone, hour12: false, dateStyle: 'short', timeStyle: 'long' });
const start = new Date('2026-03-07T09:00:00');
const plus24h = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const sameWall = new Date(start);
sameWall.setDate(sameWall.getDate() + 1);
console.log('start        ', f.format(start), start.toISOString());
console.log('+86400000 ms ', f.format(plus24h), plus24h.toISOString());
console.log('setDate(+1)  ', f.format(sameWall), sameWall.toISOString());
console.log('difference   ', (sameWall - plus24h) / 60000, 'minutes');

Steps

  1. Step 1.

    Walk real time across the spring transition and watch the local clock skip.

    node walk.mjs America/New_York 2026-03-08T06:00:00Z 6
    
    America/New_York  (step 30 min of real time)
    UTC instant           local wall clock
    2026-03-08T06:00       2026-03-08, 01:00:00 EST
    2026-03-08T06:30       2026-03-08, 01:30:00 EST
    2026-03-08T07:00       2026-03-08, 03:00:00 EDT
    2026-03-08T07:30       2026-03-08, 03:30:00 EDT
    2026-03-08T08:00       2026-03-08, 04:00:00 EDT
    2026-03-08T08:30       2026-03-08, 04:30:00 EDT

    Thirty minutes of real time pass between rows two and three, and the clock moves ninety. Every local time from 02:00:00 to 02:59:59 is absent from that day, so a cron entry or a reminder that names one of them has no instant to run at.

  2. Step 2.

    Do the same across the autumn transition and watch an hour arrive twice.

    node walk.mjs America/New_York 2026-11-01T04:30:00Z 6
    
    America/New_York  (step 30 min of real time)
    UTC instant           local wall clock
    2026-11-01T04:30       2026-11-01, 00:30:00 EDT
    2026-11-01T05:00       2026-11-01, 01:00:00 EDT
    2026-11-01T05:30       2026-11-01, 01:30:00 EDT
    2026-11-01T06:00       2026-11-01, 01:00:00 EST
    2026-11-01T06:30       2026-11-01, 01:30:00 EST
    2026-11-01T07:00       2026-11-01, 02:00:00 EST

    01:30 appears at two instants an hour apart, separated only by EDT and EST. A local timestamp stored without the offset cannot say which one it was, and that day has 25 hours, which breaks any total computed as days times 24.

  3. Step 3.

    Hand the runtime a local time that does not exist and one that happens twice.

    $env:TZ='America/New_York'; node dstparse.mjs
    
    process zone America/New_York
    2026-03-08T01:30:00 -> 2026-03-08T06:30:00.000Z -> 2026-03-08, 01:30:00 EST  offset 300
    2026-03-08T02:30:00 -> 2026-03-08T07:30:00.000Z -> 2026-03-08, 03:30:00 EDT  offset 240
    2026-11-01T01:30:00 -> 2026-11-01T05:30:00.000Z -> 2026-11-01, 01:30:00 EDT  offset 240
    the other 01:30 -> 2026-11-01T06:30:00.000Z -> 2026-11-01, 01:30:00 EST

    Line two is the one to read twice. 02:30 was asked for, 03:30 came back, and nothing raised: the runtime used the offset in force before the transition, landing an hour later on the other side. Line three picks the earlier of the two 01:30 instants, so the second one, on line four, is unreachable by parsing a local string. Format and parse is not a round trip that day.

  4. Step 4.

    Compare a day of real time with a calendar day across the same transition.

    $env:TZ='America/New_York'; node dstmath.mjs
    
    start         2026-03-07, 09:00:00 EST 2026-03-07T14:00:00.000Z
    +86400000 ms  2026-03-08, 10:00:00 EDT 2026-03-08T14:00:00.000Z
    setDate(+1)   2026-03-08, 09:00:00 EDT 2026-03-08T13:00:00.000Z
    difference    -60 minutes

    A 09:00 appointment moved forward by 86400000 ms becomes a 10:00 appointment. setDate keeps the wall clock, and the two answers differ by an hour. Both are right for some requirements, so the defect is choosing without noticing: reminders want the wall clock, trial expiry wants the milliseconds.

  5. Step 5.

    Check a zone where the shift is not an hour.

    node walk.mjs Australia/Lord_Howe 2026-04-04T14:00:00Z 5
    
    Australia/Lord_Howe  (step 30 min of real time)
    UTC instant           local wall clock
    2026-04-04T14:00       2026-04-05, 01:00:00 GMT+11
    2026-04-04T14:30       2026-04-05, 01:30:00 GMT+11
    2026-04-04T15:00       2026-04-05, 01:30:00 GMT+10:30
    2026-04-04T15:30       2026-04-05, 02:00:00 GMT+10:30
    2026-04-04T16:00       2026-04-05, 02:30:00 GMT+10:30

    Lord Howe Island moves thirty minutes, not sixty, and the repeated local time is 01:30 rather than a whole hour. Code that detects a transition by a 60-minute offset change, or that assumes the ambiguous window starts on the hour, passes in New York and fails here. The date is April because the southern hemisphere ends summer time as the north starts it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The clock jumps from 01:30 to 03:00 | A whole local hour is missing that day | Reject or shift scheduled times inside the gap, and say which, in writing | | The same local time on two rows with different abbreviations | The hour repeats; local time alone is ambiguous | Store the instant, or the local time plus its offset | | A parse of a gap time returns an hour later with no error | The runtime used the pre-transition offset | Detect the gap yourself by formatting the result back and comparing | | A day total that is 60 minutes out | A calendar day was computed as 24 hours | Compute day boundaries in the zone, not by arithmetic on milliseconds | | An offset change of 30 minutes | Lord Howe and similar zones | Remove any equality test against a 60-minute shift |

Common mistakes

Sign: A scheduled task at 02:30 local never runs on one day a year, and no error is logged.Cause: new Date('2026-03-08T02:30:00') in America/New_York returns 03:30 EDT rather than throwing, so the scheduler stores a time it was never asked for. Whether the job runs late or is skipped then depends on the scheduler, not on the date library.
Sign: One of two records made an hour apart looks like a duplicate.Cause: On the autumn transition, 01:30 occurs at two instants that print identically. A local timestamp column cannot separate them, and a parse of the string always returns the earlier one, so the second hour is unreachable through the same code path that wrote it.
Sign: Adding a day moves an appointment by an hour.Cause: Adding 86400000 ms adds a day of elapsed time, which crosses the transition and lands on a different wall clock. setDate keeps the wall clock and changes the elapsed time by an hour instead. The output above shows both, 60 minutes apart.
Sign: The transition test passes for North America and fails for an Australian customer.Cause: Lord Howe Island shifts 30 minutes, and its ambiguous local time is 01:30, not a whole hour. Northern and southern transitions also fall in opposite months, so a fixture built around March and November never exercises the southern case.

What to check next

FAQ

How to test DST transitions in an application?

Construct instants either side of the transition rather than changing the machine clock. Steps 1 and 2 walk 30 minutes at a time in both directions, which shows the gap and the repeat without touching system settings.

Which dates should the fixture use?

One from each hemisphere. This tzdata puts the United States transitions on 2026-03-08 and 2026-11-01, and Lord Howe Island on 2026-04-05. Read the dates from the zone data at test time rather than hard-coding them, since the rules change by law.

Does storing UTC remove the problem?

It removes the ambiguity and keeps the arithmetic. A stored instant is unique, so the two 01:30 rows stay distinct. Anything a user expressed as a wall clock, such as a weekly 09:00 meeting, still needs the zone id beside it.

What should code do with a local time that does not exist?

Decide and write it down. The choices are to reject the input, to shift forward by the size of the gap, or to keep whatever the runtime returns. What is not acceptable is not knowing, which is what step 3 exposes.

Is the shift always one hour at 02:00?

No. Lord Howe moves 30 minutes, and the hour varies: walk.mjs Europe/Kyiv 2026-03-29T00:00:00Z 4 shows Kyiv skipping 03:00 to 04:00. Compare offsets before and after instead of testing for a 60-minute difference.

Verified

Verified by Maks Vernynode 22.23.2ICU 78.2tzdata 2026a

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate7 minpublished updated Maks Verny