cloudkit-sync

CloudKit and iCloud Sync

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "cloudkit-sync" with this command: npx skills add dpearson2699/swift-ios-skills/dpearson2699-swift-ios-skills-cloudkit-sync

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

swiftui-animation

No summary provided by upstream source.

Repository SourceNeeds Review
General

ios-accessibility

No summary provided by upstream source.

Repository SourceNeeds Review
General

swiftui-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

swiftui-performance

No summary provided by upstream source.

Repository SourceNeeds Review