---
title: "Calendar time zones and DST · Workwae"
description: "How Google Calendar, Microsoft Graph and CalDAV carry time zones: Windows and IANA names, all-day dates, DST in recurring events and repeated local times."
url: https://workwae.com/blog/calendar-time-zones-and-dst
---

Blog

# Calendar time zones and DST: Google, Microsoft Graph and CalDAV.

A calendar event is an instant, a zone and sometimes just a date, and the three only agree until the clocks change.

November 2, 2026 · 6 min read

- [Calendar](https://workwae.com/blog/tag/calendar)
- [Microsoft Graph](https://workwae.com/blog/tag/microsoft-graph)

US clocks went back on [Sunday, November 1](https://www.timeanddate.com/time/change/usa?year=2026). Germany’s went back a week earlier, on [October 25](https://www.timeanddate.com/time/change/germany?year=2026). For one week, a 9:00 meeting in New York was 14:00 in Berlin instead of 15:00. An app that stored the wrong thing, or read the right thing in the wrong zone, showed some users the wrong hour.

Google Calendar, Microsoft Graph and [CalDAV](https://workwae.com/glossary#caldav) describe an event’s time differently. This post covers what each sends, where all-day and recurring events stop being plain instants, what to do with times that do not exist or happen twice, and how Workwae normalizes them.

## Store the instant and the event’s zone.

A UTC instant answers “when does this happen?” It does not answer “what does the organizer expect next week?” A weekly 9:00 meeting in New York is 13:00 UTC before November 1 and 14:00 UTC after, so an app that kept only the first occurrence’s instant would be an hour off every Monday after the change. Keep both: the instant, for sorting, overlap checks and free/busy, and the IANA zone the event was scheduled in ([IANA Time Zone Database](https://www.iana.org/time-zones), e.g. `America/New_York`). A UTC offset is no stand-in for a zone. It is one fact about one day.

## Graph speaks Windows zone names.

Microsoft Graph represents a time as a `dateTimeTimeZone`: a local `dateTime` with no offset, and a `timeZone` such as “Pacific Standard Time”. It can be any [time zone Windows supports](https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/default-time-zones) among others, and Microsoft notes that create and update might not accept every one ([dateTimeTimeZone](https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone)).

On reads, start and end come back in UTC unless you send `Prefer: outlook.timezone` with a zone name. The zone an event was created in is in `originalStartTimeZone` and `originalEndTimeZone` ([Get event](https://learn.microsoft.com/en-us/graph/api/event-get), [event resource](https://learn.microsoft.com/en-us/graph/api/resources/event)). A legacy custom Outlook zone shows up as `tzone://Microsoft/Custom`.

graph-event.http

```http
GET https://graph.microsoft.com/v1.0/me/events/{id}?$select=start,end,originalStartTimeZone,isAllDay
Prefer: outlook.timezone="Eastern Standard Time"

# start: { "dateTime": "2026-11-02T09:00:00.0000000", "timeZone": "Eastern Standard Time" }
# without the header: { "dateTime": "2026-11-02T14:00:00.0000000", "timeZone": "UTC" }
```

To map a Windows name to an IANA one, use the Unicode CLDR [windowsZones.xml](https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml). One Windows name covers several IANA zones, and the entry for the world (territory `001`) picks one.

## Google sends an offset and a zone.

Google Calendar’s `start` and `end` hold either a `dateTime` or a `date`. A `dateTime` is RFC 3339 and needs an offset unless `timeZone` is given. The `timeZone` is an IANA name. For a single event it is optional and marks a custom zone. For a recurring event it is required, and it is the zone in which the recurrence is expanded ([Events resource](https://developers.google.com/workspace/calendar/api/v3/reference/events)).

By default, `events.list` returns a recurring series once, as its parent event; `singleEvents=true` expands it into instances. Each instance carries `originalStartTime`, the slot it has in the series even after it is moved ([Recurring events](https://developers.google.com/workspace/calendar/api/guides/recurringevents)).

## All-day events are dates, not instants.

Google marks an all-day event with `start.date` and `end.date`, in the form `yyyy-mm-dd`, and the end is exclusive ([Events resource](https://developers.google.com/workspace/calendar/api/v3/reference/events)). Graph uses `isAllDay`, and then start and end must be midnight in the same time zone ([event resource](https://learn.microsoft.com/en-us/graph/api/resources/event)).

A birthday on March 8 is March 8 for everyone. Converted to an instant in your server’s zone and back in the viewer’s, it can land on March 7 or 9. Keep the date as a date, and where you need an instant, use midnight in the calendar’s zone.

**Floating** time is the third kind. In iCalendar, a date-time with no `Z` and no `TZID` is floating: “the same hour, minute, and second value regardless of which time zone is currently being observed” ([RFC 5545 section 3.3.5](https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5)). For time-range queries, a CalDAV server reads floating times and dates in the collection’s `calendar-timezone` ([RFC 4791 section 7.3](https://www.rfc-editor.org/rfc/rfc4791#section-7.3)).

## A weekly 9:00 keeps its wall time, not its UTC time.

RFC 5545 says to give a recurring `DTSTART` a time zone, so that “all the recurrence instances start at the same local time regardless of time zone changes” ([section 3.8.5.3](https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3)). The rule runs on wall-clock time in its zone. Each occurrence is then converted to an instant with the offset in force that day.

weekly-0900-new-york.txt

```text
Monday  local (America/New_York)  UTC      Berlin
Oct 19  09:00 EDT (-04:00)        13:00Z   15:00 CEST
Oct 26  09:00 EDT (-04:00)        13:00Z   14:00 CET
Nov 02  09:00 EST (-05:00)        14:00Z   15:00 CET
```

The UTC column moves and the local column does not, which is the point of the rule’s zone. The Berlin column is what a colleague in another zone sees: in the week between the two changes, the meeting is an hour earlier for them. That is correct, not a bug. With `DTEND`, every occurrence gets the same exact duration, so an event that spans a clock change keeps its elapsed length.

Graph’s `recurrence.range.recurrenceTimeZone` is the zone of the rule’s start and end dates, and defaults to the event’s own zone ([recurrenceRange](https://learn.microsoft.com/en-us/graph/api/resources/recurrencerange)). Expand a series in that zone, not in UTC.

## Times that do not exist, and times that happen twice.

Two wall times need a rule. When clocks go forward, a span of local time never happens: 2:00 to 3:00 in New York on [March 8, 2026](https://www.timeanddate.com/time/change/usa?year=2026). When they go back, a span happens twice: 1:00 to 2:00 on November 1. RFC 5545 gives both answers ([section 3.3.5](https://www.rfc-editor.org/rfc/rfc5545#section-3.3.5)):

- **Repeated time:** the value refers to the first occurrence. `TZID=America/New_York:20071104T013000` is 1:30 EDT, UTC-04:00.
- **Nonexistent time:** the value is read with the UTC offset before the gap. `TZID=America/New_York:20070311T023000` is 3:30 EDT, one hour after 1:30 EST.

That is a rule about iCalendar, not a promise from the Google or Graph APIs. If your product lets people type a time, check it against the zone before you send it, and decide yourself what 02:30 on the spring-forward day means.

## How Workwae handles it.

Workwae keeps one shape for events from every provider. Each end has an instant (`dateTime`), the IANA zone the event was scheduled in (`timeZone`), and, for all-day events, the `date` (an end date is exclusive). All-day instants are midnight of that date in the calendar’s zone, and an all-day end has no zone.

- **Microsoft 365.** The sync reads events without `Prefer: outlook.timezone`, so start and end arrive in UTC, and takes the event’s own zone from `originalStartTimeZone` and `originalEndTimeZone`. Windows names become IANA names through a table generated from the CLDR file above; writes send the Windows name, and pass an IANA name through when the table has none. The calendar’s zone is the mailbox zone from `mailboxSettings`.
- **Google.** A `dateTime` with an offset is used as is; one without is read in the zone it names, else the calendar’s. A series’ zone is its `start.timeZone`, else the calendar’s.
- **CalDAV.** A `TZID` naming an IANA or Windows zone, or an IANA name with a vendor prefix, is read like the other providers’ zones. A custom `VTIMEZONE` is converted with its own rules. Floating times and dates are read in the calendar’s zone: its `calendar-timezone`, or UTC when the server names none. Writes carry a `TZID` and a generated `VTIMEZONE`.
- **Series.** Workwae stores the rule and expands occurrences itself, in the series’ zone (the calendar’s zone for all-day). Each keeps the series’ wall time, so the 9:00 above is 13:00Z, then 14:00Z, and lasts as long as the first event.
- **Gaps and repeats.** One function turns a wall time into an instant. The first of two matches wins, and a wall time in the gap uses the offset before it, so 02:30 becomes 03:30, as RFC 5545 specifies.

A weekly series in New York, read back across the change, with the SDKs:

weekly.tsweekly.py

```ts
import { createClient, paginate } from "@workwae/sdk";

const workwae = createClient({ apiKey: process.env.WORKWAE_API_KEY });

await workwae.calendarEvents.create({
  accountId,
  calendarId,
  subject: "Weekly sync",
  // Wall time in a zone, no offset. The rule is evaluated in this zone.
  start: { dateTime: "2026-10-19T09:00:00", timeZone: "America/New_York" },
  end: { dateTime: "2026-10-19T09:30:00", timeZone: "America/New_York" },
  recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"],
});

for await (const event of paginate(workwae.calendarEvents.list, {
  accountId,
  calendarId,
  start: new Date("2026-10-19T00:00:00Z"),
  end: new Date("2026-11-10T00:00:00Z"),
})) {
  // event.start.dateTime is the instant: 13:00Z on Oct 19 and Oct 26, 14:00Z on Nov 2 and Nov 9.
  // event.start.timeZone stays "America/New_York".
  console.log(event.start.dateTime, event.start.timeZone, event.kind);
}
```

```python
import os
from datetime import datetime, timezone

from workwae import Workwae, paginate

workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])

workwae.calendar_events.create(
    account_id=account_id,
    calendar_id=calendar_id,
    subject="Weekly sync",
    # Wall time in a zone, no offset. The rule is evaluated in this zone.
    start={"date_time": datetime(2026, 10, 19, 9, 0), "time_zone": "America/New_York"},
    end={"date_time": datetime(2026, 10, 19, 9, 30), "time_zone": "America/New_York"},
    recurrence=["RRULE:FREQ=WEEKLY;BYDAY=MO"],
)

for event in paginate(
    workwae.calendar_events.list,
    account_id=account_id,
    calendar_id=calendar_id,
    start=datetime(2026, 10, 19, tzinfo=timezone.utc),
    end=datetime(2026, 11, 10, tzinfo=timezone.utc),
):
    # event.start.date_time is the instant: 13:00Z on Oct 19 and Oct 26, 14:00Z on Nov 2 and Nov 9.
    # event.start.time_zone stays "America/New_York".
    print(event.start.date_time, event.start.time_zone, event.kind)
```

## Limits.

- A time in the spring-forward gap is not rejected. Workwae passes the wall time and zone to the provider as given. Its own checks, such as end after start, read it with the offset before the gap.
- On Microsoft 365 the zone you read back can differ from the one you wrote. Graph reports a Windows name, and Workwae maps it to the one IANA zone CLDR picks for the world, so an event written as `America/Detroit` reads back as `America/New_York`. The rules are the same today. The name is not preserved.
- A legacy custom Outlook zone has no IANA name. The instants stay correct, since the sync reads them in UTC, but the event’s `timeZone``time_zone` is null, and a series with such a zone is expanded in the calendar’s zone.
- Without `MailboxSettings.Read` the mailbox zone is unknown, so all-day events are anchored in the zone each was created in, and in UTC when that is unknown too.
- A floating CalDAV event becomes an event in the calendar’s zone. A custom `VTIMEZONE` gives the right instant, but the recorded zone is `UTC`.
- A calendar’s time zone can be set on Google only. Microsoft 365 uses the mailbox zone for every calendar, and CalDAV keeps it in a `VTIMEZONE` Workwae does not write; both answer `provider_unsupported`.
- The SDKs are alpha: `@workwae/sdk` is 0.1.0-alpha and `workwae` is 0.1.0a2, so their surface can still change.

The [calendar guide](https://docs.workwae.com/guides/calendar#create) covers creating events with a zone and a recurrence, and its [listing section](https://docs.workwae.com/guides/calendar#list-events) explains `start` and `end`. [Calendar](https://workwae.com/features/calendar) covers sync.

## More from the blog.

### [Sending email from your users’ mailboxes: limits, bounces, tracking and merges.](https://workwae.com/blog/sending-email-from-your-users-mailboxes)

November 4, 2026 · 15 min read

Daily limits, authentication, the 5,000-a-day rules, bounces, suppression, validation, tracking, merges and retries.

Tags: Sending, Gmail, Microsoft Graph

### [Exchange Web Services is being retired: moving to Microsoft Graph.](https://workwae.com/blog/ews-retirement-microsoft-graph)

October 30, 2026 · 9 min read

The EWS timeline, the 403 you will see, EWSEnabled and the allow list, the EWS to Graph mapping, permissions, notifications and the remaining gaps.

Tags: Microsoft Graph, Migration

### [Sending mail as your users without breaking their mailbox.](https://workwae.com/blog/sending-mail-as-your-users)

October 19, 2026 · 6 min read

Sent copies, Message-IDs, In-Reply-To, References, Gmail threadId rules and drafts across SMTP, Gmail and Graph.

Tags: Sending, Gmail, Microsoft Graph
