Why Your Calendar Events Show the Wrong Time: ICS Timezones Explained

The three ways an ICS file can express a start time, what the VTIMEZONE block does and why it is so often missing, how to read a whole-hour shift as a diagnosis, what daylight saving does to recurring events, and how Outlook and Google each guess differently when the timezone is absent.

When an imported event lands two hours off, the file is almost never storing the wrong number. It is storing a time without saying which timezone that time belongs to, and the calendar app has guessed. iCalendar lets DTSTART be written three different ways, only one of which is unambiguous on its own, and the gap between what the exporter meant and what the importer assumed is where every "off by N hours" bug comes from.

The three forms of DTSTART

RFC 5545 defines exactly three ways to write a date-time value, and you can tell them apart at a glance.

Form 1: floating local time

DTSTART:20260315T090000 — no Z, no TZID parameter. This is "nine in the morning, wherever the reader happens to be". The spec calls it a date-time with local time, and it deliberately carries no offset. It is right for a wake-up alarm that should fire at 09:00 in every office and wrong for almost everything else. It is also the most common cause of shifted events, because an exporter that forgot to write the timezone produces output identical to one that meant it.

Form 2: UTC

DTSTART:20260315T080000Z — the trailing Z marks it as UTC. This is unambiguous. Every calendar app converts it to the viewer's local timezone and shows the correct wall time. If your file uses Z consistently and events still look wrong, the problem is on the display side (the calendar's own timezone setting), not in the file.

Form 3: local time with a timezone reference

DTSTART;TZID=Europe/Madrid:20260315T090000 — a wall-clock time plus the timezone it belongs to. This is the form you want for recurring events: a 09:00 standup stays at 09:00 in March and in November, even though the UTC offset changes between them.

A fourth case is not a date-time at all: DTSTART;VALUE=DATE:20260315 for all-day events, with no time and no timezone by design. All-day events that drift to the previous or next day come from an exporter that wrote them as midnight-to-midnight date-times instead of using VALUE=DATE.

What VTIMEZONE is for, and why it goes missing

A TZID such as Europe/Madrid is only a label. The file is supposed to define what that label means, in a VTIMEZONE component inside the same VCALENDAR. The block declares the identifier and then one or more STANDARD and DAYLIGHT subcomponents, each carrying a DTSTART marking when that rule takes effect, a TZOFFSETFROM and TZOFFSETTO giving the offsets either side of the change, usually an RRULE for when the switch repeats each year, and optionally a TZNAME like CET or CEST.

RFC 5545 requires that any TZID referenced by an event be defined by a VTIMEZONE in the same iCalendar object. In practice that requirement is broken constantly:

  • The exporter assumes the reader has a copy of the IANA timezone database and can resolve the name itself. Most modern clients can, so the file works and nobody notices the omission.
  • The file was generated by a script. Python's zoneinfo, JavaScript's Intl, and most date libraries will happily produce a TZID string; writing a full VTIMEZONE block with its offsets and RRULEs is a separate job that scripts usually skip.
  • The file was edited by hand and the VTIMEZONE block, which sits above the events and looks like boilerplate, got trimmed. Or a conversion step rewrote the events without carrying the definitions across.

The omission stops being harmless the moment the TZID is not a name the client recognizes. Windows-style identifiers like "Romance Standard Time" appear in files exported by Microsoft products, and a client with only the IANA database and no VTIMEZONE to fall back on cannot resolve them at all.

Reading the size of the shift as a diagnosis

How many hours the event moved narrows the cause faster than reading the file does.

  • Exactly one hour, and only for part of the year: a daylight saving mismatch. Either the VTIMEZONE rules are stale, or the file hardcoded a UTC time computed with the wrong seasonal offset.
  • A whole number of hours equal to your own UTC offset: a floating time was read as UTC, or a UTC time was read as floating. This is the classic Form 1 versus Form 2 confusion.
  • A whole number of hours equal to the difference between two cities: the TZID was ignored and the client substituted its own default timezone.
  • Every event moved by the same amount: a file-level problem, most likely a missing or unresolvable TZID.
  • Only some events moved: the file mixes forms, which happens when events came from more than one source before being merged.
  • Not a whole number of hours: check for a 30 or 45 minute offset zone (India, Nepal, parts of Australia) before assuming corruption.

The daylight saving trap in recurring events

A weekly meeting written as DTSTART;TZID=Europe/Madrid:20260115T090000 with RRULE:FREQ=WEEKLY stays at 09:00 local all year. The same meeting written as DTSTART:20260115T080000Z is at 09:00 in January and 10:00 from the last Sunday of March, because UTC does not observe daylight saving and the local offset changed underneath it.

This is why converting everything to UTC is a fine repair for one-off events and a bad one for series. A recurring event that must keep its wall-clock time needs Form 3 with a proper VTIMEZONE. Note also that when DTSTART carries a TZID, the RRULE's UNTIL value must be written in UTC with a trailing Z; files that get this wrong import as a single occurrence rather than a series.

How Outlook and Google react differently

The two dominant clients guess differently, which is why an event can look right for one colleague and wrong for another.

Outlook treats the VTIMEZONE block as authoritative. If the file defines the zone, Outlook uses those offsets and rules even when they disagree with its own database, which makes a stale VTIMEZONE genuinely dangerous: events land at the offset the file says, not the one that is currently correct. Outlook also works natively in Windows timezone names, so a file with a name from neither list falls back to the mailbox timezone. A floating time is read as the user's local time.

Google Calendar leans on its own copy of the IANA database. A valid IANA TZID resolves correctly whether or not the file includes a VTIMEZONE block, which is why technically invalid files import cleanly into Google and then break in Outlook. An unrecognized TZID is quietly ignored and the event is placed in the calendar's own timezone rather than rejected, so nothing warns you. A floating time is interpreted in the calendar's configured timezone, not the browser's.

There is also X-WR-TIMEZONE, a non-standard property Google writes at the top of exported files to record the calendar's timezone. It is not part of RFC 5545: some clients honour it as a default for floating times, others ignore it entirely. It is a hint, not a specification.

How to fix a file

  • Open the .ics in a text editor and look at one DTSTART line. That tells you which of the three forms the file uses.
  • If there is a TZID, search for BEGIN:VTIMEZONE. If it is absent, add the definition or convert the identifier to an IANA name clients can resolve on their own.
  • Replace Windows timezone names with IANA equivalents: "Romance Standard Time" becomes Europe/Madrid, "Eastern Standard Time" becomes America/New_York.
  • For one-off events with no timezone at all, decide what the times were meant to be and rewrite them as UTC with a trailing Z.
  • For recurring events, keep TZID plus a valid VTIMEZONE, and make sure any UNTIL value ends in Z.
  • Check DTEND as well as DTSTART. A file where one carries a TZID and the other does not produces impossible durations.

If you would rather not edit iCalendar text by hand, iCalConverter.com has a timezone repair tool that does this pass automatically: it maps Windows identifiers to IANA names, generates the matching VTIMEZONE blocks, and can normalize a whole file to a single target zone or to UTC. It runs entirely in the browser, so the file stays on your device.

Bottom line

Look at DTSTART before anything else. No Z and no TZID means the file never said what the times meant, and every client guesses differently. A TZID with no VTIMEZONE works until it reaches a client that cannot resolve the name. And a recurring event stored in UTC always drifts by an hour when the clocks change.