769,339 AI resources indexedWorking toward the world’s largest AI resource index.

axiom-core-data-diag

Use when debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core Data bridging, or testing migrations without data loss - systematic Core Data diagnostics with safety-first migration patterns

Installable skillMITBy CharlesWiltgenUpdated Oct 27, 2026
INTENDED FORClaude CodeCodexCursorChatGPTInstall in a listed agent.

instructions.md

---
name: axiom-core-data-diag
description: "Use when debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core Data bridging, or testing migrations without data loss - systematic Core Data diagnostics with safety-first migration patterns"
---


# Core Data Diagnostics & Migration

## Overview

Core Data issues manifest as production crashes from schema mismatches, mysterious concurrency errors, performance degradation under load, and data corruption from unsafe migrations. **Core principle** 85% of Core Data problems stem from misunderstanding thread-confinement, schema migration requirements, and relationship query patternsβ€”not Core Data defects.

## Red Flags β€” Suspect Core Data Issue

If you see ANY of these, suspect a Core Data misunderstanding, not framework breakage:
- 🚩 **#1 root cause** Edited the existing `.xcdatamodel` in place instead of adding a new model version. Any field added/renamed/retyped this way silently rewrites v1 of the model β€” the on-disk store now mismatches it, and existing devices fail with `NSCocoaErrorDomain 134100`, "The managed object model version used to open the persistent store is incompatible with the one that was used to create the store." Check first: is there more than one `.xcdatamodel` inside the `.xcdatamodeld` bundle?
- Crash on production launch: `NSCocoaErrorDomain 134100` after a schema change
- Crash message: `localizedDescription` reads "The managed object model version used to open the persistent store is incompatible with the one that was used to create the store", while the `CoreData: error:` log line for the same failure ends with "The model used to open the store is incompatible with the one used to create the store"
- Thread-confinement violation: off-queue object access β€” silent unless the process is launched with `-com.apple.CoreData.ConcurrencyDebug 1`, which traps in `_PFAssertSafeMultiThreadedAccess_`
- App suddenly slow after adding a User→Posts relationship
- SwiftData app needs complex features; considering mixing Core Data alongside
- Schema migration works in simulator but crashes on production
- ❌ **FORBIDDEN** "Core Data is broken, we need a different database"
  - Core Data handles trillions of records in production apps
  - Schema mismatches and thread errors are always developer code, not framework
  - Do not rationalize away the issueβ€”diagnose it

**Critical distinction** A simulator you reset (app deleted, device erased) starts from an empty store, so the old→new migration never runs. Real devices keep the existing store and crash immediately on schema mismatch. **MANDATORY: Test migrations on real device with real data before shipping.**

#### First fix to try β€” enable automatic lightweight migration

Most "incompatible store" crashes from additive changes are resolved by turning ON inferred migration (it is OFF by default on a raw coordinator). Always confirm this is set before authoring any custom migration:

```swift
// NSPersistentContainer (preferred)
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true

// Raw NSPersistentStoreCoordinator
try coordinator.addPersistentStore(
    ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL,
    options: [
        NSMigratePersistentStoresAutomaticallyOption: true,
        NSInferMappingModelAutomaticallyOption: true
    ]
)
```

This only works when the change is inferable (add optional, add required-with-default, remove, rename-with-renaming-identifier) AND a new model version exists. It can NEVER infer an attribute type change.

## Mandatory First Steps

**ALWAYS run these FIRST** (before changing code):

```swift
// 1. Identify the crash/issue type
// Screenshot the crash message and note:
//   - NSCocoaErrorDomain 134100 = schema mismatch
//   - off-queue object access = thread-confinement (traps only under
//     -com.apple.CoreData.ConcurrencyDebug 1)
//   - Slow performance = N+1 queries or fetch size issues
//   - Data corruption = unsafe migration
// Record: "Crash type: [exact message]"

// 2. Check if it's schema mismatch
// Compare these:
let coordinator = persistentStoreCoordinator
let model = coordinator.managedObjectModel
let store = coordinator.persistentStores.first

// Get actual store schema version:
do {
    let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
        ofType: NSSQLiteStoreType,
        at: storeURL,
        options: nil
    )
    print("Store version identifier: \(metadata[NSStoreModelVersionIdentifiersKey] ?? "unknown")")

    // Get app's current model version:
    print("App model version: \(model.versionIdentifiers)")

    // If different = schema mismatch
} catch {
    print("Schema check error: \(error)")
}
// Record: "Store version vs. app model: match or mismatch?"

// 3. Check thread-confinement for concurrency errors
// For any NSManagedObject access:
print("Main thread? \(Thread.isMainThread)")
print("Context concurrency type: \(context.concurrencyType.rawValue)")
print("Accessing from: \(Thread.current)")
// Record: "Thread mismatch? Yes/no"

// 4. Profile relationship access for N+1 problems
// In Xcode, run with arguments:
// -com.apple.CoreData.SQLDebug 1 -com.apple.CoreData.ConcurrencyDebug 1
// Check Console for SQL queries (tables are Z-prefixed, columns named explicitly):
//   CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZNAME FROM ZUSER t0  (1 query)
//   CoreData: sql: SELECT 0, t0.Z_PK FROM ZPOST t0 WHERE t0.ZAUTHOR = ?  (1 per user = N+1!)
// Record: "N+1 found? Yes/no, how many extra queries"

// 5. Check SwiftData vs. Core Data confusion
// If using SwiftData @Model + Core Data simultaneously:
// Error: "Store is locked" or "EXC_BAD_ACCESS"
// = trying to access same database from both layers
print("Using both SwiftData and Core Data on same store?")
// Record: "Mixing SwiftData + Core Data? Yes/no"
```

#### What this tells you
- **Schema mismatch** β†’ Proceed to Pattern 1 (lightweight migration decision)
- **Thread-confinement error** β†’ Proceed to Pattern 2 (async/await concurrency)
- **N+1 queries** β†’ Proceed to Pattern 3 (relationship prefetching)
- **SwiftData + Core Data conflict** β†’ Proceed to Pattern 4 (bridging)
- **Slow after migration** β†’ Proceed to Pattern 5 (testing safety)

#### MANDATORY INTERPRETATION

Before changing ANY code, identify ONE of these:

1. If the error is `NSCocoaErrorDomain 134100` AND store/model versions differ β†’ Schema mismatch (not user error)
2. If objects are touched off their context's queue (trap it with `-com.apple.CoreData.ConcurrencyDebug 1`) β†’ Thread-confinement (not thread-safe design)
3. If performance degrades with relationship access β†’ N+1 queries (check SQL log)
4. If SwiftData and Core Data code exist together β†’ Conflicting data layers (architectural issue)
5. If migration test passes but production fails β†’ Edge case in real data (testing gap)

#### If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add print statements to every NSManagedObject access (thread check)
- Add `-com.apple.CoreData.SQLDebug 1` and count SQL queries; add `-com.apple.CoreData.ConcurrencyDebug 1` to trap off-queue object access
- Establish baseline: what's actually happening vs. what you assumed

## Decision Tree

```
Core Data problem suspected?
β”œβ”€ Crash: NSCocoaErrorDomain 134100 / "incompatible store"?
β”‚  └─ YES β†’ Schema mismatch (store β‰  app model)
β”‚     β”œβ”€ Edited the .xcdatamodel in place? β†’ Add a NEW model version first
β”‚     β”œβ”€ Add new required field / remove / rename? β†’ Pattern 1a (lightweight migration)
β”‚     β”œβ”€ Change an attribute type? β†’ Pattern 1b (rename β†’ add β†’ backfill recipe)
β”‚     └─ Don't know how to fix? β†’ See "When You're Stuck After 30 Minutes"
β”‚
β”œβ”€ Crash: object accessed off its context's queue?
β”‚  └─ YES β†’ Thread-confinement violated
β”‚     β”œβ”€ Using DispatchQueue for background work? β†’ Pattern 2a (async context)
β”‚     └─ Mixing Core Data with async/await? β†’ Pattern 2b (structured concurrency)
β”‚
β”œβ”€ Performance: App became slow?
β”‚  └─ YES β†’ Likely N+1 queries
β”‚     β”œβ”€ Accessing user.posts in loop? β†’ Pattern 3a (prefetching)
β”‚     └─ Large result set? β†’ Pattern 3b (batch sizing)
β”‚
β”œβ”€ Using both SwiftData and Core Data?
β”‚  └─ YES β†’ Data layer conflict
β”‚     β”œβ”€ Need Core Data features SwiftData lacks? β†’ Pattern 4a (drop to Core Data)
β”‚     β”œβ”€ Already committed to SwiftData? β†’ Pattern 4b (stay in SwiftData)
β”‚     └─ Unsure which to use? β†’ Pattern 4b (decision framework)
β”‚
└─ Migration works locally but crashes in production?
   └─ YES β†’ Testing gap
      β”œβ”€ Didn't test with real data? β†’ Pattern 5a (production testing)
      β”œβ”€ Schema change affects large dataset? β†’ Pattern 5b (migration safety)
      └─ Need verification before shipping? β†’ Pattern 5b (pre-deployment checklist)
```

## Common Patterns

### Pattern Selection Rules (MANDATORY)

#### Apply ONE pattern at a time, starting with diagnostics

1. **Always start with Mandatory First Steps** β€” Identify the actual problem
2. **Run decision tree** β€” Narrow to specific pattern
3. **Apply ONE pattern** β€” Don't combine patterns
4. **Test on real device** β€” Simulator hides issues
5. **Verify with migration test** β€” Before deploying

#### FORBIDDEN
- ❌ Changing code without diagnostics
- ❌ Skipping real device testing
- ❌ Using simulator success as proof of migration safety
- ❌ Mixing multiple migration patterns
- ❌ Deploying migrations without pre-deployment verification

---

### Pattern 1a: Lightweight Migration (Simple Schema Changes)

**PRINCIPLE** Core Data can automatically migrate simple schemas (additive changes) without data loss if done correctly.

#### βœ… SAFE Lightweight Migrations
- Adding new optional field: `@NSManaged var nickname: String?`
- Adding new required field WITH default: Create attribute with default value
- Renaming entity or attribute: set the renaming identifier in the new model version (still lightweight)
- Removing unused field: Just delete from model (data stays on disk, ignored)

#### ❌ WRONG (Crashes production)
```swift
// BAD: Adding required field without migration
@NSManaged var userID: String  // Required, no default

// BAD: Assuming simulator = production
// Works in simulator (fresh store, nothing to migrate), fails on real device

// BAD: Modifying field type
@NSManaged var createdAt: Date  // Was String, now Date
// Core Data can't automatically convert
```

#### βœ… CORRECT (Safe lightweight migration)
```swift
// 1. In Xcode: Editor β†’ Add Model Version
// Creates new .xcdatamodel version file

// 2. In the new version, add the required attribute and set its Default Value
//    in the data-model inspector (the model supplies it, not Swift):
@NSManaged var userID: String

// 3. Mark as current model version:
// File Inspector β†’ Versioned Core Data Model
// Check "Current Model Version"

// 4. Test:
// Simulate old version: delete app, copy old database, run with new code
// Real app loads β†’ migration succeeded

// 5. Deploy when confident
```

An `@NSManaged` property cannot carry an initial value β€” the model backs it, not Swift storage. What makes an added required attribute inferable is its Default Value in the data-model inspector, and that has to be a single constant, so a per-instance `UUID()` cannot be one; generate per-row values in a backfill instead (Pattern 1b). With no model default the migration fails with `NSCocoaErrorDomain 134110`, "Validation error missing attribute values on mandatory destination attribute".

#### When this works
- Adding optional fields (always safe)
- Adding required fields WITH default values
- Removing fields
- Renaming entities/attributes with a renaming identifier

#### When this FAILS (don't try lightweight)
- Changing field type (String β†’ Int)
- Making optional field required (data has nulls, can't convert)
- Complex relationship changes
- Custom data transformations needed

**Time cost** 5-10 minutes for lightweight migration setup

---

### Pattern 1b: Custom Migration (Complex Schema Changes)

**PRINCIPLE** When lightweight migration can't infer the change, use staged migration (iOS 17+) for custom transformation logic.

#### Use when
- Changing field types (String β†’ Date) β€” NEVER inferable, always custom
- Making optional required (need to populate existing nulls)
- Complex relationship restructuring
- Custom data transformations (e.g., split "firstName lastName" into separate fields)

#### Modern path (iOS 17+) β€” NSStagedMigrationManager

`NSStagedMigrationManager` replaces hand-authored mapping models. Express each hop between model versions as a stage: `NSLightweightMigrationStage` for inferable changes, `NSCustomMigrationStage` for code-driven transforms. Stages chain across multiple versions automatically and run inside a single transaction.

```swift
// Take each checksum from a compiled, non-editable model β€” reading
// `versionChecksum` on a model that is still editable logs "unstable version
// checksum" and the value is not guaranteed
let momd = Bundle.main.url(forResource: "Model", withExtension: "momd")!
let modelV1 = NSManagedObjectModel(contentsOf: momd.appendingPathComponent("Model.mom"))!
let modelV2 = NSManagedObjectModel(contentsOf: momd.appendingPathComponent("Model 2.mom"))!

let v1 = NSManagedObjectModelReference(model: modelV1, versionChecksum: modelV1.versionChecksum)
let v2 = NSManagedObjectModelReference(model: modelV2, versionChecksum: modelV2.versionChecksum)

let stage = NSCustomMigrationStage(migratingFrom: v1, to: v2)
stage.didMigrateHandler = { migrationManager, _ in
    // The handler receives the migration manager, not a context β€” the migrating
    // store is reached through its container
    guard let context = migrationManager.container?.viewContext else { return }
    // Backfill the new typed attribute from the renamed legacy one (see recipe below)
    let users = try context.fetch(NSFetchRequest<NSManagedObject>(entityName: "User"))
    let parser = ISO8601DateFormatter()
    for user in users {
        let legacy = user.value(forKey: "createdAt_str") as? String
        user.setValue(legacy.flatMap(parser.date(from:)) ?? Date(), forKey: "createdAt")
    }
    try context.save()
}

let manager = NSStagedMigrationManager([stage])
let description = container.persistentStoreDescriptions.first!
description.setOption(manager, forKey: NSPersistentStoreStagedMigrationManagerOptionKey)
```

#### Recipe: attribute type change (rename β†’ add β†’ backfill)

An attribute type change can NEVER be inferred and NEVER edited in place β€” Core Data has no way to reinterpret existing bytes (e.g. a `String` column as a `Date`). Always do this three-step recipe in a NEW model version:

1. **Rename** the old attribute (e.g. `createdAt` β†’ `createdAt_str`, keep its original `String` type). Use a renaming identifier so Core Data maps the column, not drops it.
2. **Add** the new attribute with the target type (`createdAt: Date`), optional or with a default.
3. **Backfill** in code β€” in a `NSCustomMigrationStage.didMigrateHandler` (iOS 17+) or a guarded one-shot on first launch:

```swift
// Guarded one-shot fallback (works on any iOS version)
func backfillCreatedAtOnce(_ context: NSManagedObjectContext) throws {
    let key = "didBackfillCreatedAt_v2"
    guard !UserDefaults.standard.bool(forKey: key) else { return }
    let parser = ISO8601DateFormatter()
    let users = try context.fetch(NSFetchRequest<NSManagedObject>(entityName: "User"))
    for user in users where user.value(forKey: "createdAt") == nil {
        let legacy = user.value(forKey: "createdAt_str") as? String
        user.setValue(legacy.flatMap(parser.date(from:)) ?? Date(), forKey: "createdAt")
    }
    try context.save()
    UserDefaults.standard.set(true, forKey: key)
}
```

The `UserDefaults` guard makes the backfill idempotent β€” without it, a relaunch re-runs the loop over already-migrated rows. Drop `createdAt_str` only in a LATER release, once every user has run the backfill.

#### Critical safety rules
- ALWAYS backup database before testing migration
- Test migration on COPY of production data
- Verify data integrity after migration (spot checks)
- Create rollback plan if migration fails

**Time cost** 30-60 minutes per migration + testing

---

### Pattern 2a: Async Context for Background Fetching

**PRINCIPLE** Core Data objects are thread-confined. Fetch on background thread, convert to lightweight representations for main thread.

#### ❌ WRONG (Thread-confinement violation)
```swift
DispatchQueue.global().async {
    let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
    let results = try! context.fetch(request)

    DispatchQueue.main.async {
        self.objects = results  // ❌ Off-queue access: no exception, no log
    }
}
```

This is silent by default β€” it compiles under Swift 5 and usually returns a row rather than crashing. Swift 6 rejects only the object-send part, because `DispatchQueue.main.async` closures are main-actor inferred; the off-queue context access itself stays unguarded. Add `-com.apple.CoreData.ConcurrencyDebug 1` to the scheme's launch arguments and that access traps immediately in `_PFAssertSafeMultiThreadedAccess_`.

#### βœ… CORRECT (Use private queue context for background work)
```swift
// Create background context
let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.parent = viewContext

// Fetch on background thread
backgroundContext.perform {
    do {
        let results = try backgroundContext.fetch(userRequest)

        // Convert to lightweight representation BEFORE main thread
        let userIDs = results.map { $0.id }  // Just the IDs, not full objects

        DispatchQueue.main.async {
            // On main thread, fetch full objects from main context
            let mainResults = try self.viewContext.fetch(request)
            self.objects = mainResults
        }
    } catch {
        print("Fetch error: \(error)")
    }
}
```

#### Why this works
- Background context fetches on background thread (safe)
- Converts heavy objects to lightweight values (safe to pass to main)
- Main context fetches on main thread (safe)
- No thread-confined objects crossing thread boundaries

**Time cost** 10 minutes to restructure

---

### Pattern 2b: Structured Concurrency (async/await with Core Data)

**PRINCIPLE** Use NSPersistentContainer or NSManagedObjectContext async methods for Swift Concurrency compatibility.

#### βœ… CORRECT (async APIs, iOS 15+)
```swift
// iOS 15+: the value-returning overload of `perform` is the async one
let users = try await viewContext.perform {
    try viewContext.fetch(userRequest)
}
// Executes fetch on correct thread, returns to caller

// On the main actor you are already on the view context's queue, so the
// synchronous fetch is safe here β€” there is no async `fetch` to await
let directUsers = try container.viewContext.fetch(userRequest)

// For background work:
let backgroundUsers = try await backgroundContext.perform {
    try backgroundContext.fetch(userRequest)
}
// Fetch happens on background queue, thread-safe
```

#### ❌ WRONG (Mixing Swift Concurrency with DispatchQueue)
```swift
async {
    DispatchQueue.global().async {
        try context.fetch(request)  // ❌ Wrong thread!
    }
}
```

**Time cost** 5 minutes to convert from DispatchQueue to async/await

---

### Pattern 3a: Relationship Prefetching (Prevent N+1)

**PRINCIPLE** Tell Core Data to fetch relationships eagerly instead of lazy-loading on access.

#### ❌ WRONG (N+1 query pattern)
```swift
let users = try context.fetch(userRequest)

for user in users {
    let posts = user.posts  // ❌ Triggers fetch for EACH user!
    // 1 fetch for users + N fetches for relationships = N+1 total
}
```

#### βœ… CORRECT (Prefetch relationships)
```swift
var request = NSFetchRequest<User>(entityName: "User")

// Tell Core Data to fetch relationships eagerly
request.relationshipKeyPathsForPrefetching = ["posts", "comments"]
// Now relationships are fetched in a single query per relationship

let users = try context.fetch(request)

for user in users {
    let posts = user.posts  // βœ… INSTANT: Already fetched
    // Total: 1 fetch for users + 1 fetch for all posts = 2 queries
}
```

#### Other optimization patterns
```swift
// Batch size: fetch in chunks for large result sets
request.fetchBatchSize = 100

// Faulting: default true returns faults (cheapest); false pre-populates every
// property up front β€” only worth it when the loop touches every field
request.returnsObjectsAsFaults = false
```

**Time cost** 2-5 minutes to add prefetching

---

### Pattern 3b: Fetch Batch Sizing

**PRINCIPLE** For large result sets, fetch in batches to manage memory.

#### Example: Scrolling through 100,000 users

```swift
var request = NSFetchRequest<User>(entityName: "User")
request.fetchBatchSize = 100  // Fetch 100 at a time

// Set sort descriptor for stable pagination
request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)]

let results = try context.fetch(request)
// Memory footprint: ~100 users at a time, not all 100,000

for user in results {
    // Accessing user 0-99: in memory
    // Accessing user 100: batch refetch (user 100-199)
    // Auto-pagination, minimal memory usage
}
```

**Time cost** 3 minutes to tune batch size

---

### Pattern 4a: Core Data Features SwiftData Doesn't Have

**Scenario** You chose SwiftData, but need features it lacks.

#### SwiftData lacks
- Complex migrations (auto-migration only)
- Custom validation (before save)
- Direct SQL queries
- Advanced prefetching
- Faulting control

#### When to drop to Core Data from SwiftData
- Need custom migrations
- Need validation logic
- Need complex relationship rules
- Need raw SQL for performance
- Need fault tolerance patterns

#### βœ… CORRECT (Hybrid approach when necessary)
```swift
// Keep SwiftData for simple entities
@Model final class Note {
    var id: String
    var title: String

    init(id: String, title: String) {
        self.id = id
        self.title = title
    }
}

// Drop to Core Data for complex operations
let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.parent = container.viewContext

// Fetch with Core Data, convert to SwiftData models
// `performAndWait` is the value-returning synchronous call; `perform` returns Void
let results = try backgroundContext.performAndWait {
    try backgroundContext.fetch(coreDataRequest)
}
```

**CRITICAL** Do NOT access the same entity from both SwiftData and Core Data simultaneously. One or the other, not both.

**Time cost** 30-60 minutes to create bridging layer

---

### Pattern 4b: Stay in SwiftData (Recommended for New Projects)

**Scenario** You're in SwiftData and wondering if you need Core Data.

#### SwiftData provides 80% of Core Data functionality for modern apps
- Type-safe models (@Model)
- Reactive queries (@Query)
- CloudKit sync (built-in)
- Automatic migrations (for simple changes)
- Proper async/await integration

#### When SwiftData is sufficient
- Simple schemas (users, notes, todos)
- Minimal relationship complexity
- CloudKit sync needed
- No legacy Core Data code to maintain

#### Decision: Stay in SwiftData if you can answer YES to 3+ of these
- βœ… Simple relationships (1-to-many, not many-to-many)
- βœ… Standard migrations (add fields, remove fields)
- βœ… CloudKit sync beneficial
- βœ… Type safety important

#### Decision: Drop to Core Data if
- ❌ Complex relationship rules (cascade rules, constraints)
- ❌ Custom migrations required
- ❌ Raw SQL needed for performance
- ❌ Already have Core Data codebase

**Time cost** 0 minutes (decision only)

---

### Pattern 5a: Safe Production Testing Before Migration

**PRINCIPLE** Never deploy a migration without testing against real data.

#### MANDATORY Pre-Deployment Checklist

```swift
// Step 1: Export production database
// From running app in simulator or real device:
// ~/Library/Developer/CoreData/[AppName]/
// Copy entire [AppName].sqlite database

// Step 2: Create migration test
@MainActor @Test func testProductionDataMigration() throws {
    // Copy production database to test location
    let testDB = tempDirectory.appendingPathComponent("test.sqlite")
    try FileManager.default.copyItem(at: prodDatabase, to: testDB)

    // Attempt migration
    let config = ModelConfiguration(url: testDB)
    let container = try ModelContainer(for: User.self, configurations: config)

    // Verify data integrity
    let context = container.mainContext
    let allUsers = try context.fetch(FetchDescriptor<User>())

    // Spot checks: verify specific records migrated correctly
    guard let user1 = allUsers.first(where: { $0.id == "test-id-1" }) else {
        throw MigrationError.missingUser
    }

    // Check derived data is correct
    XCTAssertEqual(user1.name, "Expected Name")
    XCTAssertNotNil(user1.createdAt)

    // Check relationships
    XCTAssertEqual(user1.posts.count, expectedPostCount)
}

// Step 3: Run test against real production data
// Pass βœ“ before shipping
```

#### Safety rules
- ❌ NEVER test migrations with a simulator that starts from an empty store (the upgrade path never runs)
- βœ… ALWAYS test with copy of real production data
- βœ… ALWAYS verify spot checks (specific records)
- βœ… ALWAYS check relationships loaded correctly
- βœ… ALWAYS have rollback plan documented

**Time cost** 15-30 minutes to create migration test

---

### Pattern 5b: Pre-Deployment Verification Checklist

#### MANDATORY before shipping ANY Core Data change

- [ ] Did you create a new .xcdatamodel version? (Not just editing the existing one)
- [ ] Does the new version have a mapping model if needed?
- [ ] Did you test migration with real production data? (Not simulator)
- [ ] Did you verify 5+ specific records migrated correctly?
- [ ] Did you check relationships loaded?
- [ ] Did you test on real device (oldest supported)?
- [ ] Does app launch without crashing? (Fresh install)
- [ ] Does app launch with old data? (Migration path)
- [ ] Is rollback plan documented? (In case production fails)

#### If you answer NO to any item
- ❌ DO NOT SHIP
- Go back, fix the issue, re-test
- One "NO" = data loss risk

**Time cost** 5 minutes checklist

---

## Quick Reference Table

| Issue | Check | Fix |
|-------|-------|-----|
| "Incompatible store" crash | Is there >1 `.xcdatamodel` in the bundle? | Add a NEW model version; never edit the existing one in place |
| `NSCocoaErrorDomain 134100` | Do store/model versions match? | Enable shouldInfer/MigrateStoreAutomatically; add a model version |
| Changed an attribute's type | Can it be inferred? (No β€” never) | Rename old β†’ add new typed attribute β†’ in-code backfill |
| Off-queue object access (silent unless `-com.apple.CoreData.ConcurrencyDebug 1`) | Is the fetch happening on the context's queue? | Use private queue context for background work |
| App became slow | Are relationships being prefetched? | Add relationshipKeyPathsForPrefetching |
| N+1 query performance | Check `-com.apple.CoreData.SQLDebug 1` logs | Add prefetching or convert to lightweight representation |
| Custom migration needed | Can the change be inferred? | Use NSStagedMigrationManager with an NSCustomMigrationStage |
| Migration test works, production fails | Did you test with real data? | Create migration test with production database copy |

---

## When You're Stuck After 30 Minutes

If you've spent >30 minutes and the Core Data issue persists:

#### STOP. You either
1. Skipped mandatory diagnostics (most common)
2. Misidentified the actual problem
3. Applied wrong pattern for your symptom
4. Haven't tested on real device/real data
5. Have edge case requiring a custom migration stage

#### MANDATORY checklist before claiming "skill didn't work"

- [ ] I ran all Mandatory First Steps diagnostics
- [ ] I identified the problem type (schema, concurrency, performance, bridging, testing)
- [ ] I checked Core Data SQL debug logs (`-com.apple.CoreData.SQLDebug 1`), and enabled `-com.apple.CoreData.ConcurrencyDebug 1` before blaming threads
- [ ] I tested on real device with real data (not simulator)
- [ ] I applied the FIRST matching pattern from Decision Tree
- [ ] I created a migration test if schema changed
- [ ] I verified at least 3 specific records migrated correctly
- [ ] I have a rollback plan documented

#### If ALL boxes are checked and still broken
- You need a custom migration stage (`NSCustomMigrationStage`), not covered by basic patterns
- Time cost: 60-90 minutes for complex migration
- Ask: "What data transformation is actually needed?" and implement it in the stage's handlers

#### Time cost transparency
- Pattern 1 (lightweight migration): 5-10 minutes
- Pattern 1 (heavy migration with custom policy): 60-90 minutes
- Pattern 2 (concurrency): 5-10 minutes
- Pattern 3 (prefetching): 2-5 minutes
- Pattern 4 (bridging): 30-60 minutes
- Pattern 5 (testing): 15-30 minutes

---

## Common Mistakes

❌ **Testing migration in simulator only**
- A reset simulator starts from an empty store, hiding schema mismatches
- Fix: ALWAYS test on real device or with production database copy

❌ **Assuming default values protect against data loss**
- Default values only work for new records, not existing data
- Fix: Backfill existing rows via a staged migration (iOS 17+) or one-shot β€” see Pattern 1b

❌ **Accessing Core Data objects across threads without conversion**
- Objects are thread-confined, can't cross thread boundaries
- Fix: Convert to lightweight representations before passing to other threads

❌ **Not realizing relationship access = database query**
- `user.posts` triggers a fetch for EACH user (N+1)
- Fix: Use relationshipKeyPathsForPrefetching or extract IDs first

❌ **Mixing SwiftData and Core Data on same store**
- Both layers can't access the same database simultaneously
- Fix: Choose one layer, or use hybrid approach with separate entities

❌ **Deploying migrations without pre-deployment testing**
- Edge cases in production data cause crashes
- Fix: MANDATORY migration test with real production data

❌ **Rationalizing: "I'll just delete the data"**
- ❌ FORBIDDEN: Users won't appreciate losing their data
- Users uninstall and leave bad reviews
- Fix: Invest in safe migration testing

---

## Production Crisis Pressure: Defending Safe Migration Patterns

### The Problem

Under production crisis pressure, you'll face requests to:
- "Users are crashing - just delete the database and start fresh"
- "Migration is taking too long - skip the testing and ship it"
- "We can't wait 2 days for proper migration - hack it together"
- "Schema mismatch? Just force-create a new store"

These sound like pragmatic crisis responses. **But they cause data loss and permanent user trust damage.** Your job: defend using data safety principles and customer impact, not fear of pressure.

### Red Flags β€” PM/Manager Requests That Cause Data Loss

If you hear ANY of these during a production crisis, **STOP and reference this skill**:

- ❌ **"Delete the persistent store and start fresh"** – Users lose ALL their data permanently
- ❌ **"Force lightweight migration without testing"** – High risk of data corruption in production
- ❌ **"Skip migration and create new store"** – Abandons existing user data
- ❌ **"We'll fix data issues after launch"** – Impossible to recover lost/corrupted data
- ❌ **"Just ship it, we can handle support tickets"** – Data loss creates permanent user churn
- ❌ **"Test on simulator is enough"** – A simulator that was never given the old store starts empty, so the migration never runs

### How to Push Back Professionally

#### Step 1: Quantify the Customer Impact

```
"I want to resolve this crash ASAP, but let me show you what deleting the store means:

Current situation:
- 10,000 active users with data
- Average 50 items per user (500,000 total records)
- Users have 1 week to 2 years of accumulated data

If we delete the store:
- 10,000 users lose ALL their data on next app launch
- Uninstall rate: 60-80% (industry standard after data loss)
- App Store reviews: Expect 1-star reviews citing data loss
- Recovery: Impossible - data is gone permanently

Safe alternative:
- Test migration on real device with production data copy (2-4 hours)
- Deploy migration that preserves user data
- Uninstall rate: <5% (standard update churn)"
```

#### Step 2: Demonstrate the Risk

Show the PM/manager what happens:
1. Copy production database from device backup
2. Run proposed "quick fix" (delete store)
3. Show: All user data gone permanently
4. Show alternative: Safe migration preserving data
5. Time comparison: 30 min hack vs. 2-4 hour safe migration

#### Reference
- "Users don't forgive data loss" (App Store review patterns)
- Migration testing on real device prevents 95% of production crashes
- Schema mismatch crashes affect 100% of existing users

#### Step 3: Offer Compromise

```
"I can get us through this crisis while protecting user data:

#### Fast track (4 hours total)
1. Copy production database from TestFlight user (30 min)
2. Write and test migration on real device copy (2 hours)
3. Submit build with tested migration (30 min)
4. Monitor first 100 updates for crashes (1 hour)

#### Fallback if migration fails
- Have "delete store" build ready as Plan B
- Only deploy if migration shows 100% failure rate
- Communicate data loss to users proactively

This approach:
- Tries safe path first (protects user data)
- Has emergency fallback (if migration impossible)
- Honest timeline (4 hours vs. "just delete it" 30 min)"
```

#### Step 4: Document the Decision

If overruled (PM insists on deleting store):

```
Slack message to PM + team:

"Production crisis: Schema mismatch causing crashes for existing users.

PM decision: Delete persistent store to resolve immediately.

Impact assessment:
- 10,000 users lose ALL data permanently on next app launch
- Expected uninstall rate: 60-80% based on data loss patterns
- App Store review damage: High risk of 1-star reviews
- Customer support: Expect high volume of data loss complaints
- Recovery: Impossible - deleted data cannot be recovered

Alternative proposed (4-hour safe migration) was declined due to urgency.

I'm flagging this decision proactively so we can:
1. Prepare support team for data loss complaints
2. Draft App Store response to expected negative reviews
3. Consider user communication about data loss before launch"
```

#### Why this works
- You're not questioning their judgment under pressure
- You're quantifying user impact (business consequences)
- You're offering a solution with honest timeline
- You're providing fallback option (not blocking progress)
- You're documenting the decision (protects you post-launch)

### Real-World Example: Production Crash (500K Active Users)

#### Scenario
- Production app crashing for 100% of users after update
- Error: `NSCocoaErrorDomain 134100` ("The managed object model version used to open the persistent store is incompatible with the one that was used to create the store")
- CTO says: "Delete the database and ship hotfix in 2 hours"
- 500,000 active users with average 6 months of data each

#### What to do

```swift
// ❌ WRONG - Deletes all user data (CTO's request)
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
let storeURL = URL(fileURLWithPath: "/path/to/App.sqlite")  // your store URL
// destroyPersistentStore leaves a usable empty store; deleting just the .sqlite
// file leaves the -wal/-shm behind and the next add fails (I/O error 522)
try? coordinator.destroyPersistentStore(at: storeURL, type: .sqlite, options: nil)
try! coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
                                    configurationName: nil,
                                    at: storeURL,
                                    options: nil)

// βœ… CORRECT - Safe lightweight migration (4-hour timeline)
let options = [
    NSMigratePersistentStoresAutomaticallyOption: true,
    NSInferMappingModelAutomaticallyOption: true
]

do {
    try coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
                                       configurationName: nil,
                                       at: storeURL,
                                       options: options)
    // Migration succeeded - user data preserved
} catch {
    // Migration failed β€” NOW consider deleting with user communication
    print("Migration error: \(error)")
}
```

#### In the meeting, show
1. Schema version mismatch causing crash
2. Lightweight migration can fix automatically
3. Testing on production database copy (2 hours)
4. Time comparison: 2 hours (safe) vs. immediate (data loss)

**Time estimate** 4 hours total (2 hours migration testing, 2 hours build/deploy)

#### Result
- Honest timeline manages expectations
- Safe migration preserves 500K users' data
- Uninstall rate: 3% (standard update churn)
- App Store reviews: No data loss complaints

#### Alternative if migration truly impossible
- Document why migration failed
- Communicate data loss to users proactively
- Provide export feature in next version

### When to Accept Data Loss (Even If You Disagree)

Sometimes data loss is the only option. Accept if:

- [ ] Migration is genuinely impossible (tried on production data copy)
- [ ] PM/CTO understand 60-80% expected uninstall rate
- [ ] Team commits to user communication about data loss
- [ ] You've documented technical reasons migration failed

#### Document in Slack

```
"Production crisis: Migration failed on production data copy after 4-hour testing.

Technical details:
- Attempted lightweight migration: Failed with [error]
- Attempted heavy migration with mapping model: Failed with [error]
- Root cause: [specific schema incompatibility]

Data loss decision:
- No safe migration path exists
- PM approved delete persistent store approach
- Expected impact: 60-80% uninstall rate (500K β†’ 100-200K users)

Mitigation plan:
- Add data export feature before next schema change
- Communicate data loss to users via in-app message
- Prepare support team for complaints
- Monitor uninstall rates post-launch"
```

This protects you and shows you exhausted safe options first.

---

## Real-World Impact

**Before** Core Data debugging 3-8 hours per issue
- Crashes in production (schema mismatch)
- Performance gradually degrades (N+1 queries)
- Thread errors in background operations
- Data corruption from unsafe migrations
- Customer trust damaged

**After** 30 minutes to 2 hours with systematic diagnosis
- Identify problem type with diagnostics (5 min)
- Apply correct pattern (5-10 min)
- Test on real device (varies)
- Deploy with confidence

**Key insight** Core Data has well-established patterns for every common issue. The problem is developers don't know which pattern applies to their symptom.

---

**Framework**: Core Data (Foundation framework)
**Complements**: SwiftData skill (understanding relationship to Core Data)

Platform guidance

Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.

Resource notes updated 2026-10-27 Β· Original instructions by CharlesWiltgen