---
name: core-data-migrations
category: engineering
description: Core Data model versioning and migration — when a new .xcdatamodel version is required, what lightweight migration actually supports, renaming and retiring attributes without losing data, and how to verify a migration before shipping it. Use when changing an existing Core Data model, adding a model version, renaming or deleting an attribute or entity, or when a migration lost data or failed to open the store. Trigger on "Core Data migration", "xcdatamodel", "model version", "lightweight migration", "renamingIdentifier", "rename an attribute", "migration lost data", "NSMigrationError", or "store failed to load after update". For CloudKit-mirrored models see cloudkit-sync; for SwiftData see the SwiftData migration reference in ios26-api-reference.
---

# Core Data Migrations

**Changing a shipped Core Data model is a data-safety operation, not an edit.** The model on disk belongs to the user; the model in the repo is a proposal for how to reinterpret it. Every rule here exists because the failure mode is silent — a bad migration usually does not throw, it just hands back `nil` where the bytes used to be.

Read this before touching any `.xcdatamodel` that has shipped to anyone, including TestFlight.

## The one rule that prevents most incidents

**Never edit a model version that has shipped. Add a new one.**

A `.xcdatamodeld` is a bundle of versions plus an `.xccurrentversion` pointer. Editing the current version in place changes the store's expected shape without changing its version hashes, so Core Data has no idea a migration is needed and no way to describe one. Symptoms range from "store won't open" to attributes silently reading `nil`.

```
Near.xcdatamodeld/
  Near 18.xcdatamodel/contents   ← shipped, frozen forever
  Near 19.xcdatamodel/contents   ← shipped, frozen forever
  Near 20.xcdatamodel/contents   ← the new one you edit
  .xccurrentversion              ← points at "Near 20.xcdatamodel"
```

Copy the current version's directory, rename it, edit the copy, and repoint `.xccurrentversion`. If the project is generated (XcodeGen, Tuist), **regenerate afterwards** — a new version directory that the project does not reference produces a build that silently keeps using the old model.

## What lightweight migration actually supports

Inferred (lightweight) migration handles a specific list. Anything outside it needs a mapping model or a manual pass:

**Supported** — adding an attribute (optional, or non-optional with a default); removing an attribute; removing an entity; adding an entity; changing an attribute from non-optional to optional; changing optional to non-optional *with* a default; adding or removing a relationship; changing a relationship's cardinality; toggling `allowsExternalBinaryDataStorage`.

**Not supported, or supported only with a rename identifier** — renaming an entity, attribute, or relationship; changing an attribute's type; merging or splitting entities; any transformation of the values themselves.

**Verify rather than assume.** The list above is a starting point, not an oracle — write the test in the last section and let the store tell you.

## Renaming: the `momc` trap

The documented way to rename is a **renaming identifier**: the destination property carries the *source* property's name, and inferred migration maps the column across.

**In a hand-edited `.xcdatamodel/contents`, this does not work.** `momc` silently strips `renamingIdentifier` from the XML. The compiled model reports the property's own name back as its renaming identifier, migration maps nothing, the column's bytes are dropped, and **no error is raised anywhere**. Confirmed against multiple spellings — with and without `syncable`, with and without other attribute flags — by reading `attribute.renamingIdentifier` at run time and by `strings`-ing the built `.mom`, where the old name is simply absent:

```swift
// After a migration that should have renamed `payload` -> `payloadBlob`:
let attr = entity.attributesByName["payloadBlob"]!
attr.renamingIdentifier   // "payloadBlob" — the default. The XML value is gone.
```

Note that `renamingIdentifier` is never `nil` — it defaults to the property's own name — so a naive "is it set?" check passes either way. Compare it to the *old* name.

If you set the renaming identifier through Xcode's model editor (Data Model Inspector → Renaming ID) it is written into the model the editor maintains, and you should still verify it survived compilation before relying on it.

**When agents edit model XML directly — the normal case in an agent-driven repo — treat renaming identifiers as unavailable.** Use the two-build sequence below instead.

## Retiring an attribute without losing its data

You cannot rename and delete in one shipped build if the bytes matter. Use two builds:

1. **Interim build.** Declare *both* the old and new attributes. Run a one-shot migration pass on first launch that copies old → new, gated on a `UserDefaults` flag, idempotent, and reading the legacy value through `value(forKey:)` rather than an `@NSManaged` property so nothing can start writing it again.
2. **Retiring build.** Delete the old attribute from the model. Delete the migration pass with it — with the column gone there is nothing to drain, and a fetch predicate referencing a retired key will crash.

The upgrade path is then `old → interim → retiring`, **in that order, and only lossless in that order**. A device jumping straight from `old` to `retiring` drops the column. Decide explicitly whether that matters: pre-launch with no real users, it usually does not, and saying so out loud is better than engineering around a case that does not exist.

**"Legacy, read-only" is not a retirement.** An attribute left in the model with a comment forbidding writes is still in the model, and anything that walks the entity — mirroring, export, encoding, a generic serialiser — still sees it. Assert the retired name is **absent from the compiled model**; a comment, a lint on write sites, or an allowlist in a test all pass while the attribute is still live. For CloudKit-mirrored entities this is not a style point but a correctness one: see `cloudkit-sync`, where a retired-but-present attribute permanently wedges the mirroring delegate.

## Verifying a migration before you ship it

**A test that uses an in-memory store cannot verify a migration.** It builds the current model from scratch, so it passes whether or not the migration works, and whether or not a retired attribute is still present. It sees neither failure.

Write the store on disk with the **older model, loaded by name out of the compiled `.momd`**, close it, reopen with the shipping model, and assert on what came through:

```swift
private func model(named name: String) throws -> NSManagedObjectModel {
    let bundle = Bundle(for: SomeClassInYourModule.self)
    let momd = try #require(bundle.url(forResource: "MyApp", withExtension: "momd"))
    return try #require(NSManagedObjectModel(contentsOf: momd.appendingPathComponent("\(name).mom")))
}

private func makeStore(at url: URL, model: NSManagedObjectModel) throws -> NSPersistentContainer {
    let container = NSPersistentContainer(name: "MyApp", managedObjectModel: model)
    let description = NSPersistentStoreDescription(url: url)
    description.shouldMigrateStoreAutomatically = true       // same as the shipping container
    description.shouldInferMappingModelAutomatically = true
    container.persistentStoreDescriptions = [description]
    var loadError: Error?
    container.loadPersistentStores { _, error in loadError = error }
    if let loadError { throw loadError }
    return container
}

@Test("a v19 store upgrades with its data intact and its retired columns dropped")
func storeUpgrades() throws {
    let storeURL = /* a fresh temp directory */
    let payload = Data(repeating: 0xAB, count: 4096)

    let old = try makeStore(at: storeURL, model: model(named: "MyApp 19"))
    let row = NSEntityDescription.insertNewObject(forEntityName: "Thing", into: old.viewContext)
    row.setValue(payload, forKey: "retiredName")
    row.setValue(payload, forKey: "newName")
    try old.viewContext.save()
    for store in old.persistentStoreCoordinator.persistentStores {
        try old.persistentStoreCoordinator.remove(store)   // must fully close before reopening
    }

    let upgraded = try makeStore(at: storeURL, model: currentModel)
    let upgradedRow = /* fetch it back */
    #expect(upgradedRow.value(forKey: "newName") as? Data == payload)
    #expect(upgradedRow.entity.attributesByName["retiredName"] == nil)
}
```

Reaching the assertions is itself the check that the change is a valid lightweight migration — an unsupported one throws out of `loadPersistentStores`.

Use realistic sizes. A 4 KB blob and a 3-byte blob can take different storage paths, and external binary storage only engages past a threshold.

## Checklist for any model change

- [ ] New `.xcdatamodel` version added; the shipped one untouched; `.xccurrentversion` repointed.
- [ ] Project regenerated if generated (XcodeGen/Tuist), so the build actually uses the new version.
- [ ] Every change on the lightweight-supported list, or a mapping model exists.
- [ ] No reliance on a hand-edited `renamingIdentifier`.
- [ ] Data-preserving retirements split across two builds, with the upgrade order written down.
- [ ] Retired names asserted **absent** from the compiled model.
- [ ] An on-disk older-version store test covers the upgrade, with realistic data sizes.
- [ ] If the model is CloudKit-mirrored, `cloudkit-sync`'s schema rules applied — deployed field types are immutable and additive-only.

## Debugging table

| Symptom | Likely cause | Fix |
|---|---|---|
| Store fails to load after an update, `NSMigrationError` / "Cannot merge models" | A change outside the lightweight-supported set, or a shipped version edited in place | Add a proper version; write a mapping model for the unsupported change |
| Attribute reads `nil` after upgrade, no error anywhere | A rename that did not map — `momc` dropped `renamingIdentifier` | Two-build sequence; verify with an on-disk older-version test |
| Migration works in tests, loses data on device | Tests use an in-memory store, which never migrates anything | Rewrite the test to write and reopen a real store on disk |
| New model version has no effect; old shape persists | Generated project not regenerated, so the new version is not in the build | Re-run the project generator; confirm the new `.mom` is in the built `.momd` |
| Retired attribute keeps appearing in exports/sync payloads | It is still declared in the model; "read-only" describes your code, not the wire | Delete it from the model and assert its absence |
| Data loss only for users who skipped a version | A two-build retirement jumped in one step | Expected; state the required upgrade order, or keep the interim attribute one release longer |
