Recurring events
A recurrence names civil times, not instants — store the rule and the zone, and materialize occurrences as late as possible.
“Every day at 9 AM” is not a list of instants. It is a rule that produces wall-clock readings — civil times — and each occurrence only becomes an instant when you resolve it through a zone. The occurrences do not even share a UTC offset: in America/New_York, 9 AM is 14:00Z on 2026-03-07 but 13:00Z from 2026-03-08 until the fall transition, because DST moved the zone from −05:00 to −04:00 overnight.
That single fact defeats the tempting shortcut of “just precompute the UTC instants”. A meeting series materialized as fixed UTC timestamps drifts by an hour twice a year — the 14:00Z occurrence fires at 10 AM New York time after the spring transition. And DST is only the scheduled way this breaks: zone rules also change by legislation, sometimes on short notice, and a tz database update silently invalidates every precomputed future instant while leaving the stored rows looking perfectly healthy.
Store the rule, not the occurrences
The durable representation of a recurrence is the rule itself, together with the IANA zone name it is anchored in. The standard vocabulary is the iCalendar RRULE (RFC 5545):
DTSTART;TZID=America/New_York:20260307T090000
RRULE:FREQ=DAILY
DTSTART carries a local date-time plus a zone — deliberately not UTC — and the rule (FREQ, INTERVAL, BYDAY, bounded by COUNT or UNTIL) generates civil occurrences from it. Exceptions travel with the rule rather than being scattered into materialized rows: EXDATE cancels individual occurrences, and a companion entry keyed by RECURRENCE-ID overrides one (the “this meeting moved to 10 AM just this week” case). You do not need an iCalendar stack to benefit — a rule + zone + exceptions schema in your own tables preserves the same intent, and your schema should say so plainly.
Resolve occurrences to instants as late as you can: expand on read, or materialize only a short rolling window (for reminders, queues, or calendar-view caching) and treat that window as a disposable cache, rebuilt whenever the rule, its zone’s rules, or the tzdb changes. This is store vs display’s convert-at-the-edge principle applied to time itself.
Pitfall: Materializing a recurring series as fixed UTC instants. Every occurrence after the next DST transition is wrong by the shift — “9 AM daily” pinned to 14:00Z fires at 10 AM local half the year — and a tzdb rule change breaks the stored future silently. Store the rule and the zone; treat expanded instants as a cache with an expiry, never as the source of truth.
Expansion must cross DST on purpose
Expanding a rule means stepping through calendar days and re-attaching the wall-clock time in the zone for each occurrence — letting the offset fall where the zone’s rules put it:
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
ny = ZoneInfo('America/New_York')
day = date(2026, 3, 7)
for _ in range(3):
occurrence = datetime.combine(day, time(9, 0), tzinfo=ny)
print(occurrence.isoformat())
day += timedelta(days=1)
# 2026-03-07T09:00:00-05:00
# 2026-03-08T09:00:00-04:00 <- offset changed; 9 AM held
# 2026-03-09T09:00:00-04:00
The same expansion around both 2026 transitions in America/New_York:
| Occurrence (civil) | Offset | Instant (UTC) |
|---|---|---|
| 2026-03-07 09:00 | −05:00 | 14:00Z |
| 2026-03-08 09:00 | −04:00 | 13:00Z |
| 2026-10-31 09:00 | −04:00 | 13:00Z |
| 2026-11-01 09:00 | −05:00 | 14:00Z |
What expansion must not do is step with exact arithmetic. Adding 24 hours to the previous occurrence’s instant produces “the same instant tomorrow”, which after a transition is not the same wall-clock time — the series drifts by the shift and never recovers. This is exactly the exact-vs-calendar duration distinction: a recurrence steps in calendar units.
A rule whose time falls inside a transition needs a policy, too: “daily at 02:30” hits a nonexistent time on the spring-forward day and an ambiguous one at fall-back, and your expander either raises, shifts, or picks an offset. DST & the edges covers the gap/overlap mechanics and how library defaults differ.
Pitfall: Generating “daily” occurrences by adding 86,400 seconds (or 24 hours) to the previous instant. The step is exact but the intent is civil: after a spring-forward transition the 9 AM series silently becomes a 10 AM series (or 8 AM, in fall). Step the calendar day and resolve the wall-clock time in the zone for each occurrence.
See also: Instant vs civil time, DST & the edges, Durations, and Store vs display.
Go deeper: RFC 5545 corners, libraries, and tzdb churn
UNTIL is specified in UTC. RFC 5545’s sharpest edge: when DTSTART is a zoned local time, the rule’s UNTIL bound must nonetheless be written as UTC (UNTIL=20261101T140000Z). Producers that write a local UNTIL create rules that different consumers end (or don’t) on different occurrences. COUNT sidesteps the ambiguity by bounding the series by cardinality instead of by time — but a rule may carry COUNT or UNTIL, never both.
Monthly rules skip missing days. FREQ=MONTHLY anchored on the 31st generates occurrences only in months that have a 31st — February, April, June, September, and November are silently skipped, not clamped. If you mean “last day of the month”, say so: BYMONTHDAY=-1. (Compare the duration arithmetic version of this trap, where libraries clamp or throw instead of skipping.)
Use a library for expansion. Correct RRULE expansion (leap years, BYDAY with ordinals like BYDAY=-1SU, week starts, exceptions) is well-trodden library territory — dateutil.rrule in Python, rrule.js in JavaScript, java.time + ical4j on the JVM. Check each library’s zone semantics before trusting it across transitions: some expand in naive local time and leave zone resolution to you, and attaching the zone per occurrence (as above) is then still your job.
The tzdb is part of your schedule. A recurrence anchored in a zone inherits every future change to that zone’s rules. Legislatures move or abolish DST with real lead times measured in months — occasionally weeks or even days, as Morocco has demonstrated more than once; an updated IANA tz database changes which instants your stored rules denote. This is a feature — the rule stays right when the world changes — but only if expanded instants are treated as a rebuildable cache, and your runtimes actually receive tzdb updates.
See also. Points vs periods for why an occurrence with a duration is a period, not a point; Naming time fields for keeping a starts_at_local + timezone pair honest in a schema.