Skip to main content
3Nsofts logo3Nsofts
iOS Architecture

CloudKit Conflict Resolution: How NSPersistentCloudKitContainer Handles Merge Policies

How NSPersistentCloudKitContainer handles sync conflicts, what the four merge policies mean in practice, when CloudKit's last-write-wins overrides them, and patterns for avoiding conflicts by design.

By Ehsan Azish · 3NSOFTS·May 2026·11 min read

Sync conflicts are not edge cases. They are the normal condition of a multi-device app where two contexts can write to the same record before either change propagates. Understanding how the two conflict resolution layers — CloudKit's and Core Data's — interact is a prerequisite for building correct synced data models.

Two layers, two different problems

NSPersistentCloudKitContainer runs on top of two independent conflict resolution systems. Conflating them leads to incorrect assumptions about which value survives a concurrent write.

The first layer is CloudKit's own last-write-wins mechanism. CloudKit records have a server-side modified timestamp. When two clients write to the same record and the second write reaches the server, the server compares timestamps and discards the older write. This happens before any Core Data code runs.

The second layer is Core Data's merge policy. After NSPersistentCloudKitContainer imports CloudKit changes into the local store, any existing in-memory context with changes to the same objects encounters a conflict. The merge policy determines how that conflict resolves in memory.

These layers are not redundant. CloudKit's last-write-wins operates between devices. Core Data's merge policy operates between the persistent store and in-memory contexts on a single device.

The four merge policies

Core Data provides four standard NSMergePolicy types:

NSMergeByPropertyObjectTrumpMergePolicy

For each conflicting attribute, the in-memory (object) value wins over the persistent store value. Non-conflicting attributes are merged per-property. This is the most permissive policy for local edits — the user's in-flight changes survive an incoming sync. It is the correct default for apps where the local context is authoritative during active editing.

NSMergeByPropertyStoreTrumpMergePolicy

For each conflicting attribute, the persistent store value wins over the in-memory value. Non-conflicting attributes are merged per-property. This policy is appropriate when the server — via CloudKit import — should always win. A shared document where late-arriving server state represents a collaborative edit from another user is a reasonable use case.

NSOverwriteMergePolicy

The in-memory object overwrites the persistent store entirely — including attributes that did not conflict. This is rarely the correct choice in a sync context. It is appropriate for single-user, single-device apps where the store state is definitionally stale by the time an in-memory edit exists.

NSRollbackMergePolicy

Discards all in-memory changes and rolls back to the persistent store state. Appropriate for read-only contexts, or contexts that are only used for displaying data and never for editing. Setting this on a viewContext used for editing will silently discard user input whenever a sync import arrives.

Context setup

Merge policy is set on the NSManagedObjectContext, not on the container. The viewContext exposed by NSPersistentCloudKitContainer must be configured before any fetch or save:

let container = NSPersistentCloudKitContainer(name: "Model")
container.loadPersistentStores { _, error in
    guard error == nil else { fatalError("Store load failed: \(error!)") }
}

// Required for CloudKit imports to reflect in the view context
container.viewContext.automaticallyMergesChangesFromParent = true

// Per-attribute local wins policy — most apps start here
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

automaticallyMergesChangesFromParent is not optional. Without it, CloudKit-imported changes land in the persistent store but are invisible to the viewContext until you manually call refreshAllObjects() or the context is reset.

For background contexts:

let backgroundContext = container.newBackgroundContext()
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
backgroundContext.automaticallyMergesChangesFromParent = true

When last-write-wins is not enough

CloudKit's last-write-wins resolves conflicts by discarding one of the competing writes entirely. For accumulative data, it silently loses information.

Consider a stepCount attribute that two devices increment independently. Device A increments from 5,000 to 6,200 and syncs. Device B, offline during that window, increments from 5,000 to 5,800 and syncs later. CloudKit's last-write-wins gives Device B's write to the server — the result is 5,800. The 1,200 steps from Device A are gone.

The correct model for accumulative data is not a mutable attribute. It is an append-only entity.

Append-only entity patterns

An append-only entity stores state transitions rather than current state. Each record is immutable after creation. The current state is derived by aggregating the record set.

// Mutable attribute — conflict-prone
class UserProfile: NSManagedObject {
    @NSManaged var totalSteps: Int64
}

// Append-only — conflict-proof
class StepEntry: NSManagedObject {
    @NSManaged var steps: Int64
    @NSManaged var recordedAt: Date
    @NSManaged var deviceID: String
}

// Current state derived from entries
extension UserProfile {
    var derivedTotalSteps: Int64 {
        stepEntries.reduce(0) { $0 + $1.steps }
    }
}

Every device creates new StepEntry records. No record is ever updated. CloudKit's last-write-wins never has a conflict to resolve because each record is unique.

Timestamp fields and conflict attribution

For entities where a mutable field is unavoidable, a lastModifiedAt timestamp field enables application-level conflict detection even after merge policy has resolved the conflict.

The pattern: every save updates lastModifiedAt to the current device time and sets a modifiedByDeviceID field. After a merge, inspect both fields. If modifiedByDeviceID does not match the current device and lastModifiedAt is recent, a remote write just won the merge.

Granular entity modeling

CloudKit's last-write-wins operates at the record level. If a Document entity has 20 attributes, and two devices each modify one different attribute, only the last-synced device's version survives — including the other 19 attributes that were not changed by the winning device.

The mitigation is granular entity modeling: breaking monolithic entities into smaller entities where each entity covers a tightly scoped concern. Changes to DocumentTitle do not conflict with changes to DocumentSettings.

Observing sync events

NSPersistentCloudKitContainerEventChangedNotification provides the mechanism for observing sync state changes:

NotificationCenter.default.publisher(
    for: NSPersistentCloudKitContainer.eventChangedNotification
)
.compactMap { $0.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] }
.compactMap { $0 as? NSPersistentCloudKitContainerEvent }
.filter { $0.type == .import && $0.endDate != nil }
.sink { event in
    if let error = event.error {
        print("CloudKit import failed: \(error)")
    }
}
.store(in: &cancellables)

Testing conflict scenarios

Conflict resolution is untestable in a unit test that uses a single in-memory store. Testing requires two persistent stores that can both write to the same record:

  1. Create two NSPersistentContainer instances pointing to separate SQLite files.
  2. Write conflicting values to the same object identifier in both stores.
  3. Simulate an import by merging the second store's changes into the first store's context using mergeChanges(fromContextDidSave:).
  4. Assert the resolved value matches the expected policy output.

FAQ

Work With Me

The iOS Architecture Audit covers your Core Data model, merge policy configuration, CloudKit zone setup, and conflict resolution patterns — delivered as a written recommendations report in 5 business days.

Related

Authoritative References