--- name: cloudkit-sync description: "Implement, review, or improve CloudKit and iCloud sync in iOS/macOS apps. Use when working with CKContainer, CKRecord, CKQuery, CKSubscription, CKSyncEngine, CKShare, NSUbiquitousKeyValueStore, or iCloud Drive file coordination; when syncing SwiftData models via ModelConfiguration with cloudKitDatabase; when handling CKError codes for conflict resolution, network failures, or quota limits; or when checking iCloud account status before performing sync operations." --- # CloudKit and iCloud Sync Sync data across devices using CloudKit, iCloud key-value storage, and iCloud Drive. Covers container setup, record CRUD, queries, subscriptions, CKSyncEngine, SwiftData integration, conflict resolution, and error handling. Targets iOS 26+ with Swift 6.2; older availability noted where relevant. ## Contents - [Container and Database Setup](#container-and-database-setup) - [CKRecord CRUD](#ckrecord-crud) - [CKQuery](#ckquery) - [CKSubscription](#cksubscription) - [CKSyncEngine (iOS 17+)](#cksyncengine-ios-17) - [SwiftData + CloudKit](#swiftdata--cloudkit) - [NSUbiquitousKeyValueStore](#nsubiquitouskeyvaluestore) - [iCloud Drive File Sync](#icloud-drive-file-sync) - [Account Status and Error Handling](#account-status-and-error-handling) - [Conflict Resolution](#conflict-resolution) - [Common Mistakes](#common-mistakes) - [Review Checklist](#review-checklist) - [References](#references) ## Container and Database Setup Enable iCloud + CloudKit in Signing & Capabilities. A container provides three databases: | Database | Scope | Requires iCloud | Storage Quota | |----------|-------|-----------------|---------------| | Public | All users | Read: No, Write: Yes | App quota | | Private | Current user | Yes | User quota | | Shared | Shared records | Yes | Owner quota | ```swift import CloudKit let container = CKContainer.default() // Or named: CKContainer(identifier: "iCloud.com.example.app") let publicDB = container.publicCloudDatabase let privateDB = container.privateCloudDatabase let sharedDB = container.sharedCloudDatabase ``` ## CKRecord CRUD Records are key-value pairs. Max 1 MB per record (excluding CKAsset data). ```swift // CREATE let record = CKRecord(recordType: "Note") record["title"] = "Meeting Notes" as CKRecordValue record["body"] = "Discussed Q3 roadmap" as CKRecordValue record["createdAt"] = Date() as CKRecordValue record["tags"] = ["work", "planning"] as CKRecordValue let saved = try await privateDB.save(record) // FETCH by ID let recordID = CKRecord.ID(recordName: "unique-id-123") let fetched = try await privateDB.record(for: recordID) // UPDATE -- fetch first, modify, then save fetched["title"] = "Updated Title" as CKRecordValue let updated = try await privateDB.save(fetched) // DELETE try await privateDB.deleteRecord(withID: recordID) ``` ### Custom Record Zones (Private/Shared Only) Custom zones support atomic commits, change tracking, and sharing. ```swift let zoneID = CKRecordZone.ID(zoneName: "NotesZone") let zone = CKRecordZone(zoneID: zoneID) try await privateDB.save(zone) let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID) let record = CKRecord(recordType: "Note", recordID: recordID) ``` ## CKQuery Query records with NSPredicate. Supported: `==`, `!=`, `<`, `>`, `<=`, `>=`, `BEGINSWITH`, `CONTAINS`, `IN`, `AND`, `NOT`, `BETWEEN`, `distanceToLocation:fromLocation:`. ```swift let predicate = NSPredicate(format: "title BEGINSWITH %@", "Meeting") let query = CKQuery(recordType: "Note", predicate: predicate) query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)] let (results, _) = try await privateDB.records(matching: query) for (_, result) in results { let record = try result.get() print(record["title"] as? String ?? "") } // Fetch all records of a type let allQuery = CKQuery(recordType: "Note", predicate: NSPredicate(value: true)) // Full-text search across string fields let searchQuery = CKQuery( recordType: "Note", predicate: NSPredicate(format: "self CONTAINS %@", "roadmap") ) // Compound predicate let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "createdAt > %@", cutoffDate as NSDate), NSPredicate(format: "tags CONTAINS %@", "work") ]) ``` ## CKSubscription Subscriptions trigger push notifications when records change server-side. CloudKit auto-enables APNs -- no explicit push entitlement needed. ```swift // Query subscription -- fires when matching records change let subscription = CKQuerySubscription( recordType: "Note", predicate: NSPredicate(format: "tags CONTAINS %@", "urgent"), subscriptionID: "urgent-notes", options: [.firesOnRecordCreation, .firesOnRecordUpdate] ) let notifInfo = CKSubscription.NotificationInfo() notifInfo.shouldSendContentAvailable = true // silent push subscription.notificationInfo = notifInfo try await privateDB.save(subscription) // Database subscription -- fires on any database change let dbSub = CKDatabaseSubscription(subscriptionID: "private-db-changes") dbSub.notificationInfo = notifInfo try await privateDB.save(dbSub) // Record zone subscription -- fires on changes within a zone let zoneSub = CKRecordZoneSubscription( zoneID: CKRecordZone.ID(zoneName: "NotesZone"), subscriptionID: "notes-zone-changes" ) zoneSub.notificationInfo = notifInfo try await privateDB.save(zoneSub) ``` Handle in AppDelegate: ```swift func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) guard notification?.subscriptionID == "private-db-changes" else { return .noData } // Fetch changes using CKSyncEngine or CKFetchRecordZoneChangesOperation return .newData } ``` ## CKSyncEngine (iOS 17+) `CKSyncEngine` is the recommended sync approach. It handles scheduling, transient error retries, change tokens, and push notifications automatically. Works with private and shared databases only. ```swift import CloudKit final class SyncManager: CKSyncEngineDelegate { let syncEngine: CKSyncEngine init(container: CKContainer = .default()) { let config = CKSyncEngine.Configuration( database: container.privateCloudDatabase, stateSerialization: Self.loadState(), delegate: self ) self.syncEngine = CKSyncEngine(config) } func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) { switch event { case .stateUpdate(let update): Self.saveState(update.stateSerialization) case .accountChange(let change): handleAccountChange(change) case .fetchedRecordZoneChanges(let changes): for mod in changes.modifications { processRemoteRecord(mod.record) } for del in changes.deletions { processRemoteDeletion(del.recordID) } case .sentRecordZoneChanges(let sent): for saved in sent.savedRecords { markSynced(saved) } for fail in sent.failedRecordSaves { handleSaveFailure(fail) } default: break } } func nextRecordZoneChangeBatch( _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) -> CKSyncEngine.RecordZoneChangeBatch? { let pending = syncEngine.state.pendingRecordZoneChanges return CKSyncEngine.RecordZoneChangeBatch( pendingChanges: Array(pending) ) { recordID in self.recordToSend(for: recordID) } } } // Schedule changes let zoneID = CKRecordZone.ID(zoneName: "NotesZone") let recordID = CKRecord.ID(recordName: noteID, zoneID: zoneID) syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) // Trigger immediate sync (pull-to-refresh) try await syncEngine.fetchChanges() try await syncEngine.sendChanges() ``` **Key point**: persist `stateSerialization` across launches; the engine needs it to resume from the correct change token. ## SwiftData + CloudKit `ModelConfiguration` supports CloudKit sync. CloudKit models must use optional properties and avoid unique constraints. ```swift import SwiftData @Model class Note { var title: String var body: String? var createdAt: Date? @Attribute(.externalStorage) var imageData: Data? init(title: String, body: String? = nil) { self.title = title self.body = body self.createdAt = Date() } } let config = ModelConfiguration( "Notes", cloudKitDatabase: .private("iCloud.com.example.app") ) let container = try ModelContainer(for: Note.self, configurations: config) ``` **CloudKit model rules**: use optionals for all non-String properties; avoid `#Unique`; keep models flat; use `@Attribute(.externalStorage)` for large data; avoid complex relationship graphs. ## NSUbiquitousKeyValueStore Simple key-value sync. Max 1024 keys, 1 MB total, 1 MB per value. Stores locally when iCloud is unavailable. ```swift let kvStore = NSUbiquitousKeyValueStore.default // Write kvStore.set("dark", forKey: "theme") kvStore.set(14.0, forKey: "fontSize") kvStore.set(true, forKey: "notificationsEnabled") kvStore.synchronize() // Read let theme = kvStore.string(forKey: "theme") ?? "system" // Observe external changes NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kvStore, queue: .main ) { notification in guard let userInfo = notification.userInfo, let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int, let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else { return } switch reason { case NSUbiquitousKeyValueStoreServerChange: for key in keys { applyRemoteChange(key: key) } case NSUbiquitousKeyValueStoreInitialSyncChange: reloadAllSettings() case NSUbiquitousKeyValueStoreQuotaViolationChange: handleQuotaExceeded() default: break } } ``` ## iCloud Drive File Sync Use `FileManager` ubiquity APIs for document-level sync. ```swift guard let ubiquityURL = FileManager.default.url( forUbiquityContainerIdentifier: "iCloud.com.example.app" ) else { return } // iCloud not available let docsURL = ubiquityURL.appendingPathComponent("Documents") let cloudURL = docsURL.appendingPathComponent("report.pdf") try FileManager.default.setUbiquitous(true, itemAt: localURL, destinationURL: cloudURL) // Monitor iCloud files let query = NSMetadataQuery() query.predicate = NSPredicate(format: "%K LIKE '*.pdf'", NSMetadataItemFSNameKey) query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] NotificationCenter.default.addObserver( forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main ) { _ in query.disableUpdates() for item in query.results as? [NSMetadataItem] ?? [] { let name = item.value(forAttribute: NSMetadataItemFSNameKey) as? String let status = item.value( forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String } query.enableUpdates() } query.start() ``` ## Account Status and Error Handling Always check account status before sync. Listen for `.CKAccountChanged`. ```swift func checkiCloudStatus() async throws -> CKAccountStatus { let status = try await CKContainer.default().accountStatus() switch status { case .available: return status case .noAccount: throw SyncError.noiCloudAccount case .restricted: throw SyncError.restricted case .temporarilyUnavailable: throw SyncError.temporarilyUnavailable case .couldNotDetermine: throw SyncError.unknown @unknown default: throw SyncError.unknown } } ``` ### CKError Handling | Error Code | Strategy | |-----------|----------| | `.networkFailure`, `.networkUnavailable` | Queue for retry when network returns | | `.serverRecordChanged` | Three-way merge (see Conflict Resolution) | | `.requestRateLimited`, `.zoneBusy`, `.serviceUnavailable` | Retry after `retryAfterSeconds` | | `.quotaExceeded` | Notify user; reduce data usage | | `.notAuthenticated` | Prompt iCloud sign-in | | `.partialFailure` | Inspect `partialErrorsByItemID` per item | | `.changeTokenExpired` | Reset token, refetch all changes | | `.userDeletedZone` | Recreate zone and re-upload data | ```swift func handleCloudKitError(_ error: Error) { guard let ckError = error as? CKError else { return } switch ckError.code { case .networkFailure, .networkUnavailable: scheduleRetryWhenOnline() case .serverRecordChanged: resolveConflict(ckError) case .requestRateLimited, .zoneBusy, .serviceUnavailable: let delay = ckError.retryAfterSeconds ?? 3.0 scheduleRetry(after: delay) case .quotaExceeded: notifyUserStorageFull() case .partialFailure: if let partial = ckError.partialErrorsByItemID { for (_, itemError) in partial { handleCloudKitError(itemError) } } case .changeTokenExpired: resetChangeToken() case .userDeletedZone: recreateZoneAndResync() default: logError(ckError) } } ``` ## Conflict Resolution When saving a record that changed server-side, CloudKit returns `.serverRecordChanged` with three record versions. Always merge into `serverRecord` -- it has the correct change tag. ```swift func resolveConflict(_ error: CKError) { guard error.code == .serverRecordChanged, let ancestor = error.ancestorRecord, let client = error.clientRecord, let server = error.serverRecord else { return } // Merge client changes into server record for key in client.changedKeys() { if server[key] == ancestor[key] { server[key] = client[key] // Server unchanged, use client } else if client[key] == ancestor[key] { // Client unchanged, keep server (already there) } else { server[key] = mergeValues( // Both changed, custom merge ancestor: ancestor[key], client: client[key], server: server[key]) } } Task { try await CKContainer.default().privateCloudDatabase.save(server) } } ``` ## Common Mistakes **DON'T:** Perform sync operations without checking account status. **DO:** Check `CKContainer.accountStatus()` first; handle `.noAccount`. ```swift // WRONG try await privateDB.save(record) // CORRECT guard try await CKContainer.default().accountStatus() == .available else { throw SyncError.noiCloudAccount } try await privateDB.save(record) ``` **DON'T:** Ignore `.serverRecordChanged` errors. **DO:** Implement three-way merge using ancestor, client, and server records. **DON'T:** Store user-specific data in the public database. **DO:** Use private database for personal data; public only for app-wide content. **DON'T:** Assume data is available immediately after save. **DO:** Update local cache optimistically and reconcile on fetch. **DON'T:** Poll for changes on a timer. **DO:** Use `CKDatabaseSubscription` or `CKSyncEngine` for push-based sync. ```swift // WRONG Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { _ in fetchAll() } // CORRECT let sub = CKDatabaseSubscription(subscriptionID: "db-changes") sub.notificationInfo = CKSubscription.NotificationInfo() sub.notificationInfo?.shouldSendContentAvailable = true try await privateDB.save(sub) ``` **DON'T:** Retry immediately on rate limiting. **DO:** Use `CKError.retryAfterSeconds` to wait the required duration. **DON'T:** Merge conflict changes into `clientRecord`. **DO:** Always merge into `serverRecord` -- it has the correct change tag. **DON'T:** Pass nil change token on every fetch. **DO:** Persist change tokens to disk and supply them on subsequent fetches. ## Review Checklist - [ ] iCloud + CloudKit capability enabled in Signing & Capabilities - [ ] Account status checked before sync; `.noAccount` handled gracefully - [ ] Private database used for user data; public only for shared content - [ ] `CKError.serverRecordChanged` handled with three-way merge into `serverRecord` - [ ] Network failures queued for retry; `retryAfterSeconds` respected - [ ] `CKDatabaseSubscription` or `CKSyncEngine` used for push-based sync - [ ] Change tokens persisted to disk; `changeTokenExpired` resets and refetches - [ ] `.partialFailure` errors inspected per-item via `partialErrorsByItemID` - [ ] `.userDeletedZone` handled by recreating zone and resyncing - [ ] SwiftData CloudKit models use optionals, no `#Unique`, `.externalStorage` for large data - [ ] `NSUbiquitousKeyValueStore.didChangeExternallyNotification` observed - [ ] Sensitive data uses `encryptedValues` on CKRecord (not plain fields) - [ ] `CKSyncEngine` state serialization persisted across launches (iOS 17+) ## References - See `references/cloudkit-patterns.md` for CKFetchRecordZoneChangesOperation incremental sync, CKShare collaboration, record zone management, CKAsset file storage, batch operations, and CloudKit Dashboard usage. - [CloudKit Framework](https://sosumi.ai/documentation/cloudkit) - [CKContainer](https://sosumi.ai/documentation/cloudkit/ckcontainer) - [CKRecord](https://sosumi.ai/documentation/cloudkit/ckrecord) - [CKQuery](https://sosumi.ai/documentation/cloudkit/ckquery) - [CKSubscription](https://sosumi.ai/documentation/cloudkit/cksubscription) - [CKSyncEngine](https://sosumi.ai/documentation/cloudkit/cksyncengine) - [CKShare](https://sosumi.ai/documentation/cloudkit/ckshare) - [CKError](https://sosumi.ai/documentation/cloudkit/ckerror) - [NSUbiquitousKeyValueStore](https://sosumi.ai/documentation/foundation/nsubiquitouskeyvaluestore) - [CKAsset](https://sosumi.ai/documentation/cloudkit/ckasset)