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
- Setting Up EKEventStore
- Authorization: The iOS 17 Unified Model
- Creating and Editing Events
- Recurring Events with EKRecurrenceRule
- Working with Reminders
- SwiftUI Integration
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
EKEventStorefor 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
NSCalendarsFullAccessUsageDescriptionandNSCalendarsWriteOnlyAccessUsageDescriptionif 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:
- System checks
Info.plistfor the appropriate usage description key. - If the key is missing, the app crashes with a descriptive console message.
- If the user has not been prompted, the system shows an alert.
- The async call returns
true(granted) orfalse(denied). - The user can change this later in Settings. Listen for
EKEventStoreChangednotifications 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:
| Operation | Cost | Notes |
|---|---|---|
EKEventStore() init | ~50 ms | Opens a database connection. Do this once. |
events(matching:) | ~5-20 ms | Depends on date range and number of calendars |
save(_:span:) | ~10 ms | Triggers a database write and iCloud sync |
fetchReminders(matching:) | ~20-100 ms | Always 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
EKEventStoreChangednotification 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)
| Scenario | Recommendation |
|---|---|
| Adding user-facing events | EventKit — events appear in Calendar and sync across devices |
| Internal app scheduling | Use Background Tasks instead |
| Alarm with sound/vibration | Use AlarmKit on iOS 26 |
| Full calendar UI | Use a third-party view, write events to EventKit for sync |
| Reading calendar for analytics | Request 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
EKEventStoreis 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()orrequestWriteOnlyAccessToEvents()APIs with an#availablecheck. EKEventcreation requires a title, dates, and a target calendar. Store theeventIdentifierto edit or delete events later.EKRecurrenceRulehandles daily, weekly, monthly, and yearly patterns. Use thespanparameter carefully when modifying recurring events.EKReminderprovides task-list integration with due dates, priorities, and completion tracking. Fetching is always asynchronous.- Listen for
EKEventStoreChangedto 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.