EventKit: Calendar and Reminders Integration in iOS


Your users already live inside Calendar and Reminders. Rather than building a bespoke scheduling system that they will ignore, the smartest move is to write events and reminders directly into the stores they actually check. EventKit is the framework that bridges your app to both — and with the iOS 17 authorization overhaul, requesting access is finally straightforward.

This post covers EKEventStore lifecycle management, the old and new authorization models, creating and editing events and reminders, recurring rules, and a production-ready SwiftUI integration. We will not cover EventKitUI presentation controllers — those are UIKit wrappers that SwiftUI does not need.

Contents

The Problem

Imagine you are building a Pixar Movie Marathon Planner — an app that schedules movie nights, sets reminders to buy snacks before showtime, and creates recurring weekly events for a Toy Story rewatch series. Without EventKit, you would need your own persistence layer, your own notification scheduling, and your own sync engine. With EventKit, you write a few structs and the system Calendar and Reminders apps handle display, syncing, alerts, and Siri integration for free.

The challenge is that EventKit’s authorization model changed significantly in iOS 17. Before that, you requested blanket access to calendars or reminders. Now, the system distinguishes between full access and write-only access, and you must declare your intent in Info.plist. If you get this wrong, your app will silently fail to read events or, worse, crash on launch due to a missing usage description.

// The old way -- still compiles but behaves differently on iOS 17+
let store = EKEventStore()
store.requestAccess(to: .event) { granted, error in
    // On iOS 17+, this grants write-only access, not full access
}

The code above looks correct, but on iOS 17+ it only grants write-only access. If your app reads existing events (fetching schedules, checking conflicts), you will get empty results and no error. Let’s fix this properly.

Setting Up EKEventStore

EKEventStore is the gateway to calendars and reminders. It is expensive to initialize because it syncs with the system database, so create one instance and keep it alive for the duration of your app session.

import EventKit

@Observable
final class CalendarManager {
    let store = EKEventStore()

    var authorizationStatus: EKAuthorizationStatus {
        EKEventStore.authorizationStatus(for: .event)
    }

    var reminderAuthorizationStatus: EKAuthorizationStatus {
        EKEventStore.authorizationStatus(for: .reminder)
    }
}

Warning: Do not create a new EKEventStore for every operation. Each instance opens a connection to the Calendar database. Repeated allocations can cause memory pressure and stale-data bugs when different instances have different snapshots of the store.

Info.plist Keys

Before requesting any access, you must declare usage descriptions. Without these, the system will terminate your app:

<!-- For calendar access -->
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Movie Marathon Planner reads your calendar to check for scheduling conflicts.</string>

<!-- For write-only calendar access (iOS 17+) -->
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>Movie Marathon Planner adds movie night events to your calendar.</string>

<!-- For reminders access -->
<key>NSRemindersFullAccessUsageDescription</key>
<string>Movie Marathon Planner creates snack-run reminders before each movie night.</string>

Tip: Include both NSCalendarsFullAccessUsageDescription and NSCalendarsWriteOnlyAccessUsageDescription if your app has a degraded mode that works with write-only access. The system will use the appropriate string based on which API you call.

Authorization: The iOS 17 Unified Model

iOS 17 introduced requestFullAccessToEvents() and requestWriteOnlyAccessToEvents(), replacing the older requestAccess(to:) for calendars. These are async methods that play nicely with structured concurrency.

extension CalendarManager {
    /// Requests full read/write access to calendar events.
    /// Call this if your app needs to read existing events (conflict checking, display).
    func requestFullCalendarAccess() async throws -> Bool {
        if #available(iOS 17.0, *) {
            return try await store.requestFullAccessToEvents()
        } else {
            return try await store.requestAccess(to: .event)
        }
    }

    /// Requests write-only access to calendar events.
    /// Call this if your app only creates events and never reads the user's calendar.
    func requestWriteOnlyCalendarAccess() async throws -> Bool {
        if #available(iOS 17.0, *) {
            return try await store.requestWriteOnlyAccessToEvents()
        } else {
            return try await store.requestAccess(to: .event)
        }
    }

    /// Requests full access to reminders. There is no write-only option for reminders.
    func requestReminderAccess() async throws -> Bool {
        if #available(iOS 17.0, *) {
            return try await store.requestFullAccessToReminders()
        } else {
            return try await store.requestAccess(to: .reminder)
        }
    }
}

The authorization flow on iOS 17+ is:

  1. System checks Info.plist for the appropriate usage description key.
  2. If the key is missing, the app crashes with a descriptive console message.
  3. If the user has not been prompted, the system shows an alert.
  4. The async call returns true (granted) or false (denied).
  5. The user can change this later in Settings. Listen for EKEventStoreChanged notifications to react.
extension CalendarManager {
    func observeStoreChanges() {
        NotificationCenter.default.addObserver(
            forName: .EKEventStoreChanged,
            object: store,
            queue: .main
        ) { [weak self] _ in
            self?.refreshEvents()
        }
    }

    private func refreshEvents() {
        // Re-fetch events after external changes
        // (user edited in Calendar app, iCloud sync, etc.)
    }
}

Creating and Editing Events

With authorization in hand, creating an event is straightforward. Every EKEvent needs a title, start date, end date, and a calendar to belong to.

extension CalendarManager {
    /// Schedules a Pixar movie night on the user's default calendar.
    func scheduleMovieNight(
        title: String,
        startDate: Date,
        duration: TimeInterval = 7200 // 2 hours
    ) throws -> String {
        let event = EKEvent(eventStore: store)
        event.title = title
        event.startDate = startDate
        event.endDate = startDate.addingTimeInterval(duration)
        event.calendar = store.defaultCalendarForNewEvents
        event.notes = "Grab popcorn and settle in for a Pixar classic!"

        // Add a 30-minute reminder alarm
        let alarm = EKAlarm(relativeOffset: -1800) // 30 minutes before
        event.addAlarm(alarm)

        try store.save(event, span: .thisEvent)
        return event.eventIdentifier
    }
}

// Usage
let manager = CalendarManager()
let movieDate = Calendar.current.date(
    from: DateComponents(
        year: 2026, month: 4, day: 15, hour: 19, minute: 0
    )
)!
let eventID = try manager.scheduleMovieNight(
    title: "Finding Nemo Movie Night",
    startDate: movieDate
)

The eventIdentifier is a persistent string you should store if you need to edit or delete the event later:

extension CalendarManager {
    /// Updates an existing movie night event.
    func rescheduleMovieNight(
        eventIdentifier: String,
        newStartDate: Date,
        duration: TimeInterval = 7200
    ) throws {
        guard let event = store.event(withIdentifier: eventIdentifier) else {
            throw CalendarError.eventNotFound
        }
        event.startDate = newStartDate
        event.endDate = newStartDate.addingTimeInterval(duration)
        try store.save(event, span: .thisEvent)
    }

    /// Deletes an event from the calendar.
    func cancelMovieNight(eventIdentifier: String) throws {
        guard let event = store.event(withIdentifier: eventIdentifier) else {
            throw CalendarError.eventNotFound
        }
        try store.remove(event, span: .thisEvent)
    }
}

enum CalendarError: Error {
    case eventNotFound
    case calendarNotAvailable
}

Fetching Events with Predicates

To check for scheduling conflicts or display upcoming movie nights, use predicateForEvents(withStart:end:calendars:):

extension CalendarManager {
    /// Fetches all events in a date range, optionally filtered to specific calendars.
    func events(
        from startDate: Date,
        to endDate: Date,
        in calendars: [EKCalendar]? = nil
    ) -> [EKEvent] {
        let predicate = store.predicateForEvents(
            withStart: startDate,
            end: endDate,
            calendars: calendars
        )
        return store.events(matching: predicate)
            .sorted { $0.startDate < $1.startDate }
    }

    /// Checks whether a proposed time slot conflicts with existing events.
    func hasConflict(at date: Date, duration: TimeInterval = 7200) -> Bool {
        let endDate = date.addingTimeInterval(duration)
        return !events(from: date, to: endDate).isEmpty
    }
}

Warning: events(matching:) requires full access on iOS 17+. With write-only access, this returns an empty array silently. Always verify your authorization level before relying on fetch results.

Recurring Events with EKRecurrenceRule

A weekly Toy Story Tuesday rewatch needs a recurrence rule. EKRecurrenceRule supports daily, weekly, monthly, and yearly patterns:

extension CalendarManager {
    /// Creates a recurring weekly movie night every Tuesday for 10 weeks.
    func createWeeklyMovieSeries(
        seriesTitle: String,
        firstDate: Date,
        weeksCount: Int = 10
    ) throws -> String {
        let event = EKEvent(eventStore: store)
        event.title = seriesTitle
        event.startDate = firstDate
        event.endDate = firstDate.addingTimeInterval(7200)
        event.calendar = store.defaultCalendarForNewEvents

        let recurrenceEnd = EKRecurrenceEnd(occurrenceCount: weeksCount)
        let rule = EKRecurrenceRule(
            recurrenceWith: .weekly,
            interval: 1, // every week
            daysOfTheWeek: [EKRecurrenceDayOfWeek(.tuesday)],
            daysOfTheMonth: nil,
            monthsOfTheYear: nil,
            weeksOfTheYear: nil,
            daysOfTheYear: nil,
            setPositions: nil,
            end: recurrenceEnd
        )
        event.addRecurrenceRule(rule)

        try store.save(event, span: .futureEvents)
        return event.eventIdentifier
    }
}

The span parameter matters for recurring events. Use .thisEvent to modify only a single occurrence, or .futureEvents to modify the selected occurrence and all future ones. There is no .allEvents — to change past occurrences, you need to delete and recreate.

Working with Reminders

EKReminder shares the same EKEventStore but uses its own authorization and calendar type. Reminders have due dates, priorities, and completion states — perfect for pre-movie-night checklists.

extension CalendarManager {
    /// Creates a reminder to prepare for movie night.
    func createSnackReminder(
        title: String,
        dueDate: Date,
        priority: Int = 1 // 1-9, where 1 is highest
    ) throws -> String {
        let reminder = EKReminder(eventStore: store)
        reminder.title = title
        reminder.priority = priority
        reminder.calendar = store.defaultCalendarForNewReminders()

        // Set the due date using date components
        let dueDateComponents = Calendar.current.dateComponents(
            [.year, .month, .day, .hour, .minute],
            from: dueDate
        )
        reminder.dueDateComponents = dueDateComponents

        // Add an alarm 1 hour before
        let alarm = EKAlarm(relativeOffset: -3600)
        reminder.addAlarm(alarm)

        try store.save(reminder, commit: true)
        return reminder.calendarItemIdentifier
    }

    /// Marks a reminder as completed.
    func completeReminder(identifier: String) throws {
        let predicate = store.predicateForReminders(in: nil)
        store.fetchReminders(matching: predicate) { [weak self] reminders in
            guard let self,
                  let reminder = reminders?.first(where: {
                      $0.calendarItemIdentifier == identifier
                  }) else { return }
            reminder.isCompleted = true
            reminder.completionDate = Date()
            try? self.store.save(reminder, commit: true)
        }
    }
}

Fetching Reminders

Unlike events, reminders are fetched asynchronously through a completion handler. Here is an async wrapper:

extension CalendarManager {
    /// Fetches incomplete reminders from all reminder calendars.
    func fetchIncompleteReminders() async -> [EKReminder] {
        let predicate = store.predicateForIncompleteReminders(
            withDueDateStarting: nil,
            ending: nil,
            calendars: nil
        )
        return await withCheckedContinuation { continuation in
            store.fetchReminders(matching: predicate) { reminders in
                continuation.resume(returning: reminders ?? [])
            }
        }
    }
}

Apple Docs: EKReminder — EventKit

SwiftUI Integration

Putting it all together, here is a SwiftUI view model and view for the Movie Marathon Planner:

import SwiftUI
import EventKit

@Observable
final class MovieMarathonViewModel {
    private let manager = CalendarManager()
    var upcomingMovieNights: [EKEvent] = []
    var authorizationGranted = false
    var errorMessage: String?

    func requestAccess() async {
        do {
            authorizationGranted = try await manager.requestFullCalendarAccess()
            if authorizationGranted {
                fetchUpcomingNights()
            }
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func fetchUpcomingNights() {
        let now = Date()
        let threeMonthsFromNow = Calendar.current.date(
            byAdding: .month, value: 3, to: now
        )!
        upcomingMovieNights = manager.events(from: now, to: threeMonthsFromNow)
            .filter { $0.title.contains("Movie Night") }
    }

    func scheduleNight(title: String, date: Date) {
        do {
            _ = try manager.scheduleMovieNight(title: title, startDate: date)
            fetchUpcomingNights()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}
struct MovieMarathonView: View {
    @State private var viewModel = MovieMarathonViewModel()
    @State private var showingAddSheet = false

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.authorizationGranted {
                    movieNightsList
                } else {
                    ContentUnavailableView(
                        "Calendar Access Required",
                        systemImage: "calendar.badge.exclamationmark",
                        description: Text(
                            "Grant calendar access to schedule movie nights."
                        )
                    )
                }
            }
            .navigationTitle("Pixar Marathon")
            .task { await viewModel.requestAccess() }
            .toolbar {
                Button("Add Night", systemImage: "plus") {
                    showingAddSheet = true
                }
            }
        }
    }

    private var movieNightsList: some View {
        List(
            viewModel.upcomingMovieNights,
            id: \.eventIdentifier
        ) { event in
            VStack(alignment: .leading) {
                Text(event.title)
                    .font(.headline)
                Text(event.startDate, style: .date)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

The @Observable macro eliminates the need for @Published properties, and the task modifier handles the async authorization request when the view appears.

Performance Considerations

EventKit operations are disk-backed and, when iCloud Calendar is enabled, network-synced. Keep these costs in mind:

OperationCostNotes
EKEventStore() init~50 msOpens a database connection. Do this once.
events(matching:)~5-20 msDepends on date range and number of calendars
save(_:span:)~10 msTriggers a database write and iCloud sync
fetchReminders(matching:)~20-100 msAlways async. Can be slow with many reminders.

Avoid calling events(matching:) on the main thread with wide date ranges. For calendar overview screens, fetch in the background and update the UI via @Observable:

func fetchEventsInBackground(
    from start: Date,
    to end: Date
) async -> [EKEvent] {
    await withCheckedContinuation { continuation in
        DispatchQueue.global(qos: .userInitiated).async { [store] in
            let predicate = store.predicateForEvents(
                withStart: start, end: end, calendars: nil
            )
            let results = store.events(matching: predicate)
            continuation.resume(returning: results)
        }
    }
}

Tip: The EKEventStoreChanged notification fires on every external change, including iCloud sync. Debounce your refresh logic to avoid re-fetching dozens of times during an initial sync.

When to Use (and When Not To)

ScenarioRecommendation
Adding user-facing eventsEventKit — events appear in Calendar and sync across devices
Internal app schedulingUse Background Tasks instead
Alarm with sound/vibrationUse AlarmKit on iOS 26
Full calendar UIUse a third-party view, write events to EventKit for sync
Reading calendar for analyticsRequest full access and be transparent in your usage description
One-shot “add to calendar”Use write-only access (iOS 17+) to minimize permission scope

Summary

  • EKEventStore is the single gateway to both Calendar and Reminders databases. Create one instance and reuse it throughout your app session.
  • iOS 17 unified authorization splits calendar access into full and write-only. Always use the new requestFullAccessToEvents() or requestWriteOnlyAccessToEvents() APIs with an #available check.
  • EKEvent creation requires a title, dates, and a target calendar. Store the eventIdentifier to edit or delete events later.
  • EKRecurrenceRule handles daily, weekly, monthly, and yearly patterns. Use the span parameter carefully when modifying recurring events.
  • EKReminder provides task-list integration with due dates, priorities, and completion tracking. Fetching is always asynchronous.
  • Listen for EKEventStoreChanged to keep your UI in sync with external edits and iCloud changes.

For scheduling alarms that wake the user with custom sounds and snooze behavior, check out AlarmKit — the iOS 26 framework purpose-built for alarm experiences. And if your events need to trigger background work, explore Background Tasks for reliable execution.