| name | core-data |
| description | Use when choosing Core Data vs SwiftData, setting up the Core Data stack, modeling relationships, or implementing concurrency patterns - prevents thread-confinement errors and migration crashes |
| skill_type | discipline |
| version | 1.0.0 |
| last_updated | Thu Dec 25 2025 00:00:00 GMT+0000 (Coordinated Universal Time) |
| apple_platforms | iOS 3+, macOS 10.4+ |
| related | core-data-diag, swiftdata, database-migration |
Core Data
Overview
Core principle: Core Data is a mature object graph and persistence framework. Use it when needing features SwiftData doesn't support, or when targeting older iOS versions.
When to use Core Data vs SwiftData:
- SwiftData (iOS 17+) — New apps, simpler API, Swift-native
- Core Data — iOS 16 and earlier, advanced features, existing codebases
Quick Decision Tree
Which persistence framework?
├─ Targeting iOS 17+ only?
│ ├─ Simple data model? → SwiftData (recommended)
│ ├─ Need public CloudKit database? → Core Data (SwiftData is private-only)
│ ├─ Need custom migration logic? → Core Data (more control)
│ └─ Existing Core Data app? → Keep Core Data or migrate gradually
│
├─ Targeting iOS 16 or earlier?
│ └─ Core Data (SwiftData unavailable)
│
└─ Need both? → Use Core Data with SwiftData wrapper (advanced)
Red Flags
If ANY of these appear, STOP:
- ❌ "Access managed objects on any thread" — Thread-confinement violation
- ❌ "Skip migration testing on real device" — Simulator hides schema issues
- ❌ "Use a singleton context everywhere" — Leads to concurrency crashes
- ❌ "Force lightweight migration always" — Complex changes need mapping models
- ❌ "Fetch in view body" — Use @FetchRequest or observe in view model
Core Data Stack Setup
Modern Stack (iOS 10+)
import CoreData
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "Model")
// Configure for CloudKit if needed
// container.persistentStoreDescriptions.first?.cloudKitContainerOptions =
// NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.app")
container.loadPersistentStores { description, error in
if let error = error {
// Handle appropriately for production
fatalError("Failed to load store: \(error)")
}
}
// Enable automatic merging
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
persistentContainer.newBackgroundContext()
}
}
CloudKit Integration
import CoreData
class CloudKitStack {
lazy var container: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "Model")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable CloudKit sync
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourapp"
)
// Enable history tracking for sync
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { _, error in
if let error = error {
fatalError("CloudKit store failed: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}()
}
Concurrency Patterns
The Golden Rule
NEVER pass NSManagedObject across threads. Pass objectID instead.
// ❌ WRONG: Passing object across threads
let user = viewContext.fetch(...) // Main thread
Task.detached {
print(user.name) // CRASH: Wrong thread
}
// ✅ CORRECT: Pass objectID, fetch on target context
let userID = user.objectID
Task.detached {
let bgContext = CoreDataStack.shared.newBackgroundContext()
let user = bgContext.object(with: userID) as! User
print(user.name) // Safe
}
Background Processing
// ✅ CORRECT: Background context for heavy work
func importData(_ items: [ImportItem]) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
for item in items {
let entity = Entity(context: context)
entity.configure(from: item)
}
try context.save()
}
}
// Changes automatically merge to viewContext if configured
Async/Await (iOS 15+)
// Modern async context operations
func fetchUsers() async throws -> [User] {
let context = CoreDataStack.shared.viewContext
return try await context.perform {
let request = User.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
return try context.fetch(request)
}
}
Relationship Modeling
One-to-Many
// In User entity
@NSManaged var posts: NSSet?
// Convenience accessors
extension User {
var postsArray: [Post] {
(posts?.allObjects as? [Post]) ?? []
}
func addPost(_ post: Post) {
mutableSetValue(forKey: "posts").add(post)
}
}
Many-to-Many
// Both sides have NSSet
// User.tags <-> Tag.users
extension User {
func addTag(_ tag: Tag) {
mutableSetValue(forKey: "tags").add(tag)
// Core Data automatically adds to tag.users
}
}
Delete Rules
| Rule | Behavior | Use Case |
|---|---|---|
| Nullify | Set relationship to nil | Optional relationships |
| Cascade | Delete related objects | Owned children (User → Posts) |
| Deny | Prevent deletion if related objects exist | Protect referenced data |
| No Action | Do nothing (manual cleanup required) | Rarely appropriate |
Fetching Patterns
SwiftUI Integration
struct UserList: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.name, ascending: true)],
predicate: NSPredicate(format: "isActive == YES"),
animation: .default
)
private var users: FetchedResults<User>
var body: some View {
List(users) { user in
Text(user.name ?? "Unknown")
}
}
}
// Dynamic predicates
struct FilteredList: View {
@FetchRequest var items: FetchedResults<Item>
init(category: String) {
_items = FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.date, ascending: false)],
predicate: NSPredicate(format: "category == %@", category)
)
}
}
Batch Fetching (Avoid N+1)
// ❌ WRONG: N+1 queries
let users = try context.fetch(User.fetchRequest())
for user in users {
print(user.posts?.count ?? 0) // Fault fired for each user
}
// ✅ CORRECT: Prefetch relationships
let request = User.fetchRequest()
request.relationshipKeyPathsForPrefetching = ["posts"]
let users = try context.fetch(request)
for user in users {
print(user.posts?.count ?? 0) // Already loaded
}
Batch Size for Large Datasets
let request = User.fetchRequest()
request.fetchBatchSize = 20 // Load 20 at a time as needed
request.returnsObjectsAsFaults = true // Default, memory efficient
Schema Migration
Lightweight Migration (Automatic)
Handled automatically for:
- Adding optional attributes
- Removing attributes
- Renaming (with renaming identifier)
- Adding relationships with optional or default value
let description = NSPersistentStoreDescription()
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
When Mapping Model Is Needed
- Changing attribute types
- Splitting/merging entities
- Complex relationship changes
- Data transformation during migration
// Create mapping model in Xcode:
// File → New → Mapping Model
// Select source and destination models
Migration Testing Checklist
MANDATORY before shipping:
- ✓ Test on REAL DEVICE (simulator deletes DB on rebuild)
- ✓ Install old version, create data
- ✓ Install new version over it
- ✓ Verify all data accessible
- ✓ Check migration performance (large datasets)
Anti-Patterns
1. Singleton Context for Everything
// ❌ WRONG: One context for all operations
class DataManager {
let context = CoreDataStack.shared.viewContext
func importInBackground() {
// Using main context on background = crash
for item in largeDataset {
let entity = Entity(context: context)
}
}
}
// ✅ CORRECT: Context per operation type
func importInBackground() {
let bgContext = CoreDataStack.shared.newBackgroundContext()
bgContext.perform {
// Safe background work
}
}
2. Fetching in View Body
// ❌ WRONG: Fetch on every render
var body: some View {
let users = try? context.fetch(User.fetchRequest()) // Called repeatedly!
List(users ?? []) { ... }
}
// ✅ CORRECT: Use @FetchRequest
@FetchRequest(sortDescriptors: [])
var users: FetchedResults<User>
var body: some View {
List(users) { ... } // Automatic updates
}
3. Ignoring Merge Policy
// ❌ WRONG: No merge policy (conflicts crash)
let context = container.viewContext
// ✅ CORRECT: Define merge behavior
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.automaticallyMergesChangesFromParent = true
Performance Tips
- Use fetchBatchSize for large result sets
- Prefetch relationships that will be accessed
- Use background contexts for imports/exports
- Batch save — don't save after each insert
- Use fetchLimit when only first N results are needed
- Profile with SQL debug:
-com.apple.CoreData.SQLDebug 1
Pressure Scenarios
Scenario 1: "SwiftData is simpler, let's migrate now"
Situation: New iOS 17 features available, temptation to migrate mid-project.
Risk: Migration is complex. Mixed Core Data + SwiftData has sharp edges.
Response: "Complete current milestone first. Migration needs dedicated time and testing."
Scenario 2: "Skip migration testing, simulator works"
Situation: Schema change tested only in simulator.
Risk: Simulator deletes database on rebuild. Real devices keep persistent data and crash.
Response: "MANDATORY: Test on real device with real data. 15 minutes now prevents production crash."
Related Skills
core-data-diag— Debugging migrations, thread errors, N+1 queriesswiftdata— Modern alternative for iOS 17+database-migration— Safe schema evolution patternsswift-concurrency— Async/await patterns for Core Data