home / skills / kaakati / rails-enterprise-dev / core-data-patterns
This skill helps you decide when to use Core Data versus alternatives, optimize context architecture, and plan migrations for iOS data layers.
npx playbooks add skill kaakati/rails-enterprise-dev --skill core-data-patternsReview the files below or copy the command above to add this skill to your agents.
---
name: core-data-patterns
description: "Expert Core Data decisions for iOS/tvOS: when Core Data vs alternatives, context architecture for multi-threading, migration strategy selection, and performance optimization trade-offs. Use when choosing persistence layer, debugging save failures, or optimizing fetch performance. Trigger keywords: Core Data, NSManagedObject, NSPersistentContainer, NSFetchRequest, FetchRequest, migration, lightweight migration, background context, merge policy, faulting"
version: "3.0.0"
---
# 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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
```swift
@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
```swift
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
```swift
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 |
This skill provides expert decision frameworks and practical patterns for using Core Data in iOS/tvOS apps. It helps you choose Core Data vs alternatives, design context architecture for safe multithreading, pick migration strategies, and apply performance trade-offs. Use it to avoid common pitfalls and to standardize persistence practices across teams.
The skill inspects your persistence requirements and maps them to recommended solutions (UserDefaults, file-based Codable, SwiftData, Core Data, SQLite/Realm). It evaluates context models (viewContext, background contexts, parent-child) and suggests merge policies, migration approaches, and fetch optimizations. It also provides code patterns for a modern PersistenceController, background import, and efficient @FetchRequest usage.
When is Core Data overkill?
Core Data is overkill for simple preferences or flat Codable lists without complex queries; use UserDefaults, file storage, or SwiftData instead.
How do I safely access objects across threads?
Never pass NSManagedObject between contexts. Use objectID and context.object(with:) or perform/performBackgroundTask to access data on the correct queue.