How to use NSPersistentCloudKitContainer

Issue #879

NSPersistentCloudKitContainer makes it straightforward to sync Core Data across a user’s devices via iCloud. Setup is quick, but there are several non-obvious behaviors that cause sync to silently fail in production. This covers both how to set it up and what to do when it stops working.

Setting up

In your Xcode project, enable the following capabilities:

  • iCloud — with CloudKit checked
  • Push Notifications
  • Background Modes — with Remote notifications checked

Then replace NSPersistentContainer with NSPersistentCloudKitContainer and configure the store description:

let container = NSPersistentCloudKitContainer(name: "MyApp")

guard let description = container.persistentStoreDescriptions.first else { return }
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
    containerIdentifier: "iCloud.com.example.MyApp"
)

container.loadPersistentStores { _, error in
    if let error { fatalError(error.localizedDescription) }
}

container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump

Initialize the CloudKit schema

During development, call initializeCloudKitSchema once to push your Core Data model to CloudKit. Do this only in debug builds — running it in production is unnecessary and can cause errors.

#if DEBUG
try? container.initializeCloudKitSchema(options: [])
#endif

CloudKit model requirements

CloudKit requires that all attributes in your model be optional, or have a default value. If any attribute is required (non-optional with no default), the container will fail to mirror it. This is a common source of silent failures when first migrating an existing Core Data model.

Variable-length attributes

String, Binary Data, and Transformable attributes generate a companion field in CloudKit with the suffix _ckAsset. When a value exceeds the 1 MB record limit, Core Data moves it to the asset field automatically. If you inspect a CloudKit record directly and see an empty original field, check the _ckAsset version.

Device requirements

All devices must be signed into the same Apple ID and have iCloud enabled for the app. On iOS, go to Settings › Apple ID › iCloud › Apps Using iCloud and confirm the app is listed and enabled. On macOS, check System Settings › Apple ID › iCloud Drive › Apps syncing to iCloud Drive. iCloud Drive must also be on at the account level.


Troubleshooting

Sync only happens when I background the app

This is almost always because context.save() is only called during app backgrounding. The container can’t export changes it doesn’t know about. Call save() — or a debounced version of it — after every mutation, not just in sceneDidEnterBackground.

The export pipeline and why it gets blocked

When you call context.save(), the container schedules an export and asks the system scheduler (dasd) for permission. Three policies can block it:

  • Activity Group Policy — too many concurrent system activities. Resolves quickly.
  • ActivityRateLimitPolicy — the app has hit a rate limit from scheduling too frequently. This “can last for hours” (TN3163).
  • Low Power Mode Policy — the device is in Low Power Mode. Per Apple, it “may last until the battery level is back to a certain level, or the device is connected to a power adapter.”

That third policy explains reports of sync only working when plugged in. The system is intentionally deferring background work to save battery. Disabling Low Power Mode on both devices resolves it.

Imports arrive via push — not polling

For private and shared CloudKit databases, imports are driven by silent push notifications (APNs). After a device saves a change, CloudKit notifies other devices and the container imports within seconds. You don’t configure this subscription — the container handles it automatically.

Public databases work differently: polling happens “once every 30 minutes in the CloudKit development environment, and up to once every 24 hours in the production environment” (TN3163).

You can nudge an import manually by posting UIApplication.didBecomeActiveNotification while the app is in the foreground — the container treats this the same as actual app activation and schedules an import check:

NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil)

Do not call this on a timer. Repeated calls trigger ActivityRateLimitPolicy and halt sync for hours. Apple is unambiguous: “There is no API for apps to configure the timing for the synchronization.”

Two notifications, two different jobs

NSPersistentCloudKitContainer exposes two notifications that are easy to confuse.

NSPersistentStoreRemoteChangeNotification means the SQLite store on disk was written to — either by a CloudKit import or by another process like an app extension writing to a shared store. This is the merge signal. If you are not using automaticallyMergesChangesFromParent = true, this is where you manually merge changes into your context:

NotificationCenter.default.addObserver(
    forName: .NSPersistentStoreRemoteChange,
    object: container.persistentStoreCoordinator,
    queue: nil
) { _ in
    context.perform {
        context.mergeChanges(fromContextDidSave: ...)
    }
}

With automaticallyMergesChangesFromParent = true set on the viewContext, you usually don’t need this — the container handles the merge automatically, and NSFetchedResultsController will pick up the changes through its context observation.

NSPersistentCloudKitContainer.eventChangedNotification fires when a CloudKit sync operation (setup, import, or export) starts or finishes. It carries an Event object with the type, success flag, timestamps, and any error. This notification has nothing to do with merging data — it is purely for observability. Use it to drive sync status UI: spinners, “Last synced” timestamps, error banners:

NotificationCenter.default.addObserver(
    forName: NSPersistentCloudKitContainer.eventChangedNotification,
    object: container,
    queue: .main
) { notification in
    guard let event = notification.userInfo?[
        NSPersistentCloudKitContainer.eventNotificationUserInfoKey
    ] as? NSPersistentCloudKitContainer.Event else { return }
    // update sync status UI
}

The short version: NSPersistentStoreRemoteChange = data changed, merge it. eventChangedNotification = sync operation status, show it.

The UI doesn’t update after import

NSFetchedResultsController observes the managed object context and responds to changes automatically. When automaticallyMergesChangesFromParent = true is set, remote CloudKit imports merge into the viewContext and FRC delegate methods fire without any extra work.

Where this breaks down is when you fetch data outside of FRC — a ViewModel that stores results in an array, a custom loader class, anything that runs context.fetch() once and holds onto the results. Those won’t react to remote changes on their own. For those cases, listen to eventChangedNotification and re-fetch when an import completes:

NotificationCenter.default.addObserver(
    forName: NSPersistentCloudKitContainer.eventChangedNotification,
    object: nil, queue: .main
) { notification in
    guard
        let event = notification.userInfo?[
            NSPersistentCloudKitContainer.eventNotificationUserInfoKey
        ] as? NSPersistentCloudKitContainer.Event,
        event.type == .import,
        event.endDate != nil,
        event.succeeded
    else { return }

    // reload your view model or custom loader here
}

Debounce this by around five seconds. Imports often arrive in a burst, and reloading on every event is wasteful.

The change token expires

CloudKit uses a server change token per device to track sync progress. This token can expire when the CloudKit zone is deleted and recreated (common during development schema resets), when the device hasn’t synced in a long time, or after certain server-side events.

When it expires, the container fires NSCloudKitMirroringDelegateWillResetSyncNotificationName, wipes the local mirror, and re-imports everything from CloudKit. No data is lost, but the re-import can be slow.

The risk is calling context.save() during the reset window, while objects are being invalidated and replaced. Guard against it:

private var isResettingSync = false

// Listen for WillReset:
isResettingSync = true
saveDebouncer.cancel()

// Listen for DidReset:
isResettingSync = false

func save() {
    guard !isResettingSync else { return }
    try? context.save()
}

Cancelling pending debounced saves when WillReset fires matters — a queued save can otherwise fire mid-reset before the flag takes effect.

Toggling iCloud sync on and off

Changing cloudKitContainerOptions alone is not enough. The old container is still alive and still has a reference to the same SQLite file. Creating a new container without removing the old store causes two containers to conflict, which produces error 134410.

Always remove existing stores first:

if let existing = container {
    for store in existing.persistentStoreCoordinator.persistentStores {
        try? existing.persistentStoreCoordinator.remove(store)
    }
}
container = makeContainer(enablesSync: newValue)

Enabling debug logs

In Xcode, open Edit Scheme › Run › Arguments Passed On Launch and add:

-com.apple.CoreData.CloudKitDebug 1
-com.apple.CoreData.SQLDebug 1

CloudKitDebug logs all mirroring activity — setup, import and export cycles, push receipts, and errors. SQLDebug logs every statement against the local store. Together they trace the full pipeline from a context.save() through to a CloudKit record.

For live monitoring in Terminal:

# Core Data + CloudKit activity for your app
log stream --predicate 'process = "YourApp" AND (sender = "CoreData" OR sender = "CloudKit")'

# System scheduler decisions on sync tasks
log stream --predicate 'process = "dasd" AND message contains[cd] "com.apple.coredata.cloudkit.activity"'

# Push notification delivery
log stream --predicate 'process = "apsd" AND message contains[cd] "YourApp"'

The dasd stream is the most useful when sync seems stuck — it shows exactly which policy is blocking and whether it has expired.

Read more

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation