Why "always store UTC" breaks for future appointments
"Store everything in UTC" is good advice for things that have already happened. A payment, a login and a log line each occurred at one instant, and no later decision by a government can move them. Converting them to UTC loses nothing.
A dentist appointment at 10:00 next April in Lisbon is different. Nobody agreed on an instant. The patient agreed to turn up when the clock on the clinic wall says 10:00. The instant that corresponds to depends on the offset Portugal will be using next April, and that is decided by the government, not by your database.
If you convert to UTC at booking time, you bake in today's rules. Suppose the country then changes when daylight saving time starts or abolishes it entirely. The UTC value in your table does not move, so the wall-clock time it represents moves by an hour. The appointment silently becomes 09:00 or 11:00, the reminder fires at the wrong moment, and the clinic's own calendar disagrees with yours. Governments have changed these rules at a few weeks' notice, which is less time than many bookings sit in a table.
What goes in the row
The record carries the facts the user supplied, plus a derived column you are free to throw away and rebuild:
| Column | Example | Role |
|---|
local_start | 2027-04-12 10:00 | Source of truth, no offset attached |
zone_id | Europe/Lisbon | IANA identifier for where the appointment happens |
duration | 30 minutes | Elapsed time, not an end wall-clock time |
utc_start | 2027-04-12 09:00Z | Derived, indexed, used for reminders and range queries |
tz_version | the tzdata release used | Lets you find rows computed under old rules |
The time zone is the one for the place where the appointment happens, not the zone of whoever is looking at it. The provider's clinic in Lisbon fixes the zone even if the customer booked from Toronto. For a call with no physical location, you pick one side, usually the organiser, and say so in the booking.
In PostgreSQL, note what timestamptz does: it converts the input to UTC and keeps no record of the zone or the offset it came in with. It is right for utc_start and wrong as the only column, because the information you need to recompute is gone.
The tz database is data that changes
The IANA tz database (tzdata) records every zone's offset history and announced future rules. It ships in regular releases whenever a government announces a change, and your operating system, language runtime, database and the user's browser each carry their own copy.
That has two consequences. First, a rule change only reaches you when the copy your code uses is updated, so tzdata becomes part of your patching process. A standard JDK build, for example, ships its own copy of the zone rules, separate from the operating system's. Second, every utc_start computed under the old release may now be wrong. Because you kept local_start and zone_id, the fix is mechanical: recompute the derived column for future rows in the affected zones, then reschedule the reminders that hang off it. Without those two columns there is nothing to recompute from.
The same reasoning tells you why you refuse a fixed offset. +01:00 says what the offset was on one date. It cannot tell you what it will be after the next rule change, and it cannot distinguish summer in Lisbon from Lagos in Nigeria. Abbreviations are worse: IST is used for India, Ireland and Israel.
Gaps and overlaps at booking time
Daylight saving time transitions create local times that never happen and local times that happen twice. In Europe/London, on 28 March 2027 clocks jump from 01:00 to 02:00, so 01:30 does not exist. On 31 October 2027 they fall back from 02:00 to 01:00, so 01:30 occurs twice, an hour apart.
Detect both when the user books, while you can still ask them:
The comment on the first line of classify matters. The answer is computed from today's rules, which is exactly why the result is a cache. Libraries resolve these cases silently if you let them: ZonedDateTime.of shifts a gap time forward by the length of the gap and picks the earlier offset in an overlap, and Python's zoneinfo uses the fold attribute to choose. Silent resolution is fine for a log line and wrong for a booking, because the user never learns their appointment moved.
The mistake that separates answers
The weak answer stops at "store UTC, convert on display". It is correct for past events and quietly wrong for every future one, and it fails precisely in the scenario the question describes: a country changing its rules after the booking was made.
The strong answer keeps the user's wall-clock time and the zone ID as the record and treats the instant as derived. It knows tzdata is a dependency that needs updating, and it rejects or disambiguates impossible times while the user is still on the page. Recurring appointments follow the same rule: store the series as "every Monday at 09:00 in this zone" and expand occurrences from it. Do not store fifty-two precomputed instants.
A future appointment is an agreement about a wall clock in a place. Store the wall clock and the place, and derive the instant from rules you expect to change.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).