6.4 — Core Data + CloudKit
Opening scenario
You’ve shipped a Core Data app. Users love it. They ask, every week, “why doesn’t this sync to my iPad?” You read about CloudKit (Chapter 6.3). You weigh writing a custom sync engine — change tokens, conflict resolution, asset uploads, the works — against switching a single line in your persistence stack.
NSPersistentContainer → NSPersistentCloudKitContainer. That’s the line. Apple wrote the sync engine.
It’s not free. There are constraints on the schema, a developer-mode iCloud step, and a few sharp edges around shared databases. But: the cost is a week of careful work, and you get the same sync engine Apple Notes uses.
| Context | What it usually means |
|---|---|
Reads “NSPersistentCloudKitContainer” | Has at least read the docs |
| Reads “optional or default for every attribute” | Has tried to enable CloudKit on an existing schema |
| Reads “no unique constraints with CloudKit” | Has been bitten by it in production |
| Reads “history tracking” | Knows the magic that makes the sync engine work |
Reads “CKShare” | Has built collaborative features |
Concept → Why → How → Code
Concept
NSPersistentCloudKitContainer is a NSPersistentContainer subclass. It mirrors your Core Data store to an iCloud private (and optionally shared) database, using CloudKit’s APIs invisibly. The same viewContext you’ve always used now triggers iCloud syncs on save(). Changes from other devices appear via the normal NSManagedObjectContextDidSave notifications.
Under the hood:
NSPersistentHistoryTrackingrecords every change you make.- A background “mirror” process turns Core Data changes into
CKModifyRecordsOperationcalls. - Subscriptions and silent push pull remote changes back.
- Conflicts are resolved with last-writer-wins by default (configurable via your merge policy).
Why
You want this when:
- Your app already uses Core Data and adding sync would otherwise mean a custom backend.
- You want end-to-end encryption on user data (CloudKit private database respects Advanced Data Protection).
- You want iCloud sharing (collaborative documents, shared lists) without building your own access-control system.
You don’t want this when:
- You need a Web client (CloudKit has no public Web API beyond
CKWebAuthToken-based JSON, which is limited). - You need cross-platform with Android.
- You need server-side aggregation, search, or business logic.
How — the upgrade
Start from your existing Core Data stack. Three changes:
- Container class:
NSPersistentContainer→NSPersistentCloudKitContainer. - Store description: mark the persistent store as CloudKit-backed.
- Schema constraints: every attribute must be optional or have a default value, all relationships must be optional or to-many, and you cannot use unique constraints or
denydelete rules.
import CoreData
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "Journal")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Missing persistent store description")
}
// 1. CloudKit container identifier
description.cloudKitContainerOptions =
NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.Journal")
// 2. History tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
The history tracking + remote change options are mandatory; CloudKit sync will silently not work without them.
Initial schema deploy
In Development environment, Core Data + CloudKit auto-promotes your Core Data schema into CloudKit’s record types and fields. Run the app once with a signed-in iCloud account on the simulator (or device) and CloudKit Dashboard will show the schema appearing.
When you ship to TestFlight or App Store, the build will run against the Production environment. The Production schema is empty until you go to CloudKit Dashboard → Schema → Deploy Schema to Production. Forget this step and your App Store users will see nothing sync.
Listening for remote changes
The container posts .NSPersistentStoreRemoteChange notifications when iCloud pushes arrive. Most of the time automaticallyMergesChangesFromParent = true is enough and SwiftUI / FetchedResultsController re-renders automatically. For custom UI:
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange)
.sink { [weak self] _ in
Task { @MainActor in
self?.refreshUI()
}
}
.store(in: &cancellables)
Shared records (collaboration)
Add a second store description for the shared database:
let sharedDescription = NSPersistentStoreDescription(
url: container.persistentStoreDescriptions.first!.url!
.deletingLastPathComponent()
.appendingPathComponent("Journal-Shared.sqlite")
)
sharedDescription.configuration = "Shared"
sharedDescription.cloudKitContainerOptions = {
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.Journal")
options.databaseScope = .shared
return options
}()
sharedDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
sharedDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions.append(sharedDescription)
Then in your .xcdatamodeld, define two configurations: “Default” for entities living in private, “Shared” for entities living in shared. Use container.share(_:to:) to share an NSManagedObject graph, and accept incoming shares via UIApplicationDelegate.application(_:userDidAcceptCloudKitShareWith:).
In the wild
- Apple Reminders, Apple Notes use Core Data + CloudKit (with private extensions Apple doesn’t expose).
- NetNewsWire uses Core Data + CloudKit for cross-device feed/read state sync.
- Bear (open beta of v2) uses Core Data + CloudKit for the new sync engine, replacing their legacy CloudKit-direct implementation.
- Things 3 does not use Core Data + CloudKit — they have a custom sync server. The reason is historical (they shipped before
NSPersistentCloudKitContainerwas good enough), and switching now would risk their reliability story.
Common misconceptions
- “It just works after I change the class name.” It works for new schemas where every attribute is optional or defaulted. Existing schemas usually need migration to relax constraints (required → optional with default). Test on a copy of a real production store before shipping.
- “CloudKit and Core Data share IDs.” CloudKit assigns its own
CKRecord.IDto each mirrored object. The mapping is internal. Don’t expose CloudKit record IDs as keys in your app. - “Schema changes deploy automatically.” Only in Development. Production schema deployment is a manual click in the dashboard and is one-way (you can’t remove a field or record type from production).
- “I can use it with the public database.” No.
NSPersistentCloudKitContainersupports private and shared databases only. Public requires raw CloudKit. - “Conflicts are handled automatically.” They are resolved automatically (last-writer-wins by default), but the resolution may not match user expectations. For semantic conflicts (two devices renamed the same record), you need a merge policy or app-level UX.
Seasoned engineer’s take
When NSPersistentCloudKitContainer works, it’s the best deal in mobile development: weeks of sync engineering for a one-line change. When it doesn’t work, the failure modes are subtle (silent merge errors, missing records that “should be there”, history-tracking corruption that requires a re-sync), and debugging requires reading Apple’s CoreData/CloudKit logs in Console.app on a tethered device.
The framework has matured enormously since iOS 13. In 2026 it’s the default for new sync-requiring apps on the Apple platform. The biggest practical issue I still hit: the initial sync of an existing store with thousands of records on a new device is slow (minutes, not seconds), and users perceive the app as “missing data.” Add a clear “Syncing from iCloud…” status UI by observing eventChangedNotification and showing progress.
For schemas you can shape from scratch, plan for the constraints up front. For schemas you’re retrofitting, do a migration to v2 that relaxes the constraints, ships and bakes for one release, and then enable CloudKit in v3. Trying to do both at once will turn one of your weekends into all of them.
TIP: Subscribe to
NSPersistentCloudKitContainer.eventChangedNotificationto surface sync state (.setup,.import,.export) and errors to your UI. Users tolerate slow sync if they can see it’s happening; they uninstall if they can’t.
WARNING: Never delete and recreate the SQLite store on launch as a “reset CloudKit” hack. Deleting the local store does not delete the iCloud records, and on next launch the sync re-imports them — but the history-tracking state is lost and you can end up with duplicates. To reset, use the CloudKit Dashboard to delete the user’s zone, then delete the local store, then sign in again.
Interview corner
Junior: “What’s the easiest way to add iCloud sync to a Core Data app?”
Change
NSPersistentContainertoNSPersistentCloudKitContainer, set the CloudKit container identifier on the store description, enable history tracking and remote change notifications. Make sure every attribute is optional or has a default.
Mid: “Why does the schema require all attributes to be optional or defaulted?”
CloudKit records are schemaless on the wire — fields can be missing. When a record arrives from another device that was created against an earlier schema (or by an older app version), the new attribute won’t be present. Core Data must fault that record into your
NSManagedObjectand needs either anilvalue (optional) or a default to fill in. Required-no-default would crash on insert.
Senior: “Walk me through troubleshooting a user report of ‘changes I made on iPhone don’t appear on iPad’.”
First, verify both devices are signed into the same iCloud account and have iCloud Drive on. Next, look at sync state via
eventChangedNotification— is import/export running, is there a recurring.exporterror? Check CloudKit Dashboard for the user’s record zone size and recent operations. Common causes: (1) schema not deployed to production, (2) the iPad still on an older app version with a schema mismatch, (3) silent merge failure because of a constraint added later — fix by versioned schema migration on next release, (4) iCloud account in.restrictedor quota-full state. Have the user pull-to-refresh which callstry await container.fetchSharedAccount()and a manualloadPersistentStoresif needed. If still missing, walk throughos_logfiltered tosubsystem:com.apple.coredata.cloudkiton a tethered device — the system logs every sync attempt with the failure reason.
Red flag: “We don’t use the CloudKit dashboard — we just code, build, ship.”
Tells the interviewer you’ve never deployed a schema change to production. Every new field is invisible to App Store users until clicked through the dashboard. This is one of the most common reasons “the feature works for the dev team but not for shipped users.”
Lab preview
Lab 6.1 — Journal App with SwiftData ships with a stretch goal to migrate the same schema to Core Data + CloudKit and compare the developer experience side by side.
Next: SwiftData + CloudKit