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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
If you see ANY of these, suspect a Core Data misunderstanding, not framework breakage:
Critical distinction Simulator deletes the database on each rebuild, hiding schema mismatch issues. Real devices keep persistent databases and crash immediately on schema mismatch. MANDATORY: Test migrations on real device with real data before shipping.
ALWAYS run these FIRST (before changing code):
// 1. Identify the crash/issue type
// Screenshot the crash message and note:
// - "Unresolvable fault" = schema mismatch
// - "different thread" = thread-confinement
// - 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
// Check Console for SQL queries:
// SELECT * FROM USERS; (1 query)
// SELECT * FROM POSTS WHERE user_id = 1; (1 query per user = N+1!)
// Record: "N+1 found? Yes/no, how many extra queries"
// 5. Check SwiftData vs. Core Data confusion
if #available(iOS 17.0, *) {
// 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"
Before changing ANY code, identify ONE of these:
-com.apple.CoreData.SQLDebug 1 and count SQL queriesCore Data problem suspected?
├─ Crash: "Unresolvable fault"?
│ └─ YES → Schema mismatch (store ≠ app model)
│ ├─ Add new required field? → Pattern 1a (lightweight migration)
│ ├─ Remove field, rename, or change type? → Pattern 1b (heavy migration)
│ └─ Don't know how to fix? → Pattern 1c (testing safety)
│
├─ Crash: "different thread"?
│ └─ YES → Thread-confinement violated
│ ├─ Using DispatchQueue for background work? → Pattern 2a (async context)
│ ├─ Mixing Core Data with async/await? → Pattern 2b (structured concurrency)
│ └─ SwiftUI @FetchRequest causing issues? → Pattern 2c (@FetchRequest safety)
│
├─ Performance: App became slow?
│ └─ YES → Likely N+1 queries
│ ├─ Accessing user.posts in loop? → Pattern 3a (prefetching)
│ ├─ Large result set? → Pattern 3b (batch sizing)
│ └─ Just added relationships? → Pattern 3c (relationship tuning)
│
├─ 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 4c (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 5c (pre-deployment checklist)
PRINCIPLE Core Data can automatically migrate simple schemas (additive changes) without data loss if done correctly.
@NSManaged var nickname: String?// BAD: Adding required field without migration
@NSManaged var userID: String // Required, no default
// BAD: Assuming simulator = production
// Works in simulator (deletes DB), crashes on real device
// BAD: Modifying field type
@NSManaged var createdAt: Date // Was String, now Date
// Core Data can't automatically convert
// 1. In Xcode: Editor → Add Model Version
// Creates new .xcdatamodel version file
// 2. In new version, add required field WITH default:
@NSManaged var userID: String = UUID().uuidString
// 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
Time cost 5-10 minutes for lightweight migration setup
PRINCIPLE When lightweight migration won't work, use NSEntityMigrationPolicy for custom transformation logic.
// 1. Create mapping model
// File → New → Mapping Model
// Source: old version, Destination: new version
// 2. Create custom migration policy
class DateMigrationPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
let destination = NSEntityDescription.insertNewObject(
forEntityName: mapping.destinationEntityName ?? "",
into: manager.destinationContext
)
for key in sInstance.entity.attributesByName.keys {
destination.setValue(sInstance.value(forKey: key), forKey: key)
}
// Custom transformation: String → Date
if let dateString = sInstance.value(forKey: "createdAt") as? String,
let date = ISO8601DateFormatter().date(from: dateString) {
destination.setValue(date, forKey: "createdAt")
} else {
destination.setValue(Date(), forKey: "createdAt")
}
manager.associate(source: sInstance, withDestinationInstance: destination, for: mapping)
}
}
// 3. In mapping model Inspector:
// Set Custom Policy Class: DateMigrationPolicy
// 4. Test extensively with real data before shipping
Time cost 30-60 minutes per migration + testing
PRINCIPLE Core Data objects are thread-confined. Fetch on background thread, convert to lightweight representations for main thread.
DispatchQueue.global().async {
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
let results = try! context.fetch(request)
DispatchQueue.main.async {
self.objects = results // ❌ CRASH: objects faulted on background thread
}
}
// 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)")
}
}
Time cost 10 minutes to restructure
PRINCIPLE Use NSPersistentContainer or NSManagedObjectContext async methods for Swift Concurrency compatibility.
// iOS 13+: Use async perform
let users = try await viewContext.perform {
try viewContext.fetch(userRequest)
}
// Executes fetch on correct thread, returns to caller
// iOS 17+: Use Swift Concurrency async/await directly
let users = try await container.mainContext.fetch(userRequest)
// For background work:
let backgroundUsers = try await backgroundContext.perform {
try backgroundContext.fetch(userRequest)
}
// Fetch happens on background queue, thread-safe
async {
DispatchQueue.global().async {
try context.fetch(request) // ❌ Wrong thread!
}
}
Time cost 5 minutes to convert from DispatchQueue to async/await
PRINCIPLE Tell Core Data to fetch relationships eagerly instead of lazy-loading on access.
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
}
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
}
// Batch size: fetch in chunks for large result sets
request.fetchBatchSize = 100
// Faulting behavior: convert faults to lightweight snapshots
request.returnsObjectsAsFaults = false // Keep objects in memory
// Use carefully—can cause memory pressure with large results
// Distinct: remove duplicates from relationship fetches
request.returnsDistinctResults = true
Time cost 2-5 minutes to add prefetching
PRINCIPLE For large result sets, fetch in batches to manage memory.
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
Scenario You chose SwiftData, but need features it lacks.
// Keep SwiftData for simple entities
@Model final class Note {
var id: String
var title: String
}
// Drop to Core Data for complex operations
let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.parent = container.viewContext
// Fetch with Core Data, convert to SwiftData models
let results = try backgroundContext.perform {
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
Scenario You're in SwiftData and wondering if you need Core Data.
Time cost 0 minutes (decision only)
PRINCIPLE Never deploy a migration without testing against real data.
// 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
@Test func testProductionDataMigration() throws {
// Copy production database to test location
let testDB = tempDirectory.appendingPathComponent("test.sqlite")
try FileManager.default.copyItem(from: prodDatabase, to: testDB)
// Attempt migration
var config = ModelConfiguration(url: testDB, isStoredInMemory: false)
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
Time cost 15-30 minutes to create migration test
Time cost 5 minutes checklist
| Issue | Check | Fix |
|---|---|---|
| "Unresolvable fault" crash | Do store/model versions match? | Create .xcdatamodel version + mapping model |
| "Different thread" crash | Is fetch happening on main thread? | 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 |
| SwiftData needs Core Data features | Do you need custom migrations? | Use Core Data NSEntityMigrationPolicy |
| Not sure about SwiftData vs. Core Data | Do you need iOS 16 support? | Use Core Data for iOS 16, SwiftData for iOS 17+ |
| Migration test works, production fails | Did you test with real data? | Create migration test with production database copy |
If you've spent >30 minutes and the Core Data issue persists:
-com.apple.CoreData.SQLDebug 1)❌ Testing migration in simulator only
❌ Assuming default values protect against data loss
❌ Accessing Core Data objects across threads without conversion
❌ Not realizing relationship access = database query
user.posts triggers a fetch for EACH user (N+1)❌ Mixing SwiftData and Core Data on same store
❌ Deploying migrations without pre-deployment testing
❌ Rationalizing: "I'll just delete the data"
Under production crisis pressure, you'll face requests to:
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.
If you hear ANY of these during a production crisis, STOP and reference this skill:
"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)"
Show the PM/manager what happens:
"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)"
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"
// ❌ WRONG - Deletes all user data (CTO's request)
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
let storeURL = /* persistent store URL */
try? FileManager.default.removeItem(at: storeURL) // 500K users lose data
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)")
}
Time estimate 4 hours total (2 hours migration testing, 2 hours build/deploy)
Sometimes data loss is the only option. Accept if:
"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.
Before Core Data debugging 3-8 hours per issue
After 30 minutes to 2 hours with systematic diagnosis
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.
Last Updated: 2025-11-30 Status: TDD-tested with pressure scenarios Framework: Core Data (Foundation framework) Complements: SwiftData skill (understanding relationship to Core Data)