---
name: cloudkit-sync
description: "CloudKit and iCloud sync on iOS and macOS: CKContainer public, private and shared databases, CKRecord, CKQuery, CKSubscription, CKSyncEngine, CKShare, NSUbiquitousKeyValueStore, iCloud Drive file coordination, SwiftData sync via ModelConfiguration cloudKitDatabase, CKError handling for conflicts, network failures and quota, iCloud account status checks. Use when implementing, reviewing or improving how an app stores or shares user data through iCloud."
metadata:
  source: multi-agent-pipeline
---

# CloudKit Sync

Everything iCloud offers for app data: CloudKit databases, the key-value
store, and iCloud Drive documents. This file covers containers, record
operations, queries, subscriptions, `CKSyncEngine`, SwiftData integration,
error handling and conflict merges for Swift 6.3 on iOS 26, with older
availability noted where it matters.

Incremental fetch operations, change tokens, sharing, zones, assets, batching,
quality of service, encrypted fields and the Dashboard are in the
[CloudKit patterns reference](references/cloudkit-patterns.md). Core Data
stacks that sync through `NSPersistentCloudKitContainer` start in `core-data`;
background push handling in general is `push-notifications`.

## Containers and databases

Enable **iCloud** with the **CloudKit** service under Signing & Capabilities.

| Database | Who sees it | Needs an iCloud account | Storage charged to |
|---|---|---|---|
| Public | Every user of the app | Only for writes; reads work signed out | The app |
| Private | The signed-in user | Yes | The user |
| Shared | Records others shared with the user | Yes | The owner |

```swift
import CloudKit

let container = CKContainer(identifier: "iCloud.app.gardenlog")   // or CKContainer.default()
let publicDB = container.publicCloudDatabase
let privateDB = container.privateCloudDatabase
let sharedDB = container.sharedCloudDatabase
```

## Records

A `CKRecord` is a typed dictionary. One record can hold up to 1 MB, not
counting the files behind any `CKAsset` fields.

```swift
func plant(named name: String, in db: CKDatabase) async throws -> CKRecord {
    let plant = CKRecord(recordType: "Plant")
    plant["name"] = name as CKRecordValue
    plant["plantedOn"] = Date.now as CKRecordValue
    plant["tags"] = ["herb", "sunny"] as CKRecordValue
    return try await db.save(plant)
}

func rename(_ recordName: String, to newName: String, in db: CKDatabase) async throws {
    let id = CKRecord.ID(recordName: recordName)
    let existing = try await db.record(for: id)       // always fetch before editing
    existing["name"] = newName as CKRecordValue
    _ = try await db.save(existing)
}

func uproot(_ recordName: String, in db: CKDatabase) async throws {
    _ = try await db.deleteRecord(withID: CKRecord.ID(recordName: recordName))
}
```

### Custom zones

The private database accepts zones the app creates; the shared database
exposes the zones other people shared with the user. Custom zones unlock
atomic commits, change tracking and sharing. The public database has no custom
zones.

```swift
let beds = CKRecordZone.ID(zoneName: "GardenBeds")
_ = try await privateDB.save(CKRecordZone(zoneID: beds))

let bedID = CKRecord.ID(recordName: UUID().uuidString, zoneID: beds)
let bed = CKRecord(recordType: "Bed", recordID: bedID)
```

## Queries

CloudKit accepts a subset of `NSPredicate`:

- comparisons `==`, `!=`, `<`, `>`, `<=`, `>=`
- `BEGINSWITH` for string prefixes
- `CONTAINS` for membership in a list field, except `self CONTAINS`, which runs a tokenized full-text search
- `IN`, `AND`, `NOT`, `BETWEEN`
- `distanceToLocation:fromLocation:` for location fields

An unsupported operator, key path or field type is only rejected when the
query executes, not when you build it.

Encrypted values cannot be queried or sorted. `CKAsset` contents are already
encrypted, and a `CKRecord.Reference` can never be encrypted because the
server needs it. Say all three whenever you review encryption.

```swift
func recentHerbs(in db: CKDatabase) async throws -> [CKRecord] {
    let weekAgo = Date.now.addingTimeInterval(-7 * 86_400)
    let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
        NSPredicate(format: "plantedOn > %@", weekAgo as NSDate),
        NSPredicate(format: "tags CONTAINS %@", "herb"),
    ])
    let query = CKQuery(recordType: "Plant", predicate: predicate)
    query.sortDescriptors = [NSSortDescriptor(key: "plantedOn", ascending: false)]

    let (matches, _) = try await db.records(matching: query)
    return matches.compactMap { try? $0.1.get() }
}
```

Use `NSPredicate(value: true)` to match every record of a type.

## Subscriptions

A subscription makes CloudKit push a notification when server data changes.
With CloudKit enabled, Xcode manages the push entitlement; no separate App ID
push setup is needed. Silent pushes still require **Background Modes >
Remote notifications**.

```swift
let newPlants = CKQuerySubscription(
    recordType: "Plant",
    predicate: NSPredicate(value: true),
    subscriptionID: "plant-changes",
    options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true      // silent
newPlants.notificationInfo = info
_ = try await publicDB.save(newPlants)

let everything = CKDatabaseSubscription(subscriptionID: "private-db")          // any change in the DB
let oneZone = CKRecordZoneSubscription(zoneID: beds, subscriptionID: "beds")   // changes in one zone
```

```swift
import UIKit

// UIApplicationDelegate
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
    guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo),
          note.subscriptionID == "private-db" else { return .noData }
    do {
        try await GardenSync.shared.pullNow()   // CKSyncEngine or CKFetchRecordZoneChangesOperation
        return .newData
    } catch {
        return .failed
    }
}
```

## CKSyncEngine (iOS 17+)

For custom model data, `CKSyncEngine` is the recommended route. It schedules
work, retries transient failures, keeps change tokens and manages the
database subscription for you. It does **not** resolve app-level save
failures: a `CKError.serverRecordChanged` inside
`sentRecordZoneChanges.failedRecordSaves` needs your merge and a reschedule.
Its automatic timing is not predictable. It needs the CloudKit capability plus
Remote notifications, and works only with private and shared databases, never
public.

```swift
actor GardenSync: CKSyncEngineDelegate {
    static let shared = GardenSync()
    private var engine: CKSyncEngine?

    func start() {
        guard engine == nil else { return }
        let config = CKSyncEngine.Configuration(
            database: CKContainer.default().privateCloudDatabase,
            stateSerialization: StateFile.load(),   // CKSyncEngine.State.Serialization?
            delegate: self
        )
        engine = CKSyncEngine(config)
    }

    func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
        switch event {
        case .stateUpdate(let update):
            StateFile.save(update.stateSerialization)      // resume from the right token next launch
        case .accountChange(let change):
            await LocalStore.shared.handle(change)
        case .fetchedRecordZoneChanges(let fetched):
            for mod in fetched.modifications { await LocalStore.shared.upsert(mod.record) }
            for gone in fetched.deletions { await LocalStore.shared.remove(gone.recordID) }
        case .sentRecordZoneChanges(let sent):
            for saved in sent.savedRecords { await LocalStore.shared.markSynced(saved) }
            for failure in sent.failedRecordSaves { await resolve(failure, engine: syncEngine) }
        default:
            break
        }
    }

    func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext,
                                   syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
        let scope = context.options.scope
        let pending = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) }
        return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { id in
            await LocalStore.shared.record(for: id)
        }
    }

    func queueUpload(of id: CKRecord.ID) {
        engine?.state.add(pendingRecordZoneChanges: [.saveRecord(id)])
    }

    func pullNow() async throws {
        guard let engine else { return }
        try await engine.fetchChanges()     // pull-to-refresh or push arrival
        try await engine.sendChanges()
    }
}
```

`context.options.scope` says which zones (for example `.zoneIDs([...])`) or
records this send covers; filter the pending changes to it rather than
sending everything. Both delegate methods are `async`. Persist
`stateSerialization` on every `.stateUpdate`; without it the engine starts
over on the next launch.

## SwiftData with CloudKit

`ModelConfiguration` can sync a SwiftData store to CloudKit. Any
implementation or review has to state **two verdicts**:

1. **Model compatibility** - no `#Unique` macro or unique attributes, every
   relationship optional, no `.deny` delete rule, and large `Data` marked for
   external storage. Scalar properties do not all need to be optional; they
   just need defaults or optionality where CloudKit requires it.
2. **Schema rollout** - initialize the development schema from a non-release
   build, inspect it in CloudKit Dashboard, and promote it to production before
   shipping. After promotion, changes are additive only: never delete a model
   type or change an existing attribute.

```swift
import SwiftData

@Model
final class Harvest {
    var crop: String = ""
    var weightGrams: Double = 0
    var pickedOn: Date = Date.now
    @Attribute(.externalStorage) var photo: Data?
    var bed: GardenBed?                 // optional relationship
    init() {}
}
```

```swift
let config = ModelConfiguration("Garden", cloudKitDatabase: .private("iCloud.app.gardenlog"))
let container = try ModelContainer(for: Harvest.self, GardenBed.self, configurations: config)
```

## Key-value store

`NSUbiquitousKeyValueStore` holds small preferences: at most 1024 keys, 1 MB
in total and 1 MB per value. It writes locally when iCloud is unavailable and
syncs later.

```swift
let kv = NSUbiquitousKeyValueStore.default
kv.set("celsius", forKey: "temperatureUnit")
kv.synchronize()
let unit = kv.string(forKey: "temperatureUnit")

NotificationCenter.default.addObserver(
    forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
    object: kv, queue: .main
) { note in
    let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
    let keys = note.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] ?? []
    switch reason {
    case NSUbiquitousKeyValueStoreServerChange:       Preferences.apply(keys)
    case NSUbiquitousKeyValueStoreInitialSyncChange:  Preferences.reloadAll()
    case NSUbiquitousKeyValueStoreQuotaViolationChange: Preferences.trimStorage()
    default: break
    }
}
```

## iCloud Drive documents

Documents sync through the `FileManager` ubiquity APIs. Both
`url(forUbiquityContainerIdentifier:)` and
`setUbiquitous(_:itemAt:destinationURL:)` can block (the second performs
coordinated file operations), so call them off the main thread. A `nil`
container URL means iCloud is not available. If the app is currently
presenting the file, register a file presenter before moving it.

```swift
func moveToCloud(_ local: URL) {
    Task.detached {
        let fm = FileManager.default
        guard let root = fm.url(forUbiquityContainerIdentifier: nil) else { return }
        let docs = root.appending(path: "Documents", directoryHint: .isDirectory)
        try? fm.createDirectory(at: docs, withIntermediateDirectories: true)
        try? fm.setUbiquitous(true, itemAt: local, destinationURL: docs.appending(path: local.lastPathComponent))
    }
}
```

Watch for changes with an `NSMetadataQuery` whose search scope is
`NSMetadataQueryUbiquitousDocumentsScope` or `NSMetadataQueryUbiquitousDataScope`.

### Coordinated reads and writes

The iCloud daemon reads and writes files in the ubiquity container at the same
time as the app, so every direct access to a ubiquitous file goes through
`NSFileCoordinator`. Do the file work only inside the accessor block and only
with the URL it passes in, which can differ from the one requested. A
coordinated read of a ubiquitous item waits for its contents to download
unless `.immediatelyAvailableMetadataOnly` is passed. Coordination blocks the
calling thread, so keep it off the main actor.

```swift
nonisolated func readCoordinated(_ url: URL) throws -> Data {
    var coordinationError: NSError?
    var result: Result<Data, any Error> = .failure(CocoaError(.fileReadUnknown))
    NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordinationError) { safeURL in
        result = Result { try Data(contentsOf: safeURL) }
    }
    if let coordinationError { throw coordinationError }
    return try result.get()
}

nonisolated func writeCoordinated(_ data: Data, to url: URL) throws {
    var coordinationError: NSError?
    var writeError: (any Error)?
    NSFileCoordinator().coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { safeURL in
        do { try data.write(to: safeURL, options: .atomic) } catch { writeError = error }
    }
    if let coordinationError { throw coordinationError }
    if let writeError { throw writeError }
}
```

An object that keeps a file open (an editor, a live preview) adopts
`NSFilePresenter` so it hears about changes, moves and deletions that iCloud
makes: implement `presentedItemURL`, return a private `OperationQueue` from
`presentedItemOperationQueue` (the main queue invites deadlocks), react in
`presentedItemDidChange()`, and pair `NSFileCoordinator.addFilePresenter(_:)`
with `removeFilePresenter(_:)`. Create the presenter's own coordinators with
`NSFileCoordinator(filePresenter:)` so it is not told about its own writes.
`UIDocument` already does all of this, so a document-based app gets
coordination by subclassing it.

## Account status

Check the account before any sync, and listen for `.CKAccountChanged`.

```swift
switch try await CKContainer.default().accountStatus() {
case .available:              await GardenSync.shared.start()
case .noAccount:              showSignInToICloudHint()
case .restricted:             showRestrictedNotice()
case .temporarilyUnavailable: scheduleAccountRecheck()
case .couldNotDetermine:      scheduleAccountRecheck()
@unknown default:             scheduleAccountRecheck()
}
```

## CKError handling

| Code | Response |
|---|---|
| `.networkFailure`, `.networkUnavailable` | Queue the work and retry once connectivity returns |
| `.serverRecordChanged` | Three-way merge (below) |
| `.requestRateLimited`, `.zoneBusy`, `.serviceUnavailable` | Wait `retryAfterSeconds` before retrying |
| `.quotaExceeded` | Tell the user; reduce stored data |
| `.notAuthenticated` | Ask the user to sign in to iCloud |
| `.partialFailure` | Walk `partialErrorsByItemID` and handle each item's error |
| `.changeTokenExpired` | Drop the token and refetch everything |
| `.userDeletedZone` | Recreate the zone and upload again |

```swift
func retryDelay(for error: CKError) -> TimeInterval {
    error.retryAfterSeconds ?? 3
}
```

## Conflict resolution

A `.serverRecordChanged` error carries `ancestorRecord`, `clientRecord` and
`serverRecord`. Always merge **into `serverRecord`**, because it holds the
current change tag; saving the client copy just fails again.

```swift
func merged(_ error: CKError) -> CKRecord? {
    guard let server = error.serverRecord, let mine = error.clientRecord else { return nil }
    let base = error.ancestorRecord
    for key in mine.changedKeys() {
        let serverValue = server[key] as? NSObject
        let baseValue = base?[key] as? NSObject
        let myValue = mine[key] as? NSObject
        if serverValue == baseValue {
            server[key] = mine[key]                  // only I changed it
        } else if myValue == baseValue {
            continue                                 // only the server changed it
        } else {
            server[key] = resolveBothChanged(key, mine: mine[key], theirs: server[key])
        }
    }
    return server                                    // save this one
}
```

## Common mistakes

- Starting sync without confirming `accountStatus() == .available`.
- Ignoring `.serverRecordChanged`, which silently loses edits.
- Putting personal data in the public database. Private is for user data;
  public is only for content every user should see.
- Polling on a timer. Use a `CKDatabaseSubscription` with silent pushes or `CKSyncEngine`.
- Retrying a rate-limited request immediately instead of waiting `retryAfterSeconds`.
- Expecting `CKSyncEngine` to settle `.serverRecordChanged`. Merge the
  `failedRecordSaves` yourself and reschedule them.
- Fetching with a `nil` change token every time. Store tokens on disk.

## Review checklist

- [ ] iCloud capability with CloudKit enabled
- [ ] Account status checked; `.noAccount` handled
- [ ] User data kept in the private database
- [ ] Custom zones in the private database; shared zones discovered from accepted shares
- [ ] `.serverRecordChanged` merged into `serverRecord`
- [ ] Network failures queued; `retryAfterSeconds` honored
- [ ] Sync driven by pushes; Remote notifications background mode on
- [ ] Change tokens persisted; `.changeTokenExpired` resets them
- [ ] `.partialFailure` examined item by item
- [ ] `.userDeletedZone` handled
- [ ] SwiftData review gives both the model-compatibility and schema-rollout verdicts
- [ ] Key-value store external-change notification observed
- [ ] Ubiquitous files read and written through `NSFileCoordinator`; open files have an `NSFilePresenter`
- [ ] Encryption review mentions: references cannot be encrypted, encrypted fields cannot be queried or sorted, assets are encrypted already
- [ ] `CKSyncEngine` state serialization saved (iOS 17+)

## References

- [CloudKit patterns](references/cloudkit-patterns.md) - incremental fetches, tokens, sharing, zones, assets, batches, QoS, encryption, Dashboard
- [CloudKit](https://developer.apple.com/documentation/cloudkit)
- [CKContainer](https://developer.apple.com/documentation/cloudkit/ckcontainer)
- [CKRecord](https://developer.apple.com/documentation/cloudkit/ckrecord)
- [CKQuery](https://developer.apple.com/documentation/cloudkit/ckquery)
- [CKSubscription](https://developer.apple.com/documentation/cloudkit/cksubscription)
- [CKSyncEngine](https://developer.apple.com/documentation/cloudkit/cksyncengine-5sie5)
- [CKShare](https://developer.apple.com/documentation/cloudkit/ckshare)
- [CKError](https://developer.apple.com/documentation/cloudkit/ckerror)
- [NSUbiquitousKeyValueStore](https://developer.apple.com/documentation/foundation/nsubiquitouskeyvaluestore)
- SwiftData: the article on syncing model data across a person's devices
