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
- Node 22 with current tzdata.
node -p "process.versions.tz"printed2026ahere. Old bundled tzdata gives the right shape at the wrong moment. - PowerShell for steps 3 and 4. In Git Bash,
TZ=America/New_York node script.mjsleaves the process on the machine zone and says nothing. - The tz database for the rules, and the Date.parse rules for a string with no offset.
- Three scripts:
// 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
- 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 6America/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 EDTThirty 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.
- 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 6America/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 EST01:30appears at two instants an hour apart, separated only byEDTandEST. 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. - Step 3.
Hand the runtime a local time that does not exist and one that happens twice.
$env:TZ='America/New_York'; node dstparse.mjsprocess 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 ESTLine two is the one to read twice.
02:30was asked for,03:30came 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 two01:30instants, so the second one, on line four, is unreachable by parsing a local string. Format and parse is not a round trip that day. - Step 4.
Compare a day of real time with a calendar day across the same transition.
$env:TZ='America/New_York'; node dstmath.mjsstart 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 minutesA 09:00 appointment moved forward by 86400000 ms becomes a 10:00 appointment.
setDatekeeps 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. - Step 5.
Check a zone where the shift is not an hour.
node walk.mjs Australia/Lord_Howe 2026-04-04T14:00:00Z 5Australia/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:30Lord Howe Island moves thirty minutes, not sixty, and the repeated local time is
01:30rather 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
What to check next
- How to test timezone handling: the zone, offset and parsing rules this check builds on.
- How to run jest tests with a specific timezone: pinning these dates into a runner and into CI.
- How to mock date in jest: freezing the clock on a transition.
- How to check timezone stored in database: whether the column tells the two 01:30 rows apart.
- How to change timezone in chrome for testing: the same two hours from the front end.
- Convert a timestamp across timezones: read one instant in several zones.
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.
Related on this site
- Checker: timestamp convert between Unix time, ISO 8601 and locale formats across timezones
- Localization testing checklist
- All localization and formats checks
intermediate7 minpublished updated Maks Verny