How-to
Getting Started With Python Excel AutomationDeep dive

Create Calendar Events from an Excel Sheet with Python

Turn a schedule kept in Excel — training sessions, deadlines, shifts, release dates — into an .ics file that Outlook, Google Calendar and Apple Calendar import: combine date and time columns, handle all-day rows with exclusive end dates, attach the right time zone, give each event a stable UID so re-imports update instead of duplicating, and split the file per person.

Plenty of schedules live in spreadsheets: a training plan, a project's milestone list, an on-call rota, a list of contract renewal dates. People then retype the rows into their calendars, one by one, and miss the change made to row 14 last week. Calendar programs all import the same standard format, iCalendar (.ics), so a script can turn the sheet into a file that Outlook, Google Calendar and Apple Calendar load in one go — and, done properly, re-import later to apply changes rather than adding duplicates. This page shows the conversion with pandas and the icalendar package, including the details that trip up hand-written .ics files: all-day end dates, time zones and stable identifiers. It belongs to Handling Excel File Formats and Conversions.

Prerequisites

Bash
pip install pandas openpyxl icalendar

The examples use icalendar 7.3. schedule.xlsx has one row per event with an ID, title, date, start and end times, location, owner and notes. Two rows have no times — they are all-day events — and the last row is an empty leftover that must be skipped.

Step 1 — Read the sheet and build start and end timestamps

Excel stores dates and times separately when they are typed into separate columns, and openpyxl returns them as datetime and time objects. Combine them into one start and one end per row:

Python
import datetime as dt
import pandas as pd

df = pd.read_excel("schedule.xlsx", sheet_name="Schedule")
df = df.dropna(subset=["Event", "Date"]).fillna({"Location": "", "Notes": ""})
df["Date"] = pd.to_datetime(df["Date"]).dt.date
df["AllDay"] = df["Start"].isna()

def combine(day, t):
    return None if pd.isna(t) else dt.datetime.combine(day, t)

df["StartAt"] = [combine(d, t) for d, t in zip(df["Date"], df["Start"])]
df["EndAt"] = [combine(d, t) for d, t in zip(df["Date"], df["End"])]
print(df[["ID", "Event", "AllDay", "StartAt", "EndAt"]].to_string(index=False))

Dropping rows without an event name or date removes the leftover row. The time columns come back as Python time objects because the cells hold time values; if your sheet stores times as text such as "09:30", parse them first with pd.to_datetime(df["Start"], format="%H:%M").dt.time. Rows without a start time are flagged as all-day.

Sheet rows become timed and all-day events On the left the Excel schedule with date, start and end columns, two rows without times. On the right the calendar entries — two timed training sessions on Mondays from 09:30 to 12:00 and a month-end close shown as an all-day event. A B C D 1 Event Date Start End 2 Training 1 12 Oct 09:30 12:00 3 Training 2 19 Oct 09:30 12:00 4 Month-end close 30 Oct Calendar entry Mon 12 Oct 09:30–12:00 Mon 19 Oct 09:30–12:00 Fri 30 Oct (all day) schedule.xlsx calendar icalendar

Step 2 — Attach the time zone

A timestamp without a zone is ambiguous: "09:30" means one thing in London and another for a colleague in New York who imports the same file. Attach the zone the schedule was written in, and let each calendar app convert it for its user:

Python
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Europe/London")
df["StartAt"] = [s.replace(tzinfo=TZ) if pd.notna(s) else None for s in df["StartAt"]]
df["EndAt"] = [e.replace(tzinfo=TZ) if pd.notna(e) else None for e in df["EndAt"]]
print(df.loc[df["ID"] == "MS-02", "StartAt"].iloc[0].isoformat())

The budget workshop on 3 November starts at 14:00+00:00 (GMT), while the October sessions are at +01:00 (BST): the zone database knows that the clocks change on 25 October 2026, so a single zone name gives the right offset on both sides of the switch. Hard-coding an offset such as +01:00 instead would put every November event an hour out.

Step 3 — Write the events

Python
import hashlib
from icalendar import Calendar, Event

def stable_uid(row_id):
    return hashlib.sha1(f"schedule.xlsx/{row_id}".encode()).hexdigest()[:16] + "@reports.example.com"

cal = Calendar()
cal.add("prodid", "-//Example Reports//Schedule export//EN")
cal.add("version", "2.0")
stamp = dt.datetime.now(dt.timezone.utc)
for row in df.itertuples(index=False):
    ev = Event()
    ev.add("uid", stable_uid(row.ID))
    ev.add("dtstamp", stamp)
    ev.add("summary", row.Event)
    if row.AllDay:
        days = 2 if row.ID == "HD-01" else 1
        ev.add("dtstart", row.Date)
        ev.add("dtend", row.Date + dt.timedelta(days=days))
    else:
        ev.add("dtstart", row.StartAt)
        ev.add("dtend", row.EndAt)
    if row.Location:
        ev.add("location", row.Location)
    if row.Notes:
        ev.add("description", row.Notes)
    cal.add_component(ev)
cal.add_missing_timezones()
with open("schedule.ics", "wb") as fh:
    fh.write(cal.to_ical())
print(cal.to_ical().decode().count("BEGIN:VEVENT"), "events written")

Three details in this loop decide whether the file imports cleanly:

  • All-day end dates are exclusive. An all-day event on 30 October has DTSTART;VALUE=DATE:20261030 and DTEND;VALUE=DATE:20261031. Setting the end to the same day makes some apps show nothing at all. The two-day office closure ends on the 26th to cover the 24th and 25th; in a real sheet, take the number of days from an end-date column.
  • Stable UIDs. The UID is derived from the row's ID, not generated randomly, so exporting again after an edit produces the same identifier. Calendar apps treat an event with a known UID as an update to the existing entry rather than a new one.
  • Time zone definitions. add_missing_timezones() adds a VTIMEZONE block for Europe/London, which the standard requires whenever an event uses TZID. Without it Google Calendar usually guesses correctly and stricter clients may not.
One exported event in the .ics file The text of the budget workshop event. The summary, start and end with the Europe/London time zone, a stable UID derived from the row ID, and a description whose comma is escaped with a backslash. 1 BEGIN:VEVENT 2 SUMMARY:Budget workshop 3 1 DTSTART;TZID=Europe/London:20261103T140000 4 DTEND;TZID=Europe/London:20261103T163000 5 2 UID:4f1c…@reports.example.com 6 LOCATION:Teams 7 3 DESCRIPTION:Agenda: Q1 targets\, headcount 8 END:VEVENT 1 zone name, not a fixed offset 2 same UID on every export = updates 3 commas and semicolons are escaped

Step 4 — Read the file back to check it

Parsing the output with the same library catches mistakes before anyone imports it:

Python
from pathlib import Path

check = Calendar.from_ical(Path("schedule.ics").read_bytes())
for ev in check.walk("VEVENT"):
    start = ev.decoded("dtstart")
    kind = "all day" if not isinstance(start, dt.datetime) else start.strftime("%H:%M %Z")
    print(f"{str(ev['summary']):35} {start:%d %b} {kind:10} {str(ev.get('description', ''))[:30]}")

Every event comes back with its original text — Bring a laptop; Python 3.12+ included, even though the file stores it as Bring a laptop\; Python 3.12+. The library escapes commas, semicolons and backslashes and folds long lines at 75 octets, the two rules hand-written .ics files most often break. Writing the file with f-strings is tempting; it works until the first note with a comma in it.

Step 5 — One file per person

Sending each owner only their own events is a group-by away:

Python
out = Path("calendars")
out.mkdir(exist_ok=True)
for owner, group in df.groupby("Owner"):
    personal = Calendar()
    personal.add("prodid", "-//Example Reports//Schedule export//EN")
    personal.add("version", "2.0")
    for ev in check.walk("VEVENT"):
        if ev["uid"] in {stable_uid(i) for i in group["ID"]}:
            personal.add_component(ev)
    personal.add_missing_timezones()
    (out / f"{owner.lower()}.ics").write_bytes(personal.to_ical())
print(sorted(p.name for p in out.iterdir()))

Each file can be attached to an email — see Send Each Recipient Their Own Excel Report for the mailing loop. Use the MIME type text/calendar for the attachment so mail clients offer to add the events.

One schedule, one calendar file per owner schedule.xlsx feeds schedule.ics with every event, plus adaeze.ics with the two training sessions and the office closure, and jonas.ics with the month-end close and the budget workshop. schedule.xlsx 5 events schedule.ics everything adaeze.ics 2 trainings, office closed jonas.ics month-end close, workshop

Step 6 — Add a reminder

People import calendar files so that their calendar reminds them. A VALARM component inside each event asks the app to show a notification a set time before the start:

Python
from icalendar import Alarm

for ev in check.walk("VEVENT"):
    if isinstance(ev.decoded("dtstart"), dt.datetime):
        alarm = Alarm()
        alarm.add("action", "DISPLAY")
        alarm.add("description", f"Reminder: {ev['summary']}")
        alarm.add("trigger", dt.timedelta(minutes=-30))
        ev.add_component(alarm)
Path("schedule_reminders.ics").write_bytes(check.to_ical())
print(check.to_ical().decode().count("BEGIN:VALARM"), "reminders added")

The negative trigger means "30 minutes before the start". All-day events are skipped here because a reminder relative to midnight is rarely useful; give them a trigger such as -P1D (one day before) instead if your audience wants one. Calendar apps are free to ignore alarms in imported files, and some apply the user's own default notifications instead, so treat the reminder as a suggestion rather than a guarantee.

Common pitfalls

  • Same-day end for all-day events. DTEND is exclusive; add a day.
  • Random UIDs. A new UID on each export creates duplicates on re-import; derive it from a stable row key.
  • Fixed UTC offsets. Use a zone name so daylight-saving changes are handled.
  • Hand-written files. Unescaped commas and unfolded long lines break strict parsers; let icalendar write the text.
  • Subscriptions versus imports. An imported .ics is a one-off copy. For a calendar that refreshes itself, publish the file at a stable URL and subscribe to it — see Publishing Excel Reports to Cloud Storage.

Performance notes

Building events is cheap: a few thousand rows convert in well under a second, and the time zone definitions are added once per zone, not per event. The practical limits are on the receiving side. Importing a file with thousands of events into a personal calendar is slow and hard to undo, so for large schedules publish per-person or per-team files, and keep past events out of the export unless someone needs the history.

Conclusion

Read the schedule with pandas, combine date and time columns into timestamps, attach a named time zone, and write each row as an iCalendar event with a UID derived from the row's ID. Give all-day events an exclusive end date, let add_missing_timezones() add the zone definitions, parse the file back as a check, and split it per owner when each person should only see their own entries.

Frequently asked questions

How do I convert Excel rows into calendar events? Read the sheet with pandas, build start and end datetimes for each row, and write them as VEVENT components with the icalendar package. Save the result as an .ics file, which Outlook, Google Calendar and Apple Calendar can import.

How do I create all-day events in an .ics file? Use date values instead of datetimes for DTSTART and DTEND, and set DTEND to the day after the last day, because the end date is exclusive.

Why does re-importing my .ics file create duplicate events? Each export generated new UIDs. Give every event a UID derived from a stable key, such as the row's ID, so calendar apps recognise the event and update it.

How do I set the time zone for events exported from Excel? Attach a named zone with zoneinfo, for example ZoneInfo("Europe/London"), to each start and end time, and call add_missing_timezones() on the calendar so the file includes the matching VTIMEZONE definition.

More on file formats & conversion

More on file formats & conversion →