cloudkit-sync

SKILL.md

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

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

Weekly Installs
182
GitHub Stars
204
First Seen
8 days ago
Installed on
codex181
cursor180
opencode179
gemini-cli179
github-copilot179
amp179