Core Data Patterns — Expert Decisions
Expert decision frameworks for Core Data choices. Claude knows NSPersistentContainer and fetch requests — this skill provides judgment calls for when Core Data fits and architecture trade-offs.
Decision Trees
Core Data vs Alternatives
What's your persistence need? ├─ Simple key-value storage │ └─ UserDefaults or @AppStorage │ Don't use Core Data for preferences │ ├─ Flat list of Codable objects │ └─ Is query complexity needed? │ ├─ NO → File-based (JSON/Plist) or SwiftData │ └─ YES → Core Data or SQLite │ ├─ Complex relationships + queries │ └─ How many objects? │ ├─ < 10,000 → SwiftData (simpler) or Core Data │ └─ > 10,000 → Core Data (more control) │ ├─ iCloud sync required │ └─ NSPersistentCloudKitContainer │ Built-in sync with Core Data │ └─ Cross-platform (non-Apple) └─ SQLite directly or Realm Core Data is Apple-only
The trap: Using Core Data for simple lists. If you don't need relationships, queries, or undo, consider simpler options like SwiftData or file storage.
Context Architecture
How many contexts do you need? ├─ Simple app, UI-only operations │ └─ viewContext only │ Single context for reads and small writes │ ├─ Background imports/exports │ └─ viewContext + newBackgroundContext() │ Background for writes, viewContext for UI │ ├─ Complex with multiple writers │ └─ Parent-child context hierarchy │ Rarely needed — adds complexity │ └─ Sync with server └─ Dedicated sync context performBackgroundTask for sync operations
Migration Strategy
What changed in your model? ├─ Added optional attribute │ └─ Lightweight migration (automatic) │ ├─ Renamed attribute/entity │ └─ Lightweight with mapping model hints │ Set renaming identifier in model │ ├─ Changed attribute type │ └─ Depends on conversion possibility │ Int → String: lightweight │ String → Date: may need custom │ ├─ Added required attribute (no default) │ └─ Custom migration required │ Or add default value to make lightweight │ └─ Complex schema restructuring └─ Staged migration Multiple model versions, migrate step by step
Merge Policy Selection
What happens on save conflicts? ├─ UI context always wins │ └─ NSMergeByPropertyObjectTrumpMergePolicy │ Most common for view context │ ├─ Store (persisted) always wins │ └─ NSMergeByPropertyStoreTrumpMergePolicy │ For background sync contexts │ ├─ Need custom resolution │ └─ Custom merge policy │ Complex — avoid if possible │ └─ Fail on conflict └─ NSErrorMergePolicy (default) Rarely want this
NEVER Do
Context Management
NEVER use viewContext for heavy operations:
// ❌ Blocks main thread during import func importUsers(_ data: [UserData]) { let context = persistenceController.container.viewContext for item in data { let user = User(context: context) user.name = item.name } try? context.save() // UI frozen! }
// ✅ Use background context func importUsers(_ data: [UserData]) async throws { try await persistenceController.container.performBackgroundTask { context in for item in data { let user = User(context: context) user.name = item.name } try context.save() } }
NEVER pass NSManagedObjects between contexts:
// ❌ Object belongs to different context — crash or undefined behavior let user = fetchUser(in: backgroundContext) viewContext.delete(user) // Wrong context!
// ✅ Re-fetch in target context using objectID let user = fetchUser(in: backgroundContext) let userInViewContext = viewContext.object(with: user.objectID) as! User viewContext.delete(userInViewContext)
NEVER access managed objects off their context's queue:
// ❌ Thread violation — data corruption possible let user = fetchUser(in: backgroundContext) DispatchQueue.main.async { print(user.name) // Accessing background object on main thread! }
// ✅ Use context.perform for thread-safe access backgroundContext.perform { let user = fetchUser(in: backgroundContext) let name = user.name DispatchQueue.main.async { print(name) // Safe — using local copy } }
Save Operations
NEVER ignore save errors:
// ❌ Silent data loss try? context.save()
// ✅ Handle errors properly do { try context.save() } catch { context.rollback() Logger.coreData.error("Save failed: (error)") throw error }
NEVER save after every single change:
// ❌ Performance disaster — disk I/O per object for item in largeDataset { let entity = Entity(context: context) entity.value = item try context.save() // 10,000 saves! }
// ✅ Batch changes, save once (or periodically) for (index, item) in largeDataset.enumerated() { let entity = Entity(context: context) entity.value = item
// Save every 1000 objects to manage memory
if index % 1000 == 0 {
try context.save()
context.reset() // Release memory
}
} try context.save() // Final batch
NEVER call save on context with no changes:
// ❌ Unnecessary disk I/O func periodicSave() { try? context.save() // No-op but still has overhead }
// ✅ Check for changes first func saveIfNeeded() throws { guard context.hasChanges else { return } try context.save() }
Fetch Optimization
NEVER fetch all objects when you need a count:
// ❌ Loads all objects into memory just to count let users = try context.fetch(User.fetchRequest()) let count = users.count // May fetch thousands!
// ✅ Use count fetch let request = User.fetchRequest() let count = try context.count(for: request)
NEVER fetch everything without limits:
// ❌ May load entire database let request = User.fetchRequest() let allUsers = try context.fetch(request)
// ✅ Set appropriate limits let request = User.fetchRequest() request.fetchLimit = 50 request.fetchBatchSize = 20 // Loads in batches
NEVER forget to prefetch relationships you'll access:
// ❌ N+1 problem — each post access triggers fault let request = User.fetchRequest() let users = try context.fetch(request) for user in users { print(user.posts.count) // Separate fetch per user! }
// ✅ Prefetch relationships let request = User.fetchRequest() request.relationshipKeyPathsForPrefetching = ["posts"] let users = try context.fetch(request)
Migration
NEVER assume lightweight migration will work:
// ❌ Crashes on incompatible changes container.loadPersistentStores { _, error in if let error = error { fatalError("Failed: (error)") // User data lost! } }
// ✅ Handle migration failure gracefully container.loadPersistentStores { description, error in if let error = error as NSError? { if error.code == NSMigrationMissingSourceModelError { // Offer data reset or crash gracefully Self.resetStore() } } }
Essential Patterns
Modern Persistence Controller
@MainActor final class PersistenceController { static let shared = PersistenceController() static let preview = PersistenceController(inMemory: true)
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
// Enable lightweight migration
let description = container.persistentStoreDescriptions.first
description?.shouldMigrateStoreAutomatically = true
description?.shouldInferMappingModelAutomatically = true
container.loadPersistentStores { _, error in
if let error = error {
// In production: log and handle gracefully
fatalError("Core Data load failed: \(error)")
}
}
// View context configuration
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.undoManager = nil // Disable if not needed
}
func saveViewContext() {
let context = container.viewContext
guard context.hasChanges else { return }
do {
try context.save()
} catch {
Logger.coreData.error("View context save failed: \(error)")
}
}
}
Background Import Pattern
extension PersistenceController { func importData<T: Decodable>( _ items: [T], transform: @escaping (T, NSManagedObjectContext) -> Void ) async throws { try await container.performBackgroundTask { context in context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
for (index, item) in items.enumerated() {
transform(item, context)
// Batch save to manage memory
if index > 0 && index % 500 == 0 {
try context.save()
context.reset()
}
}
if context.hasChanges {
try context.save()
}
}
}
}
// Usage try await persistenceController.importData(userDTOs) { dto, context in let user = User(context: context) user.id = dto.id user.name = dto.name }
Efficient Fetch with @FetchRequest
struct UserListView: View { // Basic fetch — automatically updates on changes @FetchRequest( sortDescriptors: [SortDescriptor(.name)], animation: .default ) private var users: FetchedResults<User>
var body: some View {
List(users) { user in
Text(user.name ?? "Unknown")
}
}
}
// Dynamic predicate fetch struct FilteredUserList: View { @FetchRequest private var users: FetchedResults<User>
init(searchText: String) {
_users = FetchRequest(
sortDescriptors: [SortDescriptor(\.name)],
predicate: searchText.isEmpty ? nil : NSPredicate(
format: "name CONTAINS[cd] %@", searchText
),
animation: .default
)
}
var body: some View {
List(users) { user in
Text(user.name ?? "")
}
}
}
Quick Reference
Core Data vs Alternatives
Need Solution
Simple preferences UserDefaults
Small Codable lists JSON file or SwiftData
Complex queries + relationships Core Data
iCloud sync NSPersistentCloudKitContainer
Cross-platform SQLite or Realm
Context Types
Context Use For Thread
viewContext UI reads, small writes Main
newBackgroundContext() Heavy writes, imports Background
performBackgroundTask One-off background work Background
Merge Policies
Policy Winner Use Case
ObjectTrump In-memory changes View context
StoreTrump Persisted data Sync context
ErrorMerge Neither (fails) Rarely wanted
Lightweight Migration Support
Change Automatic?
Add optional attribute ✅ Yes
Add attribute with default ✅ Yes
Remove attribute ✅ Yes
Rename (with identifier) ✅ Yes
Change type (compatible) ✅ Maybe
Add required (no default) ❌ No
Change relationship type ❌ No
Red Flags
Smell Problem Fix
viewContext for imports Main thread blocked Use background context
NSManagedObject across contexts Wrong thread access Re-fetch via objectID
try? context.save() Silent data loss Handle errors
Save per object in loop Disk I/O explosion Batch saves
fetch() for count Memory waste context.count(for:)
No fetchLimit Loads entire DB Set reasonable limits
Missing prefetch N+1 fetches relationshipKeyPathsForPrefetching