- 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
- (
- )
- (
- 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