UTC Is for Instants. Your Future Events Need a Zone.
Why scheduled events stored as UTC shift when time zone rules change, and the schema, code and refresh job that fix it.

TL;DR
- UTC is the right format for things that already happened. For future events defined on someone's local clock, a UTC value is a forecast computed with today's time zone rules.
- Governments change those rules, sometimes with days of notice. When they do, every future event you stored as UTC-only silently moves.
- Store
local_start+zone_idas the source of truth. Keeputc_startas a derived, recomputable cache, stamped with the tzdata version that produced it.
The bug in one insert
Here's a line that looks perfectly responsible:
-- A team in Mexico City schedules a 9:00 meeting for 10 July 2023 (booked in 2022).
INSERT INTO meetings (title, starts_at)
VALUES ('Quarterly review', '2023-07-10 09:00 America/Mexico_City');
-- starts_at is TIMESTAMPTZ: Postgres converts to an instant and discards the zone name.
When this ran in, say, September 2022, Mexico City still observed daylight saving time. Summer 9:00 there was UTC−5, so Postgres stored 14:00 UTC.
Then, in late October 2022, Mexico's Senate voted to end daylight saving for most of the country. Under the new rules, July 2023 in Mexico City is UTC−6. The meeting the team booked for 9:00 now renders as 8:00, because 14:00 UTC is all the database kept.
Nobody edited the row. The rules underneath it changed, and the row had no memory of what anyone actually asked for.
Why UTC can't hold a future local time
Converting "9:00 on 10 July in Mexico City" to UTC takes three inputs:
utc_instant = f(local wall time, place, rules for that place on that date)
The first two come from the user. The third comes from the IANA time zone database (tzdata), and it's law, not physics. Store only the output and you've frozen the third input forever.
UTC is a record. A future appointment is a promise.
Past events don't have this problem. A payment captured at 2023-03-01T10:15:00Z happened; no later decision can move it.
How often the rules actually move
More often than most backend code assumes:
- Mexico, October 2022: the Senate voted on 26 October to end DST for most of the country, days before the clocks fell back for the last time there.
- Egypt, 2023: announced in early March that DST would return; it took effect on 28 April.
- Lebanon, March 2023: DST was postponed on 23 March, two days before the change, then reinstated on 27 March. For a few days the country effectively ran two times at once.
tzdata shipped three releases between 22 and 28 March 2023: 2023a (Egypt, plus changes in Morocco, Palestine and Greenland), then 2023b and 2023c for Lebanon's postponement and reversal.

An illustrative Beirut appointment run through the real Lebanon changes: the correct UTC answer moved twice in under a week. The local time never did.
And you don't need a government at all. A "Mondays, 9:00" meeting created in London in January and stored as 09:00Z with a weekly repeat becomes 10:00 London time the day the UK switches to summer time.
Three kinds of time: the Clock-Change Test
Before picking a column type, ask one question:
If the government changed the clocks tomorrow, what should this value do?

| Answer | What it is | Store | Examples |
|---|---|---|---|
| Stay fixed in absolute time | An instant or a duration | UTC (timestamptz) |
logs, payments, created_at, token expiry, "every 6 hours" |
| Stay fixed on the wall clock | A promise made in a place | local timestamp + IANA zone_id (+ derived UTC) |
appointments, standups, opening hours, classes |
| Stay fixed on the calendar | A date, not a moment | DATE |
birthdays, holidays, contract end dates |
| Nobody can answer | Undefined requirement | Nothing yet | "due end of day Friday" (whose Friday?) |
The date row has its own classic bug: a birthday saved as midnight UTC displays as the previous day for anyone west of Greenwich.
Five production traps
1. timestamptz doesn't store a time zone
PostgreSQL's timestamp with time zone converts input to an absolute instant and keeps no zone. MySQL's TIMESTAMP does the same via the session time zone. For an appointment's wall time, the "naive" type (timestamp / DATETIME) is correct, as long as a zone column sits beside it.
2. An offset is not a zone
2023-03-30T09:00:00+03:00 looks complete. But +03:00 is Beirut's rules on the day you formatted the string. Asia/Beirut points at whatever the rules currently are.
An offset is a photograph of a rule. A zone ID is the rule itself.
Capture the IANA zone name from the client (or ask the user). Don't infer it from the browser's current offset.
3. Local times that don't exist, or happen twice
On spring-forward nights, 02:30 doesn't exist in many zones. On fall-back nights, 01:30 happens twice. Libraries pick silently, and differently. Detect both and apply a written policy:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
class NonexistentLocalTime(ValueError):
pass
def to_utc(local: datetime, zone_id: str) -> datetime:
"""local is naive wall time, e.g. datetime(2023, 3, 30, 9, 0)."""
tz = ZoneInfo(zone_id)
first = local.replace(tzinfo=tz, fold=0)
second = local.replace(tzinfo=tz, fold=1)
# Inside a spring-forward gap, the wall time doesn't survive a round trip.
back = first.astimezone(timezone.utc).astimezone(tz).replace(tzinfo=None)
if back != local:
raise NonexistentLocalTime(f"{local} does not exist in {zone_id}")
if first.utcoffset() != second.utcoffset():
pass # ambiguous: fold=0 = first occurrence. Make it policy, not accident.
return first.astimezone(timezone.utc)
4. The copies outlive the fix
The UTC value doesn't only live in the row. It's in the queued reminder (send_at), the .ics invite already emailed, the push job scheduled with a provider, the cached countdown, the partner webhook. Recompute the row and every copy still holds the old answer. It's the same failure as a worker that reads stale data and saves it downstream.
5. Your stack disagrees about the rules
tzdata lives in many places: each container's OS image, the JVM, PostgreSQL's bundled copy, browsers, Python's system data or tzdata package. A rule change reaches each on a different day, and an image that's never rebuilt never gets it. Two services can compute two UTC values for one appointment. A data file changed behavior with no deploy, exactly the pattern in Nobody Deployed Anything. Production Broke Anyway.
The pattern: store the question, cache the answer
This is the approach Jon Skeet described in his 2019 post "Storing UTC is not a silver bullet." As a table:
CREATE TABLE appointments (
id bigserial PRIMARY KEY,
local_start timestamp NOT NULL, -- what the person chose
zone_id text NOT NULL, -- 'Asia/Beirut', never '+03:00'
utc_start timestamptz NOT NULL, -- derived: sorting, "what's next", scheduling
tz_version text NOT NULL -- rules that produced utc_start, e.g. '2023c'
);
CREATE INDEX ON appointments (utc_start);

utc_start is now a cache, and like any cache that serves stale data, it needs invalidation. The trigger is a tzdata update, not a write. Run a refresh job whenever your rules version changes (with the tzdata PyPI package, that's tzdata.IANA_VERSION):
def refresh_future(db, rules_version: str) -> None:
rows = db.fetch("""
SELECT id, local_start, zone_id, utc_start FROM appointments
WHERE utc_start > now() - interval '2 days'
AND tz_version <> %s""", [rules_version])
for r in rows:
new_utc = to_utc(r.local_start, r.zone_id)
with db.transaction():
db.execute(
"UPDATE appointments SET utc_start = %s, tz_version = %s WHERE id = %s",
[new_utc, rules_version, r.id])
if new_utc != r.utc_start:
db.enqueue_outbox("appointment.rescheduled", r.id, new_utc)
Two deliberate choices:
- Every row gets the new version stamp, even if it didn't move. The job becomes rerunnable, and you can prove which rules each row was computed under.
- Copies are rescheduled through an outbox in the same transaction, not a direct call. Updating the row and notifying another system are two writes, and there's no safe order for two writes. A consumer reschedules reminders, re-sends invites and refreshes caches.
When UTC-only is still the right call
- Machine schedules and durations: backups every six hours, token TTLs, rate-limit windows.
- Events defined as instants by their creator: a global livestream announced as "15:00 UTC."
The cost of the pattern is real: extra columns, a job to own, zone capture, explicit gap/overlap policy, and tracking tzdata across your stack. The Clock-Change Test tells you where it's worth paying.
Key Takeaways
- A UTC value for a future local event is
f(local time, place, rules). Store the inputs, not just the output. - Postgres
timestamptzand MySQLTIMESTAMPdon't keep a zone. Pair a naive local timestamp with an IANA zone ID. - Offsets (
+03:00) freeze rules; zone IDs (Asia/Beirut) follow them. - Handle nonexistent and ambiguous local times with a written policy.
- On every tzdata update: recompute future rows, stamp the version, reschedule every copy via an outbox.
- Use the Clock-Change Test to decide each column: instant, wall-clock promise, date, or "ask product."
FAQ
Should I store future dates in UTC?
Only if the time is defined as an instant (a token expiry, a livestream announced in UTC). If a person scheduled it on a local clock, store the local time plus an IANA zone ID and keep UTC as a derived, recomputable value.
Does PostgreSQL timestamptz store the time zone?
No. timestamptz converts the input to an absolute instant and discards the zone. Output is shown in the session's time zone. To keep the zone, store it in a separate column.
What's the difference between a UTC offset and a time zone?
An offset like +03:00 is a fixed difference from UTC on one date. A time zone like Asia/Beirut is a named set of rules (offsets, DST dates, history) that can change when a government acts.
How should recurring events handle daylight saving time?
Store the recurrence as a rule on local time with a zone ("Mondays 09:00, Europe/London") and expand each occurrence with that day's rules. A single UTC time with a weekly repeat drifts by an hour at every DST change.
What happens when time zone rules change after an event is booked?
If you stored only UTC, the event silently shifts in local time. If you stored local time and zone, recompute UTC with the new tzdata release and reschedule reminders, invites and notifications that copied the old value.
How do I handle a local time that doesn't exist because of DST?
Detect it (in Python, a gap time doesn't survive a UTC round trip) and apply a documented policy, commonly shifting forward by the gap or rejecting the booking with a clear message.
Related reading
- Nobody Deployed Anything. Production Broke Anyway.
- Your Worker Read the Past. Then It Saved It.
- There's No Safe Order for Two Writes
- Why Your Cache Is Serving Stale Data (And How to Actually Fix It)
The bottom line
Keep UTC for what happened. For what was promised, keep the promise, and treat UTC as the fast, disposable answer you recompute whenever the world moves the clocks.
Store what the person meant. Compute what the machine needs.
New here? Start Here: A Plain-English Map to Understanding AI · Get one of these in your inbox each week: subscribe to Simply Explained
Adam Jaber is a software engineer who writes Simply Explained: complex topics, made simple. No jargon, no hype.
About the author
- I'm a senior software engineer. I write Simply Explained, a plain-English breakdown of how AI and software actually work, and the quiet ways they break: AI fixes that hide bugs, agents that pick the wrong tool, health checks that take down healthy servers. One symptom, one analogy, one fix per piece. New here? Start with the pinned Start Here guide, or get one breakdown a week by email.
Comments
Join the discussionThe 'store the question, cache the answer' framing is the clearest way I've seen to put this. One thing I'd add is a detail about recurrence: for repeating events, the series rule (local time, zone, RRULE) is the source of truth and each occurrence's UTC is derived, so a tzdata refresh job should regenerate only future occurrences and never touch ones already in the past. Also, nonexistent or ambiguous local times (the spring-forward gap, the fall-back repeat) probably deserve an explicit policy stored per event rather than a global default. How would you decide when the refresh job runs, on each tzdata release or lazily on read?




