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.
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:
- Create two
NSPersistentContainerinstances pointing to separate SQLite files. - Write conflicting values to the same object identifier in both stores.
- Simulate an import by merging the second store's changes into the first store's context using
mergeChanges(fromContextDidSave:). - 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.