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
-
CKRecord CRUD
-
CKQuery
-
CKSubscription
-
CKSyncEngine (iOS 17+)
-
SwiftData + CloudKit
-
NSUbiquitousKeyValueStore
-
iCloud Drive File Sync
-
Account Status and Error Handling
-
Conflict Resolution
-
Common Mistakes
-
Review Checklist
-
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
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).
// 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.
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: .
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.
// 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:
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.
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.
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.
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.
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 .
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
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.
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 .
// 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.
// 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
-
CKContainer
-
CKRecord
-
CKQuery
-
CKSubscription
-
CKSyncEngine
-
CKShare
-
CKError
-
NSUbiquitousKeyValueStore
-
CKAsset