Skip to main content

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.

Updated 10 min readMarkdown
UTC Is for Instants. Your Future Events Need a Zone.

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_id as the source of truth. Keep utc_start as 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.

Three cards for one 09:00 Beirut appointment on 30 March 2023: correct UTC is 06:00 when booked, 07:00 after the DST postponement, and 06:00 again after the 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?

The Clock-Change Test: stay fixed in absolute time (UTC instant), stay fixed on the wall clock (local time + IANA zone ID), stay fixed on the calendar (DATE), or nobody can answer (a product question)

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);

Comparison: store the answer (06:00 UTC only, silently wrong after a rule change) vs store the question (09:00 + Asia/Beirut, UTC kept as a derived copy and recomputed)

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 timestamptz and MySQL TIMESTAMP don'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.

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.

The '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?