Skip to main content
3Nsofts logo3Nsofts

Insights / iOS Architecture

CloudKit Conflict Resolution: How NSPersistentCloudKitContainer Handles Merge Policies

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.

By Ehsan Azish · 3NSOFTS · May 2026

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. For the architecture decisions around Core Data vs SwiftData, see the SwiftData vs Core Data 2026 guide.

The four merge policies

Core Data provides four standard NSMergePolicy types. Their names are precise but not obvious:

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. A user's own in-flight edit will be silently discarded for any attribute the incoming record touches.

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. Omitting it produces the appearance of missing sync — changes from another device are in the store but the UI doesn't update.

For background contexts — used for writes that should not block the main thread — set the same merge policy explicitly. A background context that inherits no policy will use NSErrorMergePolicy (the default), which throws on any conflict rather than resolving it.

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 many attributes — user settings, toggle states, single-value fields — this is acceptable. 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. The trade-off: storage grows without bound unless you periodically compact entries. For apps managing sensitive data, append-only also provides an audit trail — see the CalmLedger architecture, which uses this pattern for financial transaction history.

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. Your application code can then decide whether to surface this to the user, log it, or proceed silently.

This is not a substitute for correct merge policy — it is instrumentation that makes conflict events observable. For a production implementation, see the Core Data CloudKit sync architecture guide and the CloudKit sync implementation guide.

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 in CloudKit — 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. A Document that previously had title, body, tags, and settings as attributes on one entity becomes four related entities. Changes to DocumentTitle do not conflict with changes to DocumentSettings.

This has implications for SwiftData as well — see the comparison in the SwiftData vs Core Data guide. SwiftData's ModelContainer uses the same CloudKit record model and the same last-write-wins behaviour. The entity granularity principle applies equally.

Observing sync events

NSPersistentCloudKitContainerEventChangedNotification provides the mechanism for observing sync state changes. The notification delivers an NSPersistentCloudKitContainerEvent with an error property if the import or export failed:

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 {
        // Log sync error — surface to user if relevant
        print("CloudKit import failed: \(error)")
    }
    // UI can update sync status indicator here
}
.store(in: &cancellables)

For the production NSPersistentCloudKitContainer setup, including zone configuration and error handling, see the NSPersistentCloudKitContainer production guide.

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.

The most straightforward approach:

  • 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.

For live CloudKit testing, two simulator instances in different iCloud accounts pointing at the same CloudKit development container will produce genuine sync conflicts when writes overlap. Use network conditioning to create a controlled offline window on one simulator before writing.

FAQs

What is a merge conflict in NSPersistentCloudKitContainer?

A merge conflict occurs when two persistent stores have different values for the same attribute on the same managed object. In a CloudKit-synced app, this typically happens when changes made on a second device arrive via CloudKit into a context that has its own in-flight or saved changes. The merge policy determines which value wins.

Which merge policy should I use with NSPersistentCloudKitContainer?

NSMergeByPropertyObjectTrumpMergePolicy is the most common starting point — it applies in-memory changes per attribute and preserves local edits. For apps where server state should always win (a collaborative document, a shared ledger), NSMergeByPropertyStoreTrumpMergePolicy is more appropriate. The choice depends on whether your conflict semantics are client-wins or server-wins, and how you model your data.

Does CloudKit's last-write-wins override Core Data merge policies?

Yes, at the record level. CloudKit uses server-side last-write-wins for its own record conflict resolution before data reaches Core Data. Core Data merge policies then handle conflicts between the imported CloudKit data and local context state. The two layers operate independently — CloudKit resolves cloud-to-cloud conflicts; the merge policy resolves cloud-import-to-local-context conflicts.

What is an append-only entity pattern and when should I use it?

An append-only entity stores each state change as a new record rather than updating a single mutable record in place. Instead of a User entity with a mutable balance attribute, you store Transactions where each transaction is immutable. Conflicts become structurally impossible because no two devices ever write to the same record.

How do I observe CloudKit sync events in my app?

Observe NSPersistentCloudKitContainerEventChangedNotification. The notification includes an NSPersistentCloudKitContainerEvent object with a type property indicating whether the event is an import, export, or setup operation, and a succeeded property indicating the outcome. This is the correct mechanism for updating sync status UI and triggering re-fetch after a successful import.

What does automaticallyMergesChangesFromParent do in this context?

Setting automaticallyMergesChangesFromParent = true on a context causes it to merge changes from its parent store coordinator automatically when the persistent store is updated — including CloudKit imports. Without this, CloudKit-imported changes are not reflected in existing contexts until you manually merge or re-fetch. It is required for the viewContext to stay current with CloudKit sync.

Can I test conflict resolution without a second physical device?

Yes. You can simulate a conflict by saving a context change, injecting a simulated remote change directly into the persistent store coordinator using the testing APIs in NSPersistentCloudKitContainer, then triggering a merge. Alternatively, use two simulator instances pointing at the same CloudKit container in the development environment, with network conditioning to create overlapping write windows.

Building a CloudKit-synced app and want the architecture reviewed?

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.