Save the moment in UTC, save the place by name: a beginner's guide to dates and timezones
---
title: "Save the moment in UTC, save the place by name: a beginner's guide to dates and timezones"
published: true
description: "What UTC is, who keeps the world's timezone rules, why Vancouver now shows MST, and the one case where storing UTC still breaks."
tags: webdev, javascript, postgres, beginners
canonical_url: depthfirst.hashnode.dev
cover_image: cdn.hashnode.com
series:
---
If your app shows a date or a time to people in more than one place, this guide is for you. You don't need to
know anything about timezones to read it. By the end you'll know the three rules most teams follow, why they
work, and the one case where they are not enough.
I learned all of this the hard way, so let me start there.
How I learned this
When we started, we saved times the simple way: the local time someone typed, with no timezone attached.
For example 2026-12-05 10:00.
It looked fine. Then it broke quietly:
- Courses closed an hour early, or an hour late.
- Early-bird prices ended at the wrong moment.
- A page showed a different time from the one that was saved.
The cause was the same every time. 2026-12-05 10:00 is not a moment. It's 10:00 somewhere. Without the
"where", every server, browser and job read it in its own timezone, and they didn't agree.
So I read the official sources, asked a lot of questions, and we changed how we store time. Here is what I
wish someone had told me on day one.
Step 1: a moment and a wall clock are two different things
- A moment is one point in time, the same everywhere on Earth. "When the rocket launched."
- A wall-clock time is what a clock on the wall shows in one place. "10:00 in Vancouver."
The same moment shows different wall-clock times in different places. 10:00 in Vancouver is 22:30 in
Bengaluru on the same moment.
Bugs happen when you save a wall-clock time and treat it like a moment.
Step 2: UTC is the reference clock
UTC (Coordinated Universal Time) is the world's reference clock. It never changes for daylight saving.
Every place on Earth is described as "UTC plus or minus some hours" at a given moment:
| Place | Offset today (Nov 2026) |
|---|---|
| Bengaluru | UTC+5:30 |
| Dublin | UTC+0 in winter, UTC+1 in summer |
| Vancouver | UTC−7, all year (new in 2026, see below) |
If you save every moment in UTC, every server, browser and background job reads the same value. That fixes the
"which timezone did this mean?" problem at the root.
Step 3: save the place by name, never by offset
An offset like −07:00 is a number. A timezone name like America/Vancouver is a place with a
history of rules.
Offsets change. Dublin is +00:00 in January and +01:00 in July. Vancouver was −08:00 every winter until
2026. If you save −08:00, you've saved a fact that was true for one date only.
If you save America/Vancouver, the software works out the right offset for any date: past, present or
future, daylight saving included. You write no daylight-saving code at all.
Names look like Continent/City: America/Vancouver, America/Edmonton, Europe/Dublin, Asia/Kolkata.
(Note: it's Asia/Kolkata and Europe/Dublin, not "India/Calcutta" or "Ireland/Dublin". Use the official
list.)
Step 4: who keeps these rules? The IANA timezone database
Somebody has to record that "Vancouver stopped changing clocks in 2026". That somebody is the IANA Time Zone
Database (also called tzdata or the Olson database), a small group of volunteers who publish the rules for
every place on Earth, going back decades.
When a government changes its clocks, the database ships a new release. Operating systems, Java, Postgres,
Node.js and libraries like moment-timezone copy that data into themselves.
That last part matters: the timezone database is a dependency, like any library. If you don't update it,
your app uses old rules.
And you probably have more than one copy:
- the server's operating system,
- your runtime (Node.js, Java),
- your date library (moment-timezone bundles its own),
- your database (Postgres ships its own copy unless it was built to use the system's),
- every user's browser.
They can disagree for weeks after a change.
Step 5: what changed in Canada in 2026 (a real example)
This year four parts of Canada stopped changing their clocks. The database needed four releases to keep up:
| Release | Date | Change |
|---|---|---|
| 2026b | 22 Apr 2026 | British Columbia: UTC−7 all year |
| 2026c | 8 Jul 2026 | Alberta: UTC−6 all year |
| 2026d | 11 Sep 2026 | Northwest Territories: UTC−6 all year |
| 2026e | 29 Sep 2026 | Manitoba: UTC−5 all year |
British Columbia moved its clocks forward for the last time on 8 March 2026. On 1 November, when clocks would
normally go back, they stayed put.
Why Vancouver now shows "MST"
Short labels like PST and MST are not decoration. Each one names an offset:
| Label | Means |
|---|---|
| PST | Pacific Standard Time, UTC−8 |
| PDT | Pacific Daylight Time, UTC−7 (daylight saving) |
| MST | Mountain Standard Time, UTC−7 |
Vancouver is now at UTC−7 all year, with no daylight saving. PST would be an hour wrong. PDT would say daylight
saving is on, and it no longer exists there. So the database labels it MST, the long-standing name for
UTC−7 standard time in North America. For the same reason, Edmonton (now UTC−6 all year) is labelled CST.
Two honest notes:
- The provinces use their own names: British Columbia calls it "Pacific Time" (PCT), Alberta calls it "Alberta
Time". The database's 2026b notes say a future release may change the label.
- Windows and browsers don't always show the database's label. Browsers use another project (Unicode CLDR)
for display names, so you may see "GMT-7" or a long name instead.
Our choice was to show what the database says. If the database changes the label later, our screens change
with the next update, with no code. If you override labels by hand, you own that list forever. And never
search-and-replace "MST" with something else: Arizona really is MST all year, and so is Denver in winter.
Step 6: what this looks like in code
In the database (Postgres): use timestamptz for moments, and keep the zone name next to it.
CREATE TABLE course (
id bigint PRIMARY KEY,
starts_at timestamptz NOT NULL, -- a moment; Postgres stores it as UTC
time_zone text NOT NULL -- IANA name, e.g. 'America/Vancouver'
);
-- Show it as the local wall-clock time of the course's place
SELECT starts_at AT TIME ZONE time_zone AS local_start
FROM course;
Despite its name, timestamptz doesn't store a timezone. It stores the moment in UTC and converts it for
display using your session's TimeZone setting. The plain timestamp type (without time zone) is the
"local time with no zone" trap from the start of this guide.
In JavaScript: a Date is already a moment (milliseconds since 1970 in UTC). Convert only when you display it.
const startsAt = new Date('2026-12-05T17:00:00Z'); // the moment, from the API, in UTC
startsAt.toLocaleString('en-CA', {
timeZone: 'America/Vancouver', // the place, by name
dateStyle: 'medium',
timeStyle: 'short',
});
// 10:00 a.m. on 5 Dec 2026 in Vancouver (with up-to-date timezone data)
In background jobs: schedule in UTC. "Close this course at 2026-12-05T17:00:00Z" fires at the right moment
whether the course is in Vancouver, Dublin or Bengaluru. Our job queue only ever deals in UTC, and that
removed a whole class of bugs.
Step 7: the one case where UTC alone breaks: future events
Here's the trap that almost nobody mentions in beginner guides.
Say in February 2026 someone creates an event: 10:00 on 5 December 2026, in Vancouver. At that time the
rules said Vancouver would be at UTC−8 in December. So the app saved 2026-12-05T18:00:00Z.
Then British Columbia changed its law. Vancouver will be at UTC−7 in December instead. Now that saved moment,
18:00Z, shows as 11:00 in Vancouver. The event is an hour off, and nobody touched it.
The person meant "10:00 on the wall clock in Vancouver". We saved a moment computed with rules that later
changed.
What to do about it:
- For things that already happened (payments, logins, check-ins): UTC is exactly right. The past doesn't
change.
- For future events people plan by the wall clock (a class at 10:00 local): also keep the local time and
the zone name the person chose, and recompute the UTC moment from them when the rules change. Or at least
check future events after every timezone-data update.
Jon Skeet wrote the classic piece on this: Storing UTC is not a silver bullet (link at the end). Read it
once you're comfortable with the basics.
A checklist to keep
1. Save moments in UTC (timestamptz in Postgres, ISO strings ending in Z in APIs).
2. Save the place as an IANA name (America/Vancouver), never as an offset.
3. Convert to local time only at the last step, when a person reads it.
4. Schedule jobs in UTC.
5. Treat the timezone database as a dependency: update it, and know every copy you run.
6. For future wall-clock events, keep the local time + zone name too, and re-check after rule changes.
7. Show the label the database gives you. Don't hand-edit labels.
What I'd tell a new developer
UTC tells you when. The zone name tells you where. You need both, and the local time is something you work
out at the end, not something you store.
What's the one timezone rule you wish someone had told you earlier?
---
Sources
- IANA Time Zone Database: iana.org
- 2026b release notes (British Columbia, the "MST" label): iana.org
- 2026c release notes (Alberta, "CST"): iana.org
- 2026e release notes (Manitoba): iana.org
- Time in British Columbia (PCT name, dates): en.wikipedia.org
- moment-timezone issue #1163 (2026 Canadian changes): moment-timezone#1163
- PostgreSQL date/time types: postgresql.org
- Jon Skeet, Storing UTC is not a silver bullet (2019): codeblog.jonskeet.uk
1 comment
The Hour That Doesn’t Exist: Booking Slots Across Daylight Saving Time
A salon’s working hours are a wall-clock fact: open nine, close eight. A booking is an instant: this client, this specialist, this exact moment. Most of the time, converting between the two needs no thought. At daylight saving transitions, that conversion can have a gap or more than one answer. A wall-clock time that never happened can silently turn into a real instant – the same one as the slot an hour later. A working block’s own start or end time can be the one that doesn’t exist that day. And the day itself can be 23 or 25 hours long instead of 24.
This is the boundary KRI, the booking SaaS I designed and built for beauty businesses, has to get right on every date, in whichever IANA time zone the salon owner picked at sign-up. The examples below use Luxon 3.7.2, the version KRI runs. Its default gap handling needs an explicit check, and reviewing its autumn behaviour exposed a separate assumption in KRI that a pinned version does not make safe.
Instants in the Database, Wall Clocks in the Schedule
KRI stores two different kinds of time, and mixing them up is the entire source of the problem. A booking – its start and its end – is a UTC instant, a use case for TIMESTAMPTZ rather than a timezone-free TIMESTAMP: a moment that means the same thing regardless of who reads it. A specialist’s working hours are the opposite on purpose: "09:00"–"20:00", a wall-clock string with no offset attached, because that’s what actually stays true across a daylight saving change. A salon that opens at nine keeps opening at nine in March and in October; what changes is which UTC instant ‘nine’ happens to be that day.
This article covers one path: slot generation and the client’s online booking page. Everywhere that path turns a specialist’s stored "09:00" into a bookable UTC instant for a specific date, or turns a client-submitted instant back into the local date it falls on, it goes through one server-side conversion module. So do that path’s day windows and date-by-date scans. The chokepoint is deliberate: daylight saving time is cheap to get right once and expensive to get right N times. The salon owner’s own dashboard has a separate manual-booking path with its own client-side conversion; it’s outside the scope of this article.
Why isValid Doesn’t Catch a Skipped Hour
This is the case the chokepoint exists for. Take Europe/Warsaw on 29 March 2026, the day clocks spring forward from 02:00 to 03:00. The wall-clock times 02:00–02:59 don’t happen that day, yet asking Luxon to build a DateTime for "02:30" in that zone doesn’t fail:
| Requested wall clock (Europe/Warsaw, 2026-03-29) | isValid | What Luxon actually returns |
| ------------------------------------------------ | --------- | --------------------------------------- |
| 02:00 | true | 03:00+02:00 – shifted forward an hour |
| 02:30 | true | 03:30+02:00 – shifted forward an hour |
| 03:30 (the real one) | true | 03:30+02:00 |
02:30 and the real 03:30 land on the exact same instant. In Luxon 3.7.2, DateTime.fromFormat (and fromObject behaves the same way) returns a valid DateTime for a wall-clock time inside a spring-forward gap: it silently shifts the time forward past the gap and reports the result as valid, so isValid can’t detect it. Ask for the same "02:30" in Europe/Helsinki, which is one hour ahead and has its own gap starting at 03:00 that day, and 02:30 exists where you’d expect, no shift. The failure depends on the zone whose gap you land in, not on the time 02:30 itself, and isValid gives no signal either way: it’s true in both zones, for different reasons.
So a naive ‘generate a slot for every 10 minutes between opening and closing, check isValid’ loop hands out a slot for the hour that never happened, silently pointing at the same instant as the slot an hour later.
Catching It: Convert, Then Convert Back
The fix needs no application-maintained table of transitions; Luxon uses the runtime’s time zone data. Convert the requested wall clock to an instant, then format that instant back into a wall clock in the same zone and compare the two strings:
function localToUtc(date: string, time: string, zone: string): Date | null {
const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone });
if (!dt.isValid) return null;
// Luxon shifts a non-existent wall clock forward instead of reporting it
// invalid. Compare the result with the requested local date and time.
if (dt.toFormat("yyyy-MM-dd HH:mm") !== `${date} ${time}`) return null;
return dt.toJSDate();
}
Detection comes down to formatting the result back in the same zone and comparing. If the wall clock you get back differs from the one you asked for, the time you asked for never existed – you’re looking at the silent forward shift, not a coincidence. That’s the whole detection, with no table of DST transition dates.
This doesn’t produce a working slot where there wasn’t one; there still isn’t one. What it changes is the failure: an explicit null the caller has to handle instead of a slot quietly pointing at the wrong instant that nothing downstream would flag. That matters most where a naive loop is least likely to notice: at the edges of a working block, when a specialist’s stored start or end time falls inside the gap.
What a Gap Does to a Slot List
KRI’s slot generator walks each working block in fixed 10-minute wall-clock steps and converts every step through the function above. Two different things can go wrong with a block on a given date, and they’re handled differently:
- A single step inside an otherwise normal block lands in the gap. That one candidate slot is skipped; the rest of the block still offers its normal slots.
- The block’s _start or end time itself_ is the one that doesn’t exist that day – a specialist scheduled to start at 02:00 on a morning where 02:00 never happens. The entire block for that date is dropped, not clipped to whatever fragment might remain. Once a non-existent boundary is shifted, the generator has no principled way to guess what it was supposed to mean, so it refuses the whole block rather than invent a partial one.
A service’s duration is added to a converted slot start in real elapsed milliseconds, not by adding minutes to a wall clock, so a duration that spans a transition is measured correctly even though the wall clock reads a full hour later than a naive add would show. In practice this only matters late at night or in the small hours. In the European zones above, the spring gap sits between 02:00 and 04:00 local time (02:00–02:59 in Warsaw, 03:00–03:59 in Helsinki), well outside a salon open 09:00–20:00. Some zones shift at midnight instead – a different case, covered below. For a salon open only during the day, those particular slot-level cases will not occur.
The Repeated Hour: Pinning the Version Is Not a Policy
On 25 October 2026, Europe/Warsaw’s clocks fall back from 03:00 to 02:00. The local time 02:30 occurs twice: at 00:30Z with offset +02:00, and at 01:30Z with offset +01:00. Both pass the round-trip check because both are real occurrences of the requested wall clock.
KRI’s conversion code assumed Luxon 3.7.2 would choose the earlier occurrence. Reviewing that assumption produced this result, with the same library version and input:
| Current time supplied to Luxon | Requested local time | Result |
| ------------------------------ | --------------------------------- | ------------------------------------- |
| January 2026 | 2026-10-25 02:30, Europe/Warsaw | 02:30+01:00, the later occurrence |
| July 2026 | 2026-10-25 02:30, Europe/Warsaw | 02:30+02:00, the earlier occurrence |
This is reproducible by changing only Settings.now in a test:
import { DateTime, Settings } from "luxon";
const originalNow = Settings.now;
try {
for (const now of ["2026-01-15T12:00:00Z", "2026-07-15T12:00:00Z"]) {
Settings.now = () => Date.parse(now);
Settings.resetCaches();
const dt = DateTime.fromFormat("2026-10-25 02:30", "yyyy-MM-dd HH:mm", {
zone: "Europe/Warsaw",
});
console.log(now, dt.toISO(), dt.toUTC().toISO());
}
} finally {
Settings.now = originalNow;
Settings.resetCaches();
}
Run this in isolation: Settings.now is global test state. The 3.7.2 implementation uses the zone’s offset at the current time as an initial guess when constructing this value. Luxon’s documentation makes no promise about which ambiguous occurrence a plain lookup chooses. The dependency is therefore broader than a library upgrade: the result can change while the version stays fixed.
If the product’s policy is to offer the earlier occurrence only, select it explicitly after the validity and round-trip checks:
const candidates = dt.getPossibleOffsets();
const earlier = candidates.reduce((a, b) => (a.toMillis() <= b.toMillis() ? a : b));
return earlier.toJSDate();
This is the proposed correction to KRI’s assumption, not a claim that its current conversion module already contains this selection. A product that offers both occurrences needs a different interface: two distinct instants with enough offset or time-zone information for the client to tell them apart. Either policy can be deliberate; leaving it to a default constructor cannot guarantee either one.
A Day Isn’t Always 24 Hours
Date math has the same trap wall-clock math does. Advance a UTC instant by a fixed 24 hours across the Warsaw autumn transition and the local calendar date it lands on doesn’t advance cleanly – two consecutive 24-hour hops can land on the same local date, because one of those days was 25 real hours long. That’s why KRI’s own day-by-day iteration (walking a booking window date by date) advances a "YYYY-MM-DD" string by calendar arithmetic, never by adding a fixed duration to an instant. In a DST-observing zone, ‘add a day’ and ‘the next calendar day’ don’t always mean the same thing.
The same reasoning extends to where a day starts. A window for a given date is built from startOf('day') in the salon’s zone rather than assuming "00:00" – because in some zones, on some dates, midnight itself is the wall clock the DST shift removes. Africa/Cairo, America/Havana, America/Santiago, Asia/Beirut, and Atlantic/Azores all skip midnight on at least one date in 2026. A strict conversion asked for "00:00" on one of those dates correctly returns null – there is no such instant – so building the day window from a fixed "00:00" lookup would fail on exactly the dates it needs to work. startOf('day') sidesteps the question and returns the first instant that exists that day, whatever its wall-clock time. Unlike the gap and duration cases above, which only bite at hours outside a typical salon’s working day, this applies to every day window and date-by-date scan behind slot generation and the booking page, in every zone, on every date.
Failing Loudly on a Bad Time Zone
One more failure mode is silent in a way that looks nothing like a bug: an invalid or unset IANA zone. Luxon doesn’t throw when you ask it to convert into a time zone it doesn’t recognise – DateTime.local().setZone("Europe/Wrsaw") (one letter off) comes back with isValid: false and no exception. Feed that into the conversion function above and every wall-clock lookup for that salon returns null, on every date, for every slot. The calendar isn’t broken – it’s just empty. To a client on the salon’s booking page – or the owner checking that same page – ‘no availability’ and ‘the time zone is misconfigured’ look identical.
So the zone itself is checked at the start of every conversion, and a bad one throws rather than falls through:
function assertTimezone(zone: string | null | undefined): string {
if (!zone || !DateTime.local().setZone(zone).isValid) {
throw new Error(`Invalid tenant timezone: ${String(zone)}`);
}
return zone;
}
The time zone is a business value here, not a formatting detail – it decides which real-world instant a booking lands on – so an unknown or missing one fails the request instead of quietly defaulting. An unset zone option would fall back to whatever the process’s own local zone happens to be, which is a different kind of wrong: not empty, but confidently pointing at the wrong instants.
What This Doesn’t Cover
Everything above covers slot generation and the client’s online booking page, not every path in the product. The salon owner’s own dashboard has a separate manual-booking flow that converts wall clock to instant with its own code. It isn’t covered here, and this article makes no claim that one module handles every booking-related conversion in the system.
Dropping a working block with an invalid boundary is KRI’s current conservative policy. Autumn ambiguity is different: the earlier-occurrence assumption needs the explicit selection above and regression checks with both winter and summer current dates.
The checks are small, but they answer different questions: does this local time exist, which occurrence do we mean, where does the day begin, and is the configured zone valid? Treating those as separate decisions makes the calendar easier to reason about and test.
---
Further reading:
- kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses – the project this article’s time zone handling is drawn from
- PostgreSQL Production Checklist: UUIDs, RLS, Indexes, Pooling – the TIMESTAMPTZ convention this article’s storage model relies on
- A Green Test Can Still Miss the Race: Testing Concurrency in PostgreSQL – a different kind of correctness problem in the same booking system
- Luxon – Time Zones and Offsets – the official documentation on invalid and ambiguous local times cited above
2 reactions
Mentions
Save the moment in UTC, save the place by name: a beginner's guide to dates and timezones --- title: "Save the moment in UTC, save the place by name: a beginner's guide to dates and timezones" published: true description: "What UTC is, who keeps the world's timezone rules, why Vancouver now shows MST, and the one case where storing UTC still breaks." tags: webdev, javascript, postgres, beginners canonical_url: depthfirst.hashnode.dev cover_image: cdn.hashnode.com series: --- If your app shows a date or a time to people in more than one place, this guide is for you. You don't need to know anything about timezones to read it. By the end you'll know the three rules most teams follow, why they work, and the one case where they are not enough. I learned all of this the hard way, so let me start there. How I learned this When we started, we saved times the simple way: the local time someone typed, with no timezone attached. For example 2026-12-05 10:00. It looked fine. Then it broke quietly: - Courses closed an hour early, or an hour late. - Early-bird prices ended at the wrong moment. - A page showed a different time from the one that was saved. The cause was the same every time. 2026-12-05 10:00 is not a moment. It's 10:00 somewhere. Without the "where", every server, browser and job read it in its own timezone, and they didn't agree. So I read the official sources, asked a lot of questions, and we changed how we store time. Here is what I wish someone had told me on day one. Step 1: a moment and a wall clock are two different things - A moment is one point in time, the same everywhere on Earth. "When the rocket launched." - A wall-clock time is what a clock on the wall shows in one place. "10:00 in Vancouver." The same moment shows different wall-clock times in different places. 10:00 in Vancouver is 22:30 in Bengaluru on the same moment. Bugs happen when you save a wall-clock time and treat it like a moment. Step 2: UTC is the reference clock UTC (Coordinated Universal Time) is the world's reference clock. It never changes for daylight saving. Every place on Earth is described as "UTC plus or minus some hours" at a given moment: | Place | Offset today (Nov 2026) | |---|---| | Bengaluru | UTC+5:30 | | Dublin | UTC+0 in winter, UTC+1 in summer | | Vancouver | UTC−7, all year (new in 2026, see below) | If you save every moment in UTC, every server, browser and background job reads the same value. That fixes the "which timezone did this mean?" problem at the root. Step 3: save the place by name, never by offset An offset like −07:00 is a number. A timezone name like America/Vancouver is a place with a history of rules. Offsets change. Dublin is +00:00 in January and +01:00 in July. Vancouver was −08:00 every winter until 2026. If you save −08:00, you've saved a fact that was true for one date only. If you save America/Vancouver, the software works out the right offset for any date: past, present or future, daylight saving included. You write no daylight-saving code at all. Names look like Continent/City: America/Vancouver, America/Edmonton, Europe/Dublin, Asia/Kolkata. (Note: it's Asia/Kolkata and Europe/Dublin, not "India/Calcutta" or "Ireland/Dublin". Use the official list.) Step 4: who keeps these rules? The IANA timezone database Somebody has to record that "Vancouver stopped changing clocks in 2026". That somebody is the IANA Time Zone Database (also called tzdata or the Olson database), a small group of volunteers who publish the rules for every place on Earth, going back decades. When a government changes its clocks, the database ships a new release. Operating systems, Java, Postgres, Node.js and libraries like moment-timezone copy that data into themselves. That last part matters: the timezone database is a dependency, like any library. If you don't update it, your app uses old rules. And you probably have more than one copy: - the server's operating system, - your runtime (Node.js, Java), - your date library (moment-timezone bundles its own), - your database (Postgres ships its own copy unless it was built to use the system's), - every user's browser. They can disagree for weeks after a change. Step 5: what changed in Canada in 2026 (a real example) This year four parts of Canada stopped changing their clocks. The database needed four releases to keep up: | Release | Date | Change | |---|---|---| | 2026b | 22 Apr 2026 | British Columbia: UTC−7 all year | | 2026c | 8 Jul 2026 | Alberta: UTC−6 all year | | 2026d | 11 Sep 2026 | Northwest Territories: UTC−6 all year | | 2026e | 29 Sep 2026 | Manitoba: UTC−5 all year | British Columbia moved its clocks forward for the last time on 8 March 2026. On 1 November, when clocks would normally go back, they stayed put. Why Vancouver now shows "MST" Short labels like PST and MST are not decoration. Each one names an offset: | Label | Means | |---|---| | PST | Pacific Standard Time, UTC−8 | | PDT | Pacific Daylight Time, UTC−7 (daylight saving) | | MST | Mountain Standard Time, UTC−7 | Vancouver is now at UTC−7 all year, with no daylight saving. PST would be an hour wrong. PDT would say daylight saving is on, and it no longer exists there. So the database labels it MST, the long-standing name for UTC−7 standard time in North America. For the same reason, Edmonton (now UTC−6 all year) is labelled CST. Two honest notes: - The provinces use their own names: British Columbia calls it "Pacific Time" (PCT), Alberta calls it "Alberta Time". The database's 2026b notes say a future release may change the label. - Windows and browsers don't always show the database's label. Browsers use another project (Unicode CLDR) for display names, so you may see "GMT-7" or a long name instead. Our choice was to show what the database says. If the database changes the label later, our screens change with the next update, with no code. If you override labels by hand, you own that list forever. And never search-and-replace "MST" with something else: Arizona really is MST all year, and so is Denver in winter. Step 6: what this looks like in code In the database (Postgres): use timestamptz for moments, and keep the zone name next to it. CREATE TABLE course ( id bigint PRIMARY KEY, starts_at timestamptz NOT NULL, -- a moment; Postgres stores it as UTC time_zone text NOT NULL -- IANA name, e.g. 'America/Vancouver' ); -- Show it as the local wall-clock time of the course's place SELECT starts_at AT TIME ZONE time_zone AS local_start FROM course; Despite its name, timestamptz doesn't store a timezone. It stores the moment in UTC and converts it for display using your session's TimeZone setting. The plain timestamp type (without time zone) is the "local time with no zone" trap from the start of this guide. In JavaScript: a Date is already a moment (milliseconds since 1970 in UTC). Convert only when you display it. const startsAt = new Date('2026-12-05T17:00:00Z'); // the moment, from the API, in UTC startsAt.toLocaleString('en-CA', { timeZone: 'America/Vancouver', // the place, by name dateStyle: 'medium', timeStyle: 'short', }); // 10:00 a.m. on 5 Dec 2026 in Vancouver (with up-to-date timezone data) In background jobs: schedule in UTC. "Close this course at 2026-12-05T17:00:00Z" fires at the right moment whether the course is in Vancouver, Dublin or Bengaluru. Our job queue only ever deals in UTC, and that removed a whole class of bugs. Step 7: the one case where UTC alone breaks: future events Here's the trap that almost nobody mentions in beginner guides. Say in February 2026 someone creates an event: 10:00 on 5 December 2026, in Vancouver. At that time the rules said Vancouver would be at UTC−8 in December. So the app saved 2026-12-05T18:00:00Z. Then British Columbia changed its law. Vancouver will be at UTC−7 in December instead. Now that saved moment, 18:00Z, shows as 11:00 in Vancouver. The event is an hour off, and nobody touched it. The person meant "10:00 on the wall clock in Vancouver". We saved a moment computed with rules that later changed. What to do about it: - For things that already happened (payments, logins, check-ins): UTC is exactly right. The past doesn't change. - For future events people plan by the wall clock (a class at 10:00 local): also keep the local time and the zone name the person chose, and recompute the UTC moment from them when the rules change. Or at least check future events after every timezone-data update. Jon Skeet wrote the classic piece on this: Storing UTC is not a silver bullet (link at the end). Read it once you're comfortable with the basics. A checklist to keep 1. Save moments in UTC (timestamptz in Postgres, ISO strings ending in Z in APIs). 2. Save the place as an IANA name (America/Vancouver), never as an offset. 3. Convert to local time only at the last step, when a person reads it. 4. Schedule jobs in UTC. 5. Treat the timezone database as a dependency: update it, and know every copy you run. 6. For future wall-clock events, keep the local time + zone name too, and re-check after rule changes. 7. Show the label the database gives you. Don't hand-edit labels. What I'd tell a new developer UTC tells you when. The zone name tells you where. You need both, and the local time is something you work out at the end, not something you store. What's the one timezone rule you wish someone had told you earlier? --- Sources - IANA Time Zone Database: iana.org - 2026b release notes (British Columbia, the "MST" label): iana.org - 2026c release notes (Alberta, "CST"): iana.org - 2026e release notes (Manitoba): iana.org - Time in British Columbia (PCT name, dates): en.wikipedia.org - moment-timezone issue #1163 (2026 Canadian changes): moment-timezone#1163 - PostgreSQL date/time types: postgresql.org - Jon Skeet, Storing UTC is not a silver bullet (2019): codeblog.jonskeet.uk
The Hour That Doesn’t Exist: Booking Slots Across Daylight Saving Time A salon’s working hours are a wall-clock fact: open nine, close eight. A booking is an instant: this client, this specialist, this exact moment. Most of the time, converting between the two needs no thought. At daylight saving transitions, that conversion can have a gap or more than one answer. A wall-clock time that never happened can silently turn into a real instant – the same one as the slot an hour later. A working block’s own start or end time can be the one that doesn’t exist that day. And the day itself can be 23 or 25 hours long instead of 24. This is the boundary KRI, the booking SaaS I designed and built for beauty businesses, has to get right on every date, in whichever IANA time zone the salon owner picked at sign-up. The examples below use Luxon 3.7.2, the version KRI runs. Its default gap handling needs an explicit check, and reviewing its autumn behaviour exposed a separate assumption in KRI that a pinned version does not make safe. Instants in the Database, Wall Clocks in the Schedule KRI stores two different kinds of time, and mixing them up is the entire source of the problem. A booking – its start and its end – is a UTC instant, a use case for TIMESTAMPTZ rather than a timezone-free TIMESTAMP: a moment that means the same thing regardless of who reads it. A specialist’s working hours are the opposite on purpose: "09:00"–"20:00", a wall-clock string with no offset attached, because that’s what actually stays true across a daylight saving change. A salon that opens at nine keeps opening at nine in March and in October; what changes is which UTC instant ‘nine’ happens to be that day. This article covers one path: slot generation and the client’s online booking page. Everywhere that path turns a specialist’s stored "09:00" into a bookable UTC instant for a specific date, or turns a client-submitted instant back into the local date it falls on, it goes through one server-side conversion module. So do that path’s day windows and date-by-date scans. The chokepoint is deliberate: daylight saving time is cheap to get right once and expensive to get right N times. The salon owner’s own dashboard has a separate manual-booking path with its own client-side conversion; it’s outside the scope of this article. Why isValid Doesn’t Catch a Skipped Hour This is the case the chokepoint exists for. Take Europe/Warsaw on 29 March 2026, the day clocks spring forward from 02:00 to 03:00. The wall-clock times 02:00–02:59 don’t happen that day, yet asking Luxon to build a DateTime for "02:30" in that zone doesn’t fail: | Requested wall clock (Europe/Warsaw, 2026-03-29) | isValid | What Luxon actually returns | | ------------------------------------------------ | --------- | --------------------------------------- | | 02:00 | true | 03:00+02:00 – shifted forward an hour | | 02:30 | true | 03:30+02:00 – shifted forward an hour | | 03:30 (the real one) | true | 03:30+02:00 | 02:30 and the real 03:30 land on the exact same instant. In Luxon 3.7.2, DateTime.fromFormat (and fromObject behaves the same way) returns a valid DateTime for a wall-clock time inside a spring-forward gap: it silently shifts the time forward past the gap and reports the result as valid, so isValid can’t detect it. Ask for the same "02:30" in Europe/Helsinki, which is one hour ahead and has its own gap starting at 03:00 that day, and 02:30 exists where you’d expect, no shift. The failure depends on the zone whose gap you land in, not on the time 02:30 itself, and isValid gives no signal either way: it’s true in both zones, for different reasons. So a naive ‘generate a slot for every 10 minutes between opening and closing, check isValid’ loop hands out a slot for the hour that never happened, silently pointing at the same instant as the slot an hour later. Catching It: Convert, Then Convert Back The fix needs no application-maintained table of transitions; Luxon uses the runtime’s time zone data. Convert the requested wall clock to an instant, then format that instant back into a wall clock in the same zone and compare the two strings: function localToUtc(date: string, time: string, zone: string): Date | null { const dt = DateTime.fromFormat(`${date} ${time}`, "yyyy-MM-dd HH:mm", { zone }); if (!dt.isValid) return null; // Luxon shifts a non-existent wall clock forward instead of reporting it // invalid. Compare the result with the requested local date and time. if (dt.toFormat("yyyy-MM-dd HH:mm") !== `${date} ${time}`) return null; return dt.toJSDate(); } Detection comes down to formatting the result back in the same zone and comparing. If the wall clock you get back differs from the one you asked for, the time you asked for never existed – you’re looking at the silent forward shift, not a coincidence. That’s the whole detection, with no table of DST transition dates. This doesn’t produce a working slot where there wasn’t one; there still isn’t one. What it changes is the failure: an explicit null the caller has to handle instead of a slot quietly pointing at the wrong instant that nothing downstream would flag. That matters most where a naive loop is least likely to notice: at the edges of a working block, when a specialist’s stored start or end time falls inside the gap. What a Gap Does to a Slot List KRI’s slot generator walks each working block in fixed 10-minute wall-clock steps and converts every step through the function above. Two different things can go wrong with a block on a given date, and they’re handled differently: - A single step inside an otherwise normal block lands in the gap. That one candidate slot is skipped; the rest of the block still offers its normal slots. - The block’s _start or end time itself_ is the one that doesn’t exist that day – a specialist scheduled to start at 02:00 on a morning where 02:00 never happens. The entire block for that date is dropped, not clipped to whatever fragment might remain. Once a non-existent boundary is shifted, the generator has no principled way to guess what it was supposed to mean, so it refuses the whole block rather than invent a partial one. A service’s duration is added to a converted slot start in real elapsed milliseconds, not by adding minutes to a wall clock, so a duration that spans a transition is measured correctly even though the wall clock reads a full hour later than a naive add would show. In practice this only matters late at night or in the small hours. In the European zones above, the spring gap sits between 02:00 and 04:00 local time (02:00–02:59 in Warsaw, 03:00–03:59 in Helsinki), well outside a salon open 09:00–20:00. Some zones shift at midnight instead – a different case, covered below. For a salon open only during the day, those particular slot-level cases will not occur. The Repeated Hour: Pinning the Version Is Not a Policy On 25 October 2026, Europe/Warsaw’s clocks fall back from 03:00 to 02:00. The local time 02:30 occurs twice: at 00:30Z with offset +02:00, and at 01:30Z with offset +01:00. Both pass the round-trip check because both are real occurrences of the requested wall clock. KRI’s conversion code assumed Luxon 3.7.2 would choose the earlier occurrence. Reviewing that assumption produced this result, with the same library version and input: | Current time supplied to Luxon | Requested local time | Result | | ------------------------------ | --------------------------------- | ------------------------------------- | | January 2026 | 2026-10-25 02:30, Europe/Warsaw | 02:30+01:00, the later occurrence | | July 2026 | 2026-10-25 02:30, Europe/Warsaw | 02:30+02:00, the earlier occurrence | This is reproducible by changing only Settings.now in a test: import { DateTime, Settings } from "luxon"; const originalNow = Settings.now; try { for (const now of ["2026-01-15T12:00:00Z", "2026-07-15T12:00:00Z"]) { Settings.now = () => Date.parse(now); Settings.resetCaches(); const dt = DateTime.fromFormat("2026-10-25 02:30", "yyyy-MM-dd HH:mm", { zone: "Europe/Warsaw", }); console.log(now, dt.toISO(), dt.toUTC().toISO()); } } finally { Settings.now = originalNow; Settings.resetCaches(); } Run this in isolation: Settings.now is global test state. The 3.7.2 implementation uses the zone’s offset at the current time as an initial guess when constructing this value. Luxon’s documentation makes no promise about which ambiguous occurrence a plain lookup chooses. The dependency is therefore broader than a library upgrade: the result can change while the version stays fixed. If the product’s policy is to offer the earlier occurrence only, select it explicitly after the validity and round-trip checks: const candidates = dt.getPossibleOffsets(); const earlier = candidates.reduce((a, b) => (a.toMillis() <= b.toMillis() ? a : b)); return earlier.toJSDate(); This is the proposed correction to KRI’s assumption, not a claim that its current conversion module already contains this selection. A product that offers both occurrences needs a different interface: two distinct instants with enough offset or time-zone information for the client to tell them apart. Either policy can be deliberate; leaving it to a default constructor cannot guarantee either one. A Day Isn’t Always 24 Hours Date math has the same trap wall-clock math does. Advance a UTC instant by a fixed 24 hours across the Warsaw autumn transition and the local calendar date it lands on doesn’t advance cleanly – two consecutive 24-hour hops can land on the same local date, because one of those days was 25 real hours long. That’s why KRI’s own day-by-day iteration (walking a booking window date by date) advances a "YYYY-MM-DD" string by calendar arithmetic, never by adding a fixed duration to an instant. In a DST-observing zone, ‘add a day’ and ‘the next calendar day’ don’t always mean the same thing. The same reasoning extends to where a day starts. A window for a given date is built from startOf('day') in the salon’s zone rather than assuming "00:00" – because in some zones, on some dates, midnight itself is the wall clock the DST shift removes. Africa/Cairo, America/Havana, America/Santiago, Asia/Beirut, and Atlantic/Azores all skip midnight on at least one date in 2026. A strict conversion asked for "00:00" on one of those dates correctly returns null – there is no such instant – so building the day window from a fixed "00:00" lookup would fail on exactly the dates it needs to work. startOf('day') sidesteps the question and returns the first instant that exists that day, whatever its wall-clock time. Unlike the gap and duration cases above, which only bite at hours outside a typical salon’s working day, this applies to every day window and date-by-date scan behind slot generation and the booking page, in every zone, on every date. Failing Loudly on a Bad Time Zone One more failure mode is silent in a way that looks nothing like a bug: an invalid or unset IANA zone. Luxon doesn’t throw when you ask it to convert into a time zone it doesn’t recognise – DateTime.local().setZone("Europe/Wrsaw") (one letter off) comes back with isValid: false and no exception. Feed that into the conversion function above and every wall-clock lookup for that salon returns null, on every date, for every slot. The calendar isn’t broken – it’s just empty. To a client on the salon’s booking page – or the owner checking that same page – ‘no availability’ and ‘the time zone is misconfigured’ look identical. So the zone itself is checked at the start of every conversion, and a bad one throws rather than falls through: function assertTimezone(zone: string | null | undefined): string { if (!zone || !DateTime.local().setZone(zone).isValid) { throw new Error(`Invalid tenant timezone: ${String(zone)}`); } return zone; } The time zone is a business value here, not a formatting detail – it decides which real-world instant a booking lands on – so an unknown or missing one fails the request instead of quietly defaulting. An unset zone option would fall back to whatever the process’s own local zone happens to be, which is a different kind of wrong: not empty, but confidently pointing at the wrong instants. What This Doesn’t Cover Everything above covers slot generation and the client’s online booking page, not every path in the product. The salon owner’s own dashboard has a separate manual-booking flow that converts wall clock to instant with its own code. It isn’t covered here, and this article makes no claim that one module handles every booking-related conversion in the system. Dropping a working block with an invalid boundary is KRI’s current conservative policy. Autumn ambiguity is different: the earlier-occurrence assumption needs the explicit selection above and regression checks with both winter and summer current dates. The checks are small, but they answer different questions: does this local time exist, which occurrence do we mean, where does the day begin, and is the configured zone valid? Treating those as separate decisions makes the calendar easier to reason about and test. --- Further reading: - kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses – the project this article’s time zone handling is drawn from - PostgreSQL Production Checklist: UUIDs, RLS, Indexes, Pooling – the TIMESTAMPTZ convention this article’s storage model relies on - A Green Test Can Still Miss the Race: Testing Concurrency in PostgreSQL – a different kind of correctness problem in the same booking system - Luxon – Time Zones and Offsets – the official documentation on invalid and ambiguous local times cited above