Guide · System design
Calendar System Design: Recurring Events and Time Zones
24 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- In calendar system design, the hard part is not storing meetings. It is recurring events, meetings that repeat, which must keep their local clock time through daylight saving changes.
- Store a repeating event once, as a rule: an RRULE, the repeat pattern written in iCalendar, the standard format calendar apps share. Do not copy it into thousands of rows.
- Save the start as local wall-clock time plus a named zone such as America/Toronto. Convert to UTC, the world's reference clock, separately for each occurrence, meaning each actual meeting.
- Store edits as exceptions: a skipped date, or an override keyed by the occurrence's original start time. For a this-and-all-later edit, a common choice is to end the series and start a new one.
- Beginners can stop after the time zone section. Engineers then get a tested expander that turns rules into dates, two readings of skipped clock times in the iCalendar standard, and why fresh zone data may change nothing.
What goes wrong when a calendar repeats a meeting across time zones?
The meeting drifts by an hour. The app saved one fixed instant and repeated it, but people keep meetings at the same local clock time. When a country changes its clocks, those two ideas split apart. Keeping them in step is the central job in calendar system design.
Picture a common setup. A shared calendar app serves families and small firms in India, and many have relatives or clients abroad. The setup is made up, but the bug is real.
One family runs a weekly video call every Sunday. A relative in Toronto created it for 8:00 am his time. In October 2026 that is 5:30 pm in Kochi, 4:00 pm in Dubai and 11:00 pm in Sydney.
The first version of the event service turned 8:00 am Toronto into UTC once, which gave 12:00 UTC. UTC is the world's reference clock, and it never shifts for summer. Then it stored every future Sunday at 12:00 UTC.
In Toronto the call rings at 7:00 am, an hour early. In Kochi nothing changes at all: it still rings at 5:30 pm, because India does not use daylight saving. So the side that looks fine is the side that hides the bug.
The fix starts from one question. What does the person who created the event mean? He meant 8:00 am on his wall clock, every Sunday, wherever the offset from UTC happens to sit that week.
What is calendar system design?
Calendar system design is planning the storage, services and rules behind an app where people create events, invite others, see their schedule and get reminders. It makes a good interview question because recurring events and time zones hide many traps.
Start with requirements. Functional requirements say what the app must do. Non-functional requirements say how well it must do it, such as speed and uptime.
- Create, edit and delete events, including events that repeat daily, weekly, monthly or yearly.
- Change one occurrence, or this one and all later ones, without breaking the rest.
- Invite people and track who said yes, no or maybe.
- Show a day, week or month view in each viewer's own time zone.
- Send reminders at the right moment, such as 15 minutes before a call.
- Show free or busy slots, so people can find a common time.
For scale, sketch rough numbers (made up). The app has 5 lakh users (500,000), each with about 40 series and one-off events. That is about 2 crore (20 million) event rows. People open their week far more often than they edit it, so reads beat writes.
Four words you need first
Series. A series is one event that repeats, like the Sunday family call. Occurrence. An occurrence, also called an instance, is one actual meeting in that series, like the call on 1 November.
Recurrence rule. A recurrence rule says how a series repeats. iCalendar, defined in RFC 5545, writes it as an RRULE, like FREQ=WEEKLY;BYDAY=SU for every Sunday.
Time zone. A time zone here means a named place rule, such as Asia/Kolkata or America/Toronto. It is not just an offset like plus 5:30, because a place's offset can change during the year, and governments change the rules.
How should a calendar store recurring events?
Store each series once, as a master row with a start time, a zone, a duration and a recurrence rule. Store exceptions next to it, and work out the actual occurrences only when you need them.
The naive first try is one row per occurrence, and it breaks fast. A series with no end date has no last row. Renaming a weekly call means updating hundreds of rows, and one failed update leaves the series half changed.
RFC 5545 already describes the better shape. A recurring event has a DTSTART, its first start time. An RRULE describes the pattern. Optional RDATE values add extra dates, and EXDATE values remove dates from the set.
The RRULE has a few parts you will use daily. FREQ is how often, such as WEEKLY. INTERVAL is the gap, so INTERVAL=2 means every second week. BYDAY picks weekdays, and in a monthly rule 1MO means the first Monday of each month.
Two parts end a series. COUNT stops it after a number of occurrences, and the DTSTART always counts as the first. UNTIL stops it at a date or time, and that moment is included. The standard says one rule must not use both.
One detail catches people. When DTSTART has a named zone, RFC 5545 says UNTIL must be written in UTC, with a Z at the end. So the last 8:00 am Toronto call on 20 December 2026 becomes UNTIL=20261220T130000Z.
Here is the master row for the family call. It is written as YAML, a simple text format for settings and data.
event_id: ev_8812
title: Sunday family call
calendar_id: cal_family_0193
start_local: 2026-10-04T08:00:00 # wall-clock time, no offset
tzid: America/Toronto # the organiser's zone
duration_minutes: 60
rrule: FREQ=WEEKLY;BYDAY=SU
exdates: [2026-11-08T08:00:00] # skipped Sundays, local time
version: 7 # bumps on every editNotice that start_local has no offset. It means 8:00 am on the organiser's wall clock, wherever that clock sits in the year. The tzid field says which wall clock.
Exceptions live in two places. The exdates list removes occurrences. A separate overrides table holds occurrences that changed, keyed by event_id and the original start time. The standard calls that original start the RECURRENCE-ID.
A real public API uses the same shape. In the Google Calendar API, a recurring event carries RRULE and EXDATE lines, and its start time zone is required. A moved instance keeps an originalStartTime that identifies it within the series.
How do you handle time zones and daylight saving in a calendar?
Store future events as local wall-clock time plus a named zone, and convert each occurrence to UTC only when you expand it. Use UTC freely for things that already happened, such as when a row was edited.
Daylight saving, or DST, is when a place moves its clocks forward in spring and back in autumn. India and Dubai do not use it. Toronto and Sydney do, but in opposite halves of the year, so the gap between them changes four times a year.
Here is an everyday picture. When a big family plans a video call with relatives abroad, the host never writes 12:00 UTC in the family group. The host writes Sunday 8 am Toronto time, because the Toronto cousins host.
Each household then works out its own clock that week. When Toronto falls back on 1 November 2026, the Kochi relatives join at 6:30 pm instead of 5:30 pm. Nobody edits the message, because 8 am Toronto time is still true.
Map it part by part. The group message is the master row with its tzid. Each household doing its own sum is the expander, converting one occurrence at a time. The world clock on the wall is UTC, used only to compare moments.
Where the picture breaks: people resolve odd cases by asking each other. Software cannot ask. At 1:30 am on a fall-back night, it needs a written rule to pick which 1:30 am you meant.
The code below shows the naive version and the fix. It uses Python's zoneinfo module, which reads the IANA time zone rules. When you add a timedelta, a length of time like one week, to a datetime with a zone, Python counts in wall-clock time.
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
toronto = ZoneInfo("America/Toronto")
first = datetime(2026, 10, 25, 8, 0, tzinfo=toronto)
# Naive version: step forward in UTC
bad = first.astimezone(timezone.utc) + timedelta(weeks=1)
print(bad.astimezone(toronto)) # 2026-11-01 07:00:00-05:00
# Fixed version: step forward in Toronto wall-clock time
good = first + timedelta(weeks=1)
print(good) # 2026-11-01 08:00:00-05:00
print(good.astimezone(timezone.utc)) # 2026-11-01 13:00:00+00:00What just happened? The naive version adds a week to 12:00 UTC and lands on 7:00 am Toronto, which is the bug. The fixed version adds a week to 8:00 am Toronto and stays at 8:00 am. The UTC time moves to 13:00 instead, which is correct.
You can check it yourself. Run the file with Python 3.9 or later, which ships zoneinfo. On Windows, run pip install tzdata first, because Windows has no IANA zone files. The three lines it prints should match the comments, as they did on Python 3.13.16 with zone data 2026c and 2026e.
Times that do not exist, or happen twice
When clocks jump forward, some local times never happen. In Toronto on 8 March 2026, clocks jumped from 2:00 am to 3:00 am, so 2:30 am never existed. When clocks fall back, an hour happens twice, so 1:30 am on 1 November 2026 is ambiguous.
For a single date and time with a zone, RFC 5545 section 3.3.5 settles both cases. A missing time uses the offset from before the gap, so 2:30 am becomes 3:30 am. A repeated time means its first occurrence.
For times a rule generates, section 3.3.10 reads two ways. One sentence says such instances must be ignored and not counted. A later sentence says to read them like an explicit date and time, which again gives 3:30 am.
So pick one reading, write it down, and test it. The expander in this guide, Python's zoneinfo and python-dateutil 2.9.0.post0 all turned a weekly 2:30 am Toronto call into 3:30 am on 8 March 2026 in our run. Printed raw, dateutil's time can still read 2:30 with the old offset, but it is the 3:30 am moment.
Python's fold attribute picks between the two copies of a repeated time. With fold=0, the default, zoneinfo uses the offset from before the change, which is the earlier moment and matches the RFC's first occurrence.
Zone rules change, so keep them updated
The IANA Time Zone Database, often called tzdata, holds the history and rules of local time for many places worldwide. Governments change their clock rules, so new releases come out several times a year.
Release 2026e, dated 29 September 2026, says Manitoba moves to a permanent offset of UTC minus 5 hours on 31 October 2026. As of 19 October 2026 it is the latest release listed on the IANA page.
In practice, Winnipeg clocks do not fall back on 1 November 2026. A 9:00 am Winnipeg meeting on 4 November 2026 is 14:00 UTC with 2026e data. With older data, such as 2026c, the same meeting is 15:00 UTC.
This is the strongest reason to store local time plus zone. A UTC value saved with the old data is now an hour off. With local time saved, you update the zone data and recompute every Winnipeg series.
There is a trap when you update. The Python docs say zoneinfo reads the system's zone files first and uses the tzdata package from PyPI only as a fallback. Installing a new tzdata package may change nothing.
This script shows which data your code really uses. It prints the search path, the package version, and the Winnipeg meeting from above in UTC.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, TZPATH
print("search path:", TZPATH)
try:
import tzdata
print("tzdata package:", tzdata.IANA_VERSION)
except ImportError:
print("tzdata package: not installed")
meeting = datetime(2026, 11, 4, 9, 0, tzinfo=ZoneInfo("America/Winnipeg"))
print("9:00 am Winnipeg on 4 Nov 2026 =", meeting.astimezone(timezone.utc))In our run, the system files held 2026c and the tzdata package held 2026e. The default search path still printed 15:00 UTC. Setting the environment variable PYTHONTZPATH to an empty string forced the package and printed 14:00 UTC.
One more case matters. An all-day event, like a birthday or Onam, has no time of day. iCalendar has a plain DATE value type, a calendar date with no time, and the Google Calendar API uses a separate date field for all-day events. Store them as plain dates, so Onam is the same date for the Sydney cousins too.
What does the architecture of a calendar app look like?
A calendar app has an API in front and an event store for series and exceptions. A worker expands series into real occurrences, and a reminder scheduler reads those occurrences. Zone rules feed the expander.
Follow the arrows from the phone apps. Notice that only the expander turns rules into dates, and only the occurrence table feeds reminders.
Calendar app architecture
Drag to exploreThe event store is the source of truth. The occurrence table is a rebuildable copy for fast reads.
Diagram connections:
- Phone and web apps to Calendar API (Create, edit, read)
- Calendar API to Event store (Write series and exceptions)
- Event store to Expander worker (Change feed)
- IANA zone data to Expander worker (Zone rules)
- Expander worker to Occurrence table (Write occurrences)
- Calendar API to Occurrence table (Week view, free or busy)
- Occurrence table to Reminder scheduler (Due reminders)
- Reminder scheduler to Notification sender
- Phone and web apps: Show views in each viewer's zone
- Calendar API: Checks access, validates rules
- Event store: Series, exceptions, attendees
- Expander worker: Turns rules into real start times
- IANA zone data: Offsets and DST rules per place
- Occurrence table: Next 12 months, start times in UTC
- Reminder scheduler: Finds reminders due in the next minute
- Notification sender: Push, email or SMS
The event store can be PostgreSQL, a relational database. If one server cannot keep up, you shard it by calendar_id, meaning you split rows across servers so each calendar stays together. A week view of one calendar then reads from one server.
Invites cross that line. A guest's week view reads the guest's own calendar, not the organiser's. A common design writes a small invite row into each guest's calendar, pointing back to the event, so one read still covers the week.
The change feed is a stream of every edit. When a series changes, the expander rebuilds only that series' occurrences. The version number on each row lets the expander ignore an older edit that arrives late.
How does a calendar show a week view fast?
It reads ready-made occurrences for a rolling window, such as the next 12 months, and expands rules on the fly only for dates outside that window. This mix keeps common views fast without storing endless rows.
There are three choices. You can expand every rule on every read, store every occurrence forever, or store a window and expand the rest. The table compares them for the family calendar app.
| Expand on every read | Store all occurrences | Rolling window plus on the fly | |
|---|---|---|---|
| Storage | Smallest, rules only | Huge, endless series cannot finish | About 2 crore event rows plus 1 year of occurrences |
| Week view speed | Slower, more work per read | Fastest | Fast for the next year |
| Free or busy for many people | Slow, expands every series | Fast, one range query | Fast inside the window |
| Edit a weekly series | One row | Hundreds of rows | One row plus a rebuild job |
| Zone data update | Nothing to fix | Rewrite many rows | Rebuild affected series |
| Best for | Small apps, rare reads | Series with an end date only | Most calendar apps with repeating events |
Here is what happens when a viewer opens a week. Each box names one step, and its note says who talks to whom. Look at the question box: it decides between the fast path and the slow one.
Loading a week view
Drag to exploreMost requests take the left path. Far-future weeks take the slower expand path.
Diagram connections:
- Ask for one week to Week inside the window?
- Week inside the window? to Read stored occurrences (Yes)
- Week inside the window? to Expand series on the fly (No, far future)
- Read stored occurrences to Send in viewer's zone
- Expand series on the fly to Send in viewer's zone
- Ask for one week: App → API
- Week inside the window?: API → Occurrence table
- Read stored occurrences: API → Occurrence table
- Expand series on the fly: API → Event store
- Send in viewer's zone: API → App
Below is a small expander for weekly series, the core of both paths. The series is a dictionary like the YAML row, with naive local times and an override that already holds its new start in UTC. The window bounds are UTC times.
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
def expand_weekly(series, window_start, window_end):
zone = ZoneInfo(series["tzid"])
local = series["start_local"] # wall-clock time, no zone
found = []
while True:
start = local.replace(tzinfo=zone).astimezone(timezone.utc)
if start >= window_end:
break
if start >= window_start and local not in series["exdates"]:
found.append(series["overrides"].get(local, start))
local += timedelta(weeks=1) # step in local time, not UTC
return found
utc = timezone.utc
family_call = {
"tzid": "America/Toronto",
"start_local": datetime(2026, 10, 4, 8, 0),
"exdates": [datetime(2026, 11, 8, 8, 0)],
# 15 November moved to Saturday 14 November, 9:00 am Toronto
"overrides": {datetime(2026, 11, 15, 8, 0): datetime(2026, 11, 14, 14, 0, tzinfo=utc)},
}
for t in expand_weekly(family_call, datetime(2026, 10, 25, tzinfo=utc),
datetime(2026, 11, 23, tzinfo=utc)):
print(t)Run it and you should see exactly these four UTC start times. The 8 November call is missing because it is in exdates, and 15 November appears as its Saturday override.
2026-10-25 12:00:00+00:00
2026-11-01 13:00:00+00:00
2026-11-14 14:00:00+00:00
2026-11-22 13:00:00+00:00What just happened? The replace line attaches the zone to each local time, so 25 October gets 12:00 UTC and 1 November gets 13:00 UTC. The exdates check drops the skipped Sunday. The overrides lookup uses the original local start as its key, which is the RECURRENCE-ID idea.
Real code adds more. It jumps straight to the window instead of walking from the first Sunday, honours COUNT and UNTIL, and handles every FREQ. It also checks each override's new start against the window, because a moved call can cross the window edge. Many teams use a tested recurrence library instead of writing this logic by hand.
Check the library against the standard too. In our run, python-dateutil 2.9.0.post0 accepted a rule with both COUNT and UNTIL. Its only complaint was a DeprecationWarning that Python hides by default, so nothing showed on screen. So the calendar API should reject such rules itself.
How do you edit one event in a recurring series?
You decide how far the change reaches. Change only this occurrence by saving an override, change this and all later ones by splitting the series, or change every occurrence by updating the master row.
Back to the family calls. One week the host posts a note: this week only, the call moves from Sunday 15 November to Saturday 14 November at 9 am. The weekly plan stays. That one-off note is an override.
Later the host posts a different kind of note: from 27 December, all calls start at 9 am. Old calls stay as they were. That is a split: the old series ends, and a new one begins.
Editing a recurring event
Drag to exploreEach answer writes a different kind of record, then the expander rebuilds.
Diagram connections:
- Someone edits one call to Which calls change?
- Which calls change? to Save an override (Only this one)
- Which calls change? to Split the series (This and following)
- Which calls change? to Update the master row (All calls)
- Save an override to Rebuild and notify
- Split the series to Rebuild and notify
- Update the master row to Rebuild and notify
- Someone edits one call: Moves the 15 November call
- Which calls change?: The app asks the user
- Save an override: Keyed by the original start
- Split the series: Old series gets UNTIL, new one starts
- Update the master row: Overrides kept or cleared by rule
- Rebuild and notify: Expander redoes this series
The split is the trickiest edit. Here is how the service moves the calls to 9 am from Sunday 27 December 2026.
End the old series
Set UNTIL on the old rule to the last old call, Sunday 20 December at 8:00 am Toronto, written in UTC as 20261220T130000Z. Earlier calls keep their time and history.
Create the new series
Copy the title, attendees and zone into a new row. Set start_local to 27 December 9:00 am with the same weekly rule.
Move later exceptions
Overrides and exdates after the split belong to the new series. Shift their keys to the new 9:00 am start, or drop them if they no longer make sense.
Link the two rows
Store a parent id on the new series, so the app can show one history and undo the split if needed.
Write it in one transaction
Save both rows in one transaction, a group of writes that all succeed or all fail. No reader ever sees the old series ended without the new one.
RFC 5545 offers another way. A RECURRENCE-ID with RANGE=THISANDFUTURE applies a change to that occurrence and every later one. Splitting is a common alternative, because two plain series are easier to query and sync.
How do reminders work for recurring events across time zones?
Reminders fire from the occurrence table, where every start is already a UTC instant. The scheduler subtracts the reminder offset, such as 15 minutes, and sends the alert when that moment arrives.
This works because the hard work happened earlier. The expander already turned 8:00 am Toronto on 1 November 2026 into 13:00 UTC. A 15-minute reminder fires at 12:45 UTC, which is 7:45 am in Toronto and 6:15 pm in Kochi.
Add a reminder fire time column, start minus offset, with an index on it. An index is a sorted lookup that makes range searches fast. Every minute, the scheduler asks for reminders due in the next minute, like a task scheduler built around a sorted list of times.
- Rebuild reminders when a series changes, because the old fire times are now wrong.
- Rebuild every affected series when new zone data arrives, as with Manitoba's change.
- Give each reminder a unique key: event id, original occurrence start, attendee and reminder offset. A retry skips keys already marked sent, so duplicates are rare, though a crash after sending can still repeat one.
- Skip reminders for occurrences that were deleted or for attendees who declined.
Attendees add one more layer. Each person's yes, no or maybe can differ per occurrence. Store a series-level answer, and let an override row hold a different answer for one Sunday.
How do you find a free slot for people in different time zones?
You collect each person's busy intervals in UTC for the chosen range, merge them, and look for gaps. Then you show those gaps in each person's local time, and drop gaps outside their working or waking hours.
Take a small firm on the app with staff in Kochi and a client in Sydney. Kochi works 9:30 am to 6:30 pm India time, and the client works 9:00 am to 5:00 pm Sydney time.
From 4 October 2026 to 4 April 2027, Sydney is on daylight saving and 5 hours 30 minutes ahead of India. On Monday 9 November 2026, the shared hours run 04:00 to 06:00 UTC. That is 9:30 to 11:30 am in Kochi and 3:00 to 5:00 pm in Sydney.
So the whole overlap is two hours before anyone's calendar is even checked. After Sydney's clocks go back in April, the gap shrinks to 4 hours 30 minutes, and the overlap grows to three hours.
Free or busy checks read only start and end times, never titles. CalDAV even has a separate privilege for free or busy queries, which does not grant reading the events themselves. The app copies that idea, so a client sees busy blocks, not meeting names.
How do you explain calendar system design in a system design interview?
Start small with one-off events. Then add recurrence, exceptions and time zones in that order, and justify each piece with a bug it prevents.
Ask about scope
Ask how many users, whether events repeat, whether people span time zones, and whether invites and reminders are in scope.
Draw the core parts
Draw the API, the event store, the expander, the occurrence table and the reminder scheduler. Say which one is the source of truth.
Store rules, not copies
Explain the master row with DTSTART, a named zone and an RRULE. Explain why one row per occurrence fails for endless series.
Handle time zones
Say local time plus named zone for future events, and UTC for past facts. Mention DST gaps, repeated hours and zone data updates.
Handle edits
Walk through the three edit scopes: override, split and master update. Mention keying overrides by the original start.
Talk about scale
Shard by calendar if needed, keep a rolling window of occurrences, and rebuild from the change feed. Give rough numbers out loud.
Questions people ask
- Should I store calendar events in UTC?
- Store past facts in UTC, but store future events as local time plus a named zone. A future meeting means a wall-clock time in a place. If that place changes its clocks or its rules, a stored UTC value becomes wrong. Convert to UTC per occurrence when you expand the series.
- What is an RRULE in a calendar?
- An RRULE is a recurrence rule from the iCalendar standard, RFC 5545, that says how an event repeats. For example, FREQ=WEEKLY;BYDAY=SU means every Sunday. It can use INTERVAL for gaps, COUNT for a number of occurrences, or UNTIL for an end moment, but the standard says never both COUNT and UNTIL.
- How do you store an exception to a recurring event?
- Use two kinds of record. A deleted occurrence goes in an exdates list on the series. A changed occurrence goes in an overrides table, keyed by the series id and the original start time, which iCalendar calls the RECURRENCE-ID. The expander removes or replaces those occurrences when it builds the schedule.
- How far ahead should a calendar expand recurring events?
- A common design keeps a rolling window, such as the next 6 to 12 months, and expands anything further on the fly. The window should cover the views and reminders people use most. A daily job extends it, so long-running series never fall off the end.
- What happens to an event scheduled during a daylight saving gap?
- It depends on which part of RFC 5545 you follow, so decide and test. For a single date and time, a missing local time uses the offset from before the gap, so 2:30 am becomes 3:30 am. For times a rule generates, the RFC says both to ignore them and to read them like a single date and time, so pick one reading and test it.
- How do calendar apps sync between phone and server?
- Many use CalDAV, RFC 4791, where the server stores each event, with all its exceptions, as one calendar object that clients read and write. Other apps use their own API with a change log, so each device fetches only what changed since its last sync. The server stays the source of truth.
- Do I need to handle time zones if all my users are in India?
- Yes, plan for it early, even though India has one zone and no daylight saving. Users travel, invite relatives abroad and work with foreign clients. Storing a named zone from day one costs one column, while adding it later means migrating every event.
Sources and further reading
- RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar), sections 3.3.5 and 3.3.10
- RFC 4791: Calendaring Extensions to WebDAV (CalDAV)
- IANA Time Zone Database: latest release and release notes
- Python documentation: zoneinfo, IANA time zone support
- Google Calendar API reference: Events resource
- Google Calendar API guide: Recurring events