
Core Data Expert
- 2.1k installs
- 288 repo stars
- Updated March 2, 2026
- avdlee/core-data-agent-skill
core-data-expert is an agent skill that Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts,.
About
Fast production oriented guidance for building correct performant Core Data stacks and fixing common crashes Agent behavior contract follow these rules 1 Determine OS deployment target when advice depends on availability iOS 14 17 features etc 2 Identify the context type before proposing fixes view context UI vs background context heavy work 3 Recommend NSManagedObjectID for cross context cross task communication never pass NSManagedObject instances across contexts 4 Prefer lightweight migration when possible use staged migration iOS 17 for complex changes 5 When recommending batch operations verify persistent history tracking is enabled often required for UI updates 6 For CloudKit integration remind developers that Production schema is immutable 7 Reference WWDC external resources sparingly prefer this skill s references Clarify the goal setup bugfix migration performance CloudKit Collect minimal facts platform deployment target store type SQLite in memory and whether CloudKit is enabled context involved view vs background and whether Swift Concurrency is in use exact error message stack trace logs Branch immediately threading crash focus on context confinement NSManagedObjectID
- description: 'Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/me
- Fast, production-oriented guidance for building **correct**, **performant** Core Data stacks and fixing common crashes.
- 1. Determine OS/deployment target when advice depends on availability (iOS 14+/17+ features, etc.).
- Follow core-data-expert SKILL.md steps and documented constraints.
- Follow core-data-expert SKILL.md steps and documented constraints.
Core Data Expert by the numbers
- 2,058 all-time installs (skills.sh)
- +38 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #553 of 16,659 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
core-data-expert capabilities & compatibility
- Capabilities
- description: 'expert core data guidance (ios/mac · fast, production oriented guidance for building · 1. determine os/deployment target when advice de · follow core data expert skill.md steps and docum
- Use cases
- orchestration
What core-data-expert says it does
description: 'Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persistent hist
Fast, production-oriented guidance for building **correct**, **performant** Core Data stacks and fixing common crashes.
1. Determine OS/deployment target when advice depends on availability (iOS 14+/17+ features, etc.).
npx skills add https://github.com/avdlee/core-data-agent-skill --skill core-data-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.1k |
|---|---|
| repo stars | ★ 288 |
| Security audit | 3 / 3 scanners passed |
| Last updated | March 2, 2026 |
| Repository | avdlee/core-data-agent-skill ↗ |
When should an agent use core-data-expert and what problem does it solve?
Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persistent history, migration
Who is it for?
Developers invoking core-data-expert as documented in the skill source.
Skip if: Skip when requirements fall outside core-data-expert documented scope.
When should I use this skill?
Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persistent history, migration
What you get
Outputs aligned with the core-data-expert SKILL.md workflow and stated deliverables.
- Stack configuration guidance
- Migration and sync fix plan
Files
Core Data Expert
Fast, production-oriented guidance for building correct, performant Core Data stacks and fixing common crashes.
Agent behavior contract (follow these rules)
1. Determine OS/deployment target when advice depends on availability (iOS 14+/17+ features, etc.). 2. Identify the context type before proposing fixes: view context (UI) vs background context (heavy work). 3. Recommend NSManagedObjectID for cross-context/cross-task communication; never pass `NSManagedObject` instances across contexts. 4. Prefer lightweight migration when possible; use staged migration (iOS 17+) for complex changes. 5. When recommending batch operations, verify persistent history tracking is enabled (often required for UI updates). 6. For CloudKit integration, remind developers that Production schema is immutable. 7. Reference WWDC/external resources sparingly; prefer this skill’s references/.
First 60 seconds (triage template)
- Clarify the goal: setup, bugfix, migration, performance, CloudKit?
- Collect minimal facts:
- platform + deployment target
- store type (SQLite / in-memory) and whether CloudKit is enabled
- context involved (view vs background) and whether Swift Concurrency is in use
- exact error message + stack trace/logs
- Branch immediately:
- threading/crash → focus on context confinement +
NSManagedObjectIDhandoff - migration error → identify model versions + migration strategy
- batch ops not updating UI → persistent history tracking + merge pipeline
Routing map (pick the right reference fast)
- Stack setup / merge policies / contexts →
references/stack-setup.md - Saving patterns →
references/saving.md - Fetch requests / list updates / aggregates →
references/fetch-requests.md - Traditional threading (perform/performAndWait, object IDs) →
references/threading.md - Swift Concurrency (async/await, actors, Sendable, DAOs) →
references/concurrency.md - Batch insert/delete/update →
references/batch-operations.md - Persistent history tracking + “batch ops not updating UI” →
references/persistent-history.md - Model configuration (constraints, validation, derived/composite, transformables) →
references/model-configuration.md - Schema migration (lightweight/staged/deferred) →
references/migration.md - CloudKit integration & debugging →
references/cloudkit-integration.md - Performance profiling & memory →
references/performance.md - Testing patterns →
references/testing.md - Terminology →
references/glossary.md
Common errors → next best move
- “Failed to find a unique match for an NSEntityDescription” →
references/testing.md(sharedNSManagedObjectModel) - `NSPersistentStoreIncompatibleVersionHashError` →
references/migration.md(versioning + migration) - Cross-context/threading exceptions (e.g. delete/update from wrong context) →
references/threading.mdand/orreferences/concurrency.md(useNSManagedObjectID) - Sendable / actor-isolation warnings around Core Data →
references/concurrency.md(don’t “paper over” with@unchecked Sendable) - `NSMergeConflict` / constraint violations →
references/model-configuration.md+references/stack-setup.md(constraints + merge policy) - Batch operations not updating UI →
references/persistent-history.md+references/batch-operations.md - CloudKit schema/sync issues →
references/cloudkit-integration.md - Memory grows during fetch →
references/performance.md+references/fetch-requests.md
Verification checklist (when changing Core Data code)
- Confirm the context matches the work (UI vs background).
- Ensure
NSManagedObjectinstances never cross contexts; passNSManagedObjectIDinstead. - If using batch ops, confirm persistent history tracking + merge pipeline.
- If using constraints, confirm merge policy and conflict resolution strategy.
- If performance-related, profile with Instruments and validate fetch batching/limits.
Reference files
references/_index.md(navigation)references/stack-setup.mdreferences/saving.mdreferences/fetch-requests.mdreferences/threading.mdreferences/concurrency.mdreferences/batch-operations.mdreferences/persistent-history.mdreferences/model-configuration.mdreferences/migration.mdreferences/cloudkit-integration.mdreferences/performance.mdreferences/testing.mdreferences/glossary.md
Reference Index
Quick navigation for Core Data topics.
Fundamentals
stack-setup.md: NSPersistentContainer setup, merge policies, context configurationsaving.md: Conditional saving, hasPersistentChanges, save timing strategiesglossary.md: Term definitions for quick lookupproject-audit.md: Checklist for discovering a project’s Core Data setup and constraints
Data Access
fetch-requests.md: Query optimization, NSFetchedResultsController, aggregatesthreading.md: NSManagedObjectID, perform vs performAndWait, concurrencyconcurrency.md: Swift Concurrency integration, async/await, actors, Sendablebatch-operations.md: NSBatchInsertRequest, NSBatchDeleteRequest, NSBatchUpdateRequest
Model & Schema
model-configuration.md: Constraints, derived attributes, transformables, validation, lifecyclemigration.md: Lightweight, staged, and deferred migration strategies
Advanced Topics
persistent-history.md: History tracking setup, Observer/Fetcher/Merger/Cleaner patterncloudkit-integration.md: NSPersistentCloudKitContainer, schema design, monitoringperformance.md: Profiling with Instruments, memory management, optimizationtesting.md: In-memory stores, shared models, data generators
Quick Links by Problem
"I need to..."
- Set up Core Data →
stack-setup.md - Save data efficiently →
saving.md - Fetch and display data →
fetch-requests.md - Work with background threads →
threading.md - Use async/await with Core Data →
concurrency.md - Import large datasets →
batch-operations.md - Configure my model →
model-configuration.md - Migrate my schema →
migration.md - Sync with CloudKit →
cloudkit-integration.md - Optimize performance →
performance.md - Write tests →
testing.md
"I'm getting an error about..."
- "NSPersistentStoreIncompatibleVersionHashError" →
migration.md - "Cannot delete objects in other contexts" →
threading.md - "NSMergeConflict" →
stack-setup.md(merge policies),model-configuration.md(constraints) - "Failed to find unique match for NSEntityDescription" →
testing.md(shared model) - Batch operations not updating UI →
persistent-history.md - CloudKit sync issues →
cloudkit-integration.md - Memory growing unbounded →
performance.md,fetch-requests.md - Validation errors →
model-configuration.md
"I want to..."
- Optimize queries →
fetch-requests.md,performance.md - Handle relationships →
model-configuration.md,fetch-requests.md - Validate data →
model-configuration.md - Track changes across contexts →
persistent-history.md - Debug performance issues →
performance.md - Test my Core Data code →
testing.md
File Statistics
project-audit.md: Project discovery checklist (deployment target, stack, history tracking, concurrency risks)stack-setup.md: NSPersistentContainer, merge policies, context configurationsaving.md: hasPersistentChanges, conditional saving, error handlingfetch-requests.md: Optimization, NSFetchedResultsController, aggregates, diffable data sourcesthreading.md: NSManagedObjectID, perform/performAndWait, traditional threadingconcurrency.md: Swift Concurrency, async/await, actors, Sendable, @MainActor, DAOsbatch-operations.md: NSBatchInsertRequest, NSBatchDeleteRequest, NSBatchUpdateRequestmodel-configuration.md: Constraints, derived attributes, transformables, validation, lifecyclemigration.md: Lightweight, staged (iOS 17+), deferred (iOS 14+), composite attributespersistent-history.md: Observer, Fetcher, Merger, Cleaner, batch operation integrationcloudkit-integration.md: NSPersistentCloudKitContainer, schema design, monitoring, debuggingperformance.md: Instruments profiling, memory management, optimization strategiestesting.md: In-memory stores, shared models, data generators, XCTest patternsglossary.md: Core Data terminology and quick definitions
Batch Operations
Batch operations provide significant performance improvements for large-scale data modifications. They operate directly at the SQL level, bypassing the object graph.
Overview
Core Data provides three batch operation types:
- NSBatchInsertRequest - Bulk inserts (iOS 14+)
- NSBatchDeleteRequest - Bulk deletes
- NSBatchUpdateRequest - Bulk updates
Key Characteristics:
- Operate at SQL level (very fast)
- Don't load objects into memory
- Don't trigger validation
- Don't send change notifications (requires persistent history tracking)
- Can't set relationships during batch insert
NSBatchInsertRequest (iOS 14+)
Basic Usage
let context = container.newBackgroundContext()
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { (object: NSManagedObject) -> Bool in
guard let article = object as? Article else { return true }
article.name = "Sample Article"
article.content = "Content here"
article.creationDate = Date()
return false // Continue inserting
}
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}Inserting Multiple Objects
func batchInsertArticles(_ data: [ArticleData]) {
let context = container.newBackgroundContext()
context.perform {
var index = 0
let batchInsert = NSBatchInsertRequest(
entity: Article.entity()
) { (object: NSManagedObject) -> Bool in
guard index < data.count else { return true } // Stop
guard let article = object as? Article else { return true }
let articleData = data[index]
article.name = articleData.name
article.content = articleData.content
article.creationDate = Date()
index += 1
return false // Continue
}
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}
}Using Dictionary Representation (Alternative)
let context = container.newBackgroundContext()
context.perform {
let objects: [[String: Any]] = [
["name": "Article 1", "content": "Content 1", "creationDate": Date()],
["name": "Article 2", "content": "Content 2", "creationDate": Date()],
["name": "Article 3", "content": "Content 3", "creationDate": Date()]
]
let batchInsert = NSBatchInsertRequest(
entity: Article.entity(),
objects: objects
)
do {
try context.execute(batchInsert)
} catch {
print("Batch insert failed: \(error)")
}
}Limitations
Cannot set relationships:
// ❌ This won't work
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.category = someCategory // Can't set relationships!
return false
}Workaround: Set relationships after batch insert:
// 1. Batch insert articles
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "Article"
return false
}
try context.execute(batchInsert)
// 2. Fetch and set relationships
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
for article in articles {
article.category = defaultCategory
}
try context.save()NSBatchDeleteRequest
Basic Usage
let context = container.newBackgroundContext()
context.perform {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try context.execute(batchDelete)
} catch {
print("Batch delete failed: \(error)")
}
}With Predicate
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views < %d", 10)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
context.perform {
do {
try context.execute(batchDelete)
} catch {
print("Batch delete failed: \(error)")
}
}Getting Deleted Object IDs
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeObjectIDs
context.perform {
do {
let result = try context.execute(batchDelete) as? NSBatchDeleteResult
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Deleted \(objectIDs.count) objects")
}
} catch {
print("Batch delete failed: \(error)")
}
}NSBatchUpdateRequest
Basic Usage
let context = container.newBackgroundContext()
context.perform {
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.predicate = NSPredicate(format: "isRead == NO")
batchUpdate.propertiesToUpdate = ["isRead": true]
do {
try context.execute(batchUpdate)
} catch {
print("Batch update failed: \(error)")
}
}Updating Multiple Properties
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.predicate = NSPredicate(format: "views < %d", 100)
batchUpdate.propertiesToUpdate = [
"views": 100,
"lastModified": Date(),
"isPopular": true
]
context.perform {
try? context.execute(batchUpdate)
}Using Expressions
// Increment views by 1
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = [
"views": NSExpression(format: "views + 1")
]
context.perform {
try? context.execute(batchUpdate)
}Getting Updated Object IDs
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = ["isRead": true]
batchUpdate.resultType = .updatedObjectIDsResultType
context.perform {
do {
let result = try context.execute(batchUpdate) as? NSBatchUpdateResult
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Updated \(objectIDs.count) objects")
}
} catch {
print("Batch update failed: \(error)")
}
}Persistent History Tracking Integration
Critical: Batch operations don't send change notifications. You must enable persistent history tracking for UI updates.
Enable Persistent History Tracking
guard let description = container.persistentStoreDescriptions.first else { return }
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)Observe Remote Changes
NotificationCenter.default.addObserver(
self,
selector: #selector(storeRemoteChange),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
@objc func storeRemoteChange(_ notification: Notification) {
// Merge changes into view context
// See persistent-history.md for full implementation
}Performance Comparison
Traditional Insert (Slow)
// Inserting 1000 objects: ~10 seconds
for i in 0..<1000 {
let article = Article(context: context)
article.name = "Article \(i)"
}
try context.save()Batch Insert (Fast)
// Inserting 1000 objects: ~0.5 seconds
var index = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard index < 1000 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(index)"
index += 1
return false
}
try context.execute(batchInsert)Performance gain: ~20x faster
When to Use Batch Operations
Use Batch Insert When:
- Importing large datasets (>100 objects)
- Initial data seeding
- Syncing data from server
- Performance is critical
Use Batch Delete When:
- Deleting many objects at once
- Clearing old data
- Implementing data retention policies
- Performance is critical
Use Batch Update When:
- Updating many objects with same values
- Bulk status changes
- Incrementing counters
- Performance is critical
Don't Use Batch Operations When:
- Need to set relationships
- Need validation
- Need to trigger lifecycle events (willSave, etc.)
- Working with small datasets (<50 objects)
- Need immediate UI updates without persistent history tracking
Complete Example: Import with Batch Insert
class DataImporter {
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func importArticles(_ data: [ArticleData]) {
let context = container.newBackgroundContext()
context.perform {
var index = 0
let batchInsert = NSBatchInsertRequest(
entity: Article.entity()
) { (object: NSManagedObject) -> Bool in
guard index < data.count else { return true }
guard let article = object as? Article else { return true }
let articleData = data[index]
article.name = articleData.name
article.content = articleData.content
article.views = 0
article.creationDate = Date()
index += 1
return false
}
do {
let result = try context.execute(batchInsert) as? NSBatchInsertResult
print("Inserted \(data.count) articles")
// If you need the object IDs
if let objectIDs = result?.result as? [NSManagedObjectID] {
print("Object IDs: \(objectIDs)")
}
} catch {
print("Batch insert failed: \(error)")
}
}
}
}Complete Example: Cleanup with Batch Delete
class DataCleaner {
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func deleteOldArticles(olderThan days: Int) {
let context = container.newBackgroundContext()
context.perform {
let cutoffDate = Calendar.current.date(
byAdding: .day,
value: -days,
to: Date()
)!
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "creationDate < %@",
cutoffDate as NSDate
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeCount
do {
let result = try context.execute(batchDelete) as? NSBatchDeleteResult
if let count = result?.result as? Int {
print("Deleted \(count) old articles")
}
} catch {
print("Batch delete failed: \(error)")
}
}
}
}Common Pitfalls
❌ Not Enabling Persistent History Tracking
// Batch insert happens
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { ... }
try context.execute(batchInsert)
// UI doesn't update! No notifications sent❌ Trying to Set Relationships
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.category = category // Won't work!
return false
}❌ Expecting Validation
// No validation happens!
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "" // Empty name - no validation error
return false
}❌ Using on View Context
// Don't use batch operations on view context
viewContext.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { ... }
try? viewContext.execute(batchInsert) // Blocks UI!
}✅ Correct Approach
// 1. Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// 2. Use background context
let context = container.newBackgroundContext()
// 3. Execute batch operation
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard let article = object as? Article else { return true }
article.name = "Valid Name"
return false
}
try? context.execute(batchInsert)
}
// 4. UI updates via persistent history trackingTesting Batch Operations
func testBatchInsert() throws {
let context = container.newBackgroundContext()
let expectation = XCTestExpectation(description: "Batch insert")
context.perform {
var count = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard count < 10 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(count)"
count += 1
return false
}
do {
try context.execute(batchInsert)
expectation.fulfill()
} catch {
XCTFail("Batch insert failed: \(error)")
}
}
wait(for: [expectation], timeout: 5.0)
// Verify
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)
XCTAssertEqual(articles.count, 10)
}Summary
1. Use batch operations for large datasets - 10-20x performance improvement 2. Enable persistent history tracking - Required for UI updates 3. Use background contexts - Don't block UI 4. Can't set relationships in batch insert - Set them separately if needed 5. No validation or lifecycle events - Batch operations bypass object graph 6. Get result types - Use resultType to get object IDs or counts 7. Test thoroughly - Verify data integrity after batch operations 8. Consider trade-offs - Speed vs validation/relationships/lifecycle events
CloudKit Integration
NSPersistentCloudKitContainer syncs Core Data with CloudKit, enabling seamless data synchronization across devices.
Setup
Basic Setup
import CoreData
import CloudKit
let container = NSPersistentCloudKitContainer(name: "Model")
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}Configure CloudKit Container
In Xcode: 1. Add CloudKit capability 2. Select or create CloudKit container 3. Enable "Use CloudKit" in Core Data model
Schema Design Limitations
CloudKit has restrictions Core Data doesn't:
Not Supported:
- Unique constraints on entities
Undefinedattribute typeObjectIDattribute type- Non-optional relationships (must be optional)
- Relationships without inverse
- Deny deletion rule
Supported:
- Adding new fields to record types
- Adding new record types
Important: Production schema is immutable. Plan carefully!
Schema Initialization
Development Environment
// First run initializes schema in Development
container.loadPersistentStores { description, error in
// Schema created automatically
}Promoting to Production
1. Test thoroughly in Development 2. Open CloudKit Dashboard 3. Deploy schema to Production 4. Cannot modify after deployment!
Monitoring Sync
Observe Events
NotificationCenter.default.addObserver(
self,
selector: #selector(storeDidChange),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: container
)
@objc func storeDidChange(_ notification: Notification) {
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
switch event.type {
case .setup:
print("Setup: \(event.succeeded ? "succeeded" : "failed")")
case .import:
print("Import: \(event.succeeded ? "succeeded" : "failed")")
case .export:
print("Export: \(event.succeeded ? "succeeded" : "failed")")
@unknown default:
break
}
if let error = event.error {
print("Error: \(error)")
}
}Testing Sync
func testSync() {
let expectation = XCTestExpectation(description: "Export")
// Create expectation for export
let observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: nil
) { notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
if event.type == .export && event.endDate != nil {
expectation.fulfill()
}
}
// Make changes
let article = Article(context: container.viewContext)
article.name = "Test"
try? container.viewContext.save()
wait(for: [expectation], timeout: 60)
NotificationCenter.default.removeObserver(observer)
}Cross-Version Compatibility
Strategy 1: Incremental Fields
Add new fields, keep old ones:
// V1: name
// V2: name, subtitle (new)
// Old versions see records but not subtitleStrategy 2: Version Attribute
// Add version attribute
article.schemaVersion = 2
// Filter in fetch requests
fetchRequest.predicate = NSPredicate(format: "schemaVersion <= %d", currentVersion)Strategy 3: New Container
let options = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.example.app.v2"
)
let description = NSPersistentStoreDescription(url: storeURL)
description.cloudKitContainerOptions = optionsCaution: Large datasets take time to upload.
Debugging
System Logs
Monitor these processes:
- Application - Core Data activity
- dasd - Scheduling decisions
- cloudd - CloudKit operations
- apsd - Push notifications
Using log stream
# Application logs
log stream --predicate 'process == "YourApp"'
# CloudKit logs
log stream --predicate 'process == "cloudd" AND message CONTAINS "your.container.id"'
# Push notifications
log stream --predicate 'process == "apsd"'
# Scheduling
log stream --predicate 'process == "dasd" AND message CONTAINS "YourApp"'CloudKit Logging Profile
1. Download from Apple Developer Portal 2. Install on device 3. Reboot device 4. Reproduce issue 5. Collect sysdiagnose
Collecting Diagnostics
sysdiagnose:
- iOS: Volume Up + Volume Down + Power (hold)
- macOS: Shift + Control + Option + Command + Period
Common Issues
Schema Mismatch
Problem: Local schema doesn't match CloudKit schema.
Solution: 1. Delete app 2. Reinstall 3. Let schema reinitialize
Sync Not Working
Checklist:
- [ ] CloudKit capability enabled
- [ ] Signed in to iCloud
- [ ] Network connection available
- [ ] CloudKit container configured
- [ ] Schema initialized in Development
- [ ] Schema promoted to Production
Large Initial Sync
Problem: First sync takes too long.
Solutions:
- Use background fetch
- Show progress indicator
- Implement data generators for testing
Best Practices
1. Test in Development first - Schema is mutable 2. Plan schema carefully - Production is immutable 3. Make relationships optional - Required by CloudKit 4. Add inverse relationships - Required by CloudKit 5. Version your data - For cross-version compatibility 6. Monitor sync events - Detect and handle errors 7. Test with multiple devices - Verify sync behavior 8. Handle conflicts - Use appropriate merge policy 9. Collect diagnostics - For debugging sync issues 10. Consider data size - Large datasets take time to sync
Summary
- Use
NSPersistentCloudKitContainerfor CloudKit sync - Schema has limitations (optional relationships, no constraints)
- Production schema is immutable
- Monitor sync with event notifications
- Test thoroughly in Development before promoting
- Plan for cross-version compatibility
- Use system logs for debugging
- Collect sysdiagnose for complex issues
Core Data and Swift Concurrency
Thread-safe patterns for using Core Data with Swift Concurrency.
Core Principles
Thread safety still matters
Core Data's thread safety rules don't change with Swift Concurrency:
- Can't pass
NSManagedObjectbetween threads - Must access objects on their context's thread
NSManagedObjectIDis thread-safe (can pass around)
NSManagedObject cannot be Sendable
@objc(Article)
public class Article: NSManagedObject {
@NSManaged public var title: String // ❌ Mutable, can't be Sendable
}Don't use `@unchecked Sendable` - hides warnings without fixing safety.
Available Async APIs
Context perform
extension NSManagedObjectContext {
func perform<T>(_ block: @escaping () throws -> T) async rethrows -> T
}What's missing
No async alternative for:
func loadPersistentStores(
completionHandler: @escaping (NSPersistentStoreDescription, Error?) -> Void
)Must bridge manually (see below).
Data Access Objects (DAO)
Thread-safe value types representing managed objects.
Pattern
// Managed object (not Sendable)
@objc(Article)
public class Article: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var timestamp: Date?
}
// DAO (Sendable)
struct ArticleDAO: Sendable, Identifiable {
let id: NSManagedObjectID
let title: String
let timestamp: Date
init?(managedObject: Article) {
guard let title = managedObject.title,
let timestamp = managedObject.timestamp else {
return nil
}
self.id = managedObject.objectID
self.title = title
self.timestamp = timestamp
}
}Benefits
- Sendable: Safe to pass across isolation domains
- Immutable: No accidental mutations
- Clear API: Explicit data transfer
Drawbacks
- Requires rewrite: All fetch/mutation logic
- Boilerplate: DAO for each entity
- Complexity: Additional layer of abstraction
Working Without DAOs
Pass only NSManagedObjectID between contexts.
Basic pattern
@MainActor
func fetchArticle(id: NSManagedObjectID) -> Article? {
viewContext.object(with: id) as? Article
}
func processInBackground(articleID: NSManagedObjectID) async throws {
let backgroundContext = container.newBackgroundContext()
try await backgroundContext.perform {
guard let article = backgroundContext.object(with: articleID) as? Article else {
return
}
// Process article
try backgroundContext.save()
}
}NSManagedObjectID is Sendable
// Safe to pass between tasks
let articleID = article.objectID
Task {
try? await processInBackground(articleID: articleID)
}Bridging Closures to Async
Load persistent stores
extension NSPersistentContainer {
func loadPersistentStores() async throws {
try await withCheckedThrowingContinuation { continuation in
self.loadPersistentStores { description, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
// Usage
try await container.loadPersistentStores()Simple CoreDataStore Pattern
Enforce isolation at API level:
final class CoreDataStore {
let persistentContainer: NSPersistentContainer
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
init(persistentContainer: NSPersistentContainer) {
self.persistentContainer = persistentContainer
}
// View context operations (main thread)
@MainActor
func read<T>(_ block: (NSManagedObjectContext) throws -> T) rethrows -> T {
try block(viewContext)
}
// Background operations
func performInBackground<T>(
_ block: @Sendable @escaping (NSManagedObjectContext) throws -> T
) async rethrows -> T {
let context = persistentContainer.newBackgroundContext()
return try await context.perform {
try block(context)
}
}
}Usage
let store = CoreDataStore(persistentContainer: container)
// Main thread operations
@MainActor
func loadArticles() throws -> [Article] {
try store.read { context in
let request = Article.fetchRequest()
return try context.fetch(request)
}
}
// Background operations
func deleteAll() async throws {
try await store.performInBackground { context in
let request = Article.fetchRequest()
let articles = try context.fetch(request)
articles.forEach { context.delete($0) }
try context.save()
}
}Why this pattern works
- @MainActor: Enforces view context on main thread
- Dedicated entry points: Read/write APIs prevent accidental cross-context use
- Simple: No custom executors needed
Custom Actor Executor (Advanced)
Note: Usually not needed. Consider simple pattern first.
Implementation
final class NSManagedObjectContextExecutor: @unchecked Sendable, SerialExecutor {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let executor = asUnownedSerialExecutor()
context.perform {
unownedJob.runSynchronously(on: executor)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}Actor usage
actor CoreDataStore {
let persistentContainer: NSPersistentContainer
private let context: NSManagedObjectContext
nonisolated let modelExecutor: NSManagedObjectContextExecutor
nonisolated var unownedExecutor: UnownedSerialExecutor {
modelExecutor.asUnownedSerialExecutor()
}
private init() {
persistentContainer = NSPersistentContainer(name: "MyApp")
context = persistentContainer.newBackgroundContext()
modelExecutor = NSManagedObjectContextExecutor(context: context)
}
func deleteAll<T: NSManagedObject>(
using request: NSFetchRequest<T>
) throws {
let objects = try context.fetch(request)
objects.forEach { context.delete($0) }
try context.save()
}
}Drawbacks
- Hidden complexity: Executor details obscure Core Data
- Forces concurrency: Even for main thread operations
- Not simpler: More code than
perform { } - Error prone: Easy to use wrong context
Recommendation: Use simple pattern instead.
Default MainActor Isolation
Problem with auto-generated code
When default isolation set to @MainActor, auto-generated managed objects conflict:
// Auto-generated (can't modify)
class Article: NSManagedObject {
// Inherits @MainActor, conflicts with NSManagedObject
}Error: Main actor-isolated initializer has different actor isolation from nonisolated overridden declaration
Solution: Manual code generation
1. Set entity to "Manual/None" code generation 2. Generate class definitions 3. Mark as nonisolated:
nonisolated class Article: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var timestamp: Date?
}Benefit: Full control over isolation.
Common Patterns
Fetch on main thread
@MainActor
func fetchArticles() throws -> [Article] {
let request = Article.fetchRequest()
return try viewContext.fetch(request)
}Background save
func saveInBackground() async throws {
let context = container.newBackgroundContext()
try await context.perform {
let article = Article(context: context)
article.title = "New Article"
try context.save()
}
}Pass ID, fetch in context
@MainActor
func displayArticle(id: NSManagedObjectID) {
guard let article = viewContext.object(with: id) as? Article else {
return
}
// Use article
}
func processArticle(id: NSManagedObjectID) async throws {
let context = container.newBackgroundContext()
try await context.perform {
guard let article = context.object(with: id) as? Article else { return }
// Process article
try context.save()
}
}Batch operations
func deleteAllArticles() async throws {
let context = container.newBackgroundContext()
try await context.perform {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Article")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
try context.execute(deleteRequest)
}
}SwiftUI Integration
Environment injection
@main
struct MyApp: App {
let persistentContainer = NSPersistentContainer(name: "MyApp")
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistentContainer.viewContext)
}
}
}View usage
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Article.timestamp, ascending: true)]
) private var articles: FetchedResults<Article>
var body: some View {
List(articles) { article in
Text(article.title ?? "")
}
}
}Best Practices
1. Pass NSManagedObjectID only - never managed objects 2. Use perform { } - don't access context directly 3. @MainActor for view context - enforce main thread 4. Use background contexts - run heavy work off the main thread 5. Manual code generation - control isolation 6. Keep it simple - avoid custom executors unless needed 7. Enable Core Data debugging - catch thread violations 8. Merge changes automatically - automaticallyMergesChangesFromParent = true 9. Use background contexts - for heavy operations 10. Test with Thread Sanitizer - catch violations early
Debugging
Enable Core Data concurrency debugging
// Launch argument
-com.apple.CoreData.ConcurrencyDebug 1Crashes immediately on thread violations.
Thread Sanitizer
Enable in scheme settings to catch data races.
Assertions
@MainActor
func fetchArticles() -> [Article] {
assert(Thread.isMainThread)
// Fetch from viewContext
}Decision Tree
Need to access Core Data?
├─ UI/View context?
│ └─ Use @MainActor + viewContext
│
├─ Background operation?
│ ├─ Quick operation? → perform { } on background context
│ └─ Batch operation? → NSBatchDeleteRequest/NSBatchUpdateRequest
│
├─ Pass between contexts?
│ └─ Use NSManagedObjectID only
│
└─ Need Sendable type?
├─ Can refactor? → Use DAO pattern
└─ Can't refactor? → Pass NSManagedObjectIDMigration Strategy
For existing projects
1. Enable manual code generation for all entities 2. Mark entities as nonisolated if using default @MainActor 3. Wrap Core Data access in CoreDataStore 4. Use @MainActor for view context operations 5. Use background contexts for write-heavy work 6. Pass NSManagedObjectID between contexts 7. Test with debugging enabled
For new projects
1. Start with simple pattern (CoreDataStore) 2. Manual code generation from the start 3. Consider DAOs if heavy cross-context usage 4. Enable strict concurrency early
Common Mistakes
❌ Passing managed objects
func process(article: Article) async {
// ❌ Article not Sendable
}❌ Accessing context from wrong thread
func background() async {
let articles = viewContext.fetch(request) // ❌ Not on main thread
}❌ Using @unchecked Sendable
extension Article: @unchecked Sendable {} // ❌ Doesn't make it safe❌ Not using perform
func save() async {
backgroundContext.save() // ❌ Not on context's thread
}Related References
- See
threading.mdfor general Core Data threading patterns - See
batch-operations.mdfor async batch operation patterns - See
stack-setup.mdfor container setup with async/await - See
testing.mdfor testing async Core Data code
Further Learning
For Core Data best practices, migration strategies, and advanced patterns:
Fetch Requests and Querying
Optimizing fetch requests is crucial for app performance. This guide covers best practices for querying Core Data efficiently, from basic fetches to advanced aggregations.
Basic Fetch Request
let fetchRequest: NSFetchRequest<Article> = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)Optimization Strategies
1. Limit Properties Fetched
Only fetch the properties you actually need:
let fetchRequest = Article.fetchRequest()
fetchRequest.propertiesToFetch = ["name", "creationDate"]
// For list view, you might only need:
fetchRequest.propertiesToFetch = ["name", "categoryName", "views"]SQL Impact:
-- Without propertiesToFetch
SELECT * FROM ZARTICLE
-- With propertiesToFetch
SELECT Z_PK, ZNAME, ZCREATIONDATE FROM ZARTICLEBenefits:
- Reduces memory usage
- Faster query execution
- Less data transferred from disk
2. Use Batch Fetching
Fetch objects in batches to avoid loading everything at once:
let fetchRequest = Article.fetchRequest()
fetchRequest.fetchBatchSize = 20How it works:
- Initially fetches only 20 objects
- Fetches next batch when needed (scrolling, iteration)
- Keeps memory usage predictable
When to use:
- List views (table/collection views)
- Large datasets
- Scrollable content
3. Set Fetch Limit
When you only need a specific number of results:
let fetchRequest = Article.fetchRequest()
fetchRequest.fetchLimit = 1 // Only fetch one resultCommon use cases:
// Get newest article
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchRequest.fetchLimit = 1
// Get top 10 most viewed
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "views", ascending: false)]
fetchRequest.fetchLimit = 104. Fetch Only Object IDs
For counting or checking existence, fetch only IDs:
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .managedObjectIDResultType
let objectIDs = try context.fetch(fetchRequest) as! [NSManagedObjectID]Benefits:
- Minimal memory usage
- Very fast
- No faulting overhead
Use for:
- Counting objects
- Checking existence
- Batch operations
- Validation
Sort Descriptors
Always specify sort descriptors for predictable results:
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: false)
]Multiple Sort Descriptors
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "category.name", ascending: true),
NSSortDescriptor(key: "name", ascending: true)
]Case-Insensitive Sorting
let sortDescriptor = NSSortDescriptor(
key: "name",
ascending: true,
selector: #selector(NSString.caseInsensitiveCompare(_:))
)
fetchRequest.sortDescriptors = [sortDescriptor]Localized Sorting
let sortDescriptor = NSSortDescriptor(
key: "name",
ascending: true,
selector: #selector(NSString.localizedStandardCompare(_:))
)
fetchRequest.sortDescriptors = [sortDescriptor]Predicates
Filter results using predicates:
Basic Predicates
// Exact match
fetchRequest.predicate = NSPredicate(format: "name == %@", "SwiftLee")
// Contains
fetchRequest.predicate = NSPredicate(format: "name CONTAINS[cd] %@", "swift")
// [c] = case insensitive, [d] = diacritic insensitive
// Begins with
fetchRequest.predicate = NSPredicate(format: "name BEGINSWITH[c] %@", "Swift")
// Greater than
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
// Date range
let startDate = Calendar.current.startOfDay(for: Date())
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
fetchRequest.predicate = NSPredicate(
format: "creationDate >= %@ AND creationDate < %@",
startDate as NSDate,
endDate as NSDate
)Compound Predicates
// AND
let predicate1 = NSPredicate(format: "views > %d", 100)
let predicate2 = NSPredicate(format: "category.name == %@", "Swift")
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate1, predicate2])
// OR
fetchRequest.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [predicate1, predicate2])
// NOT
fetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: predicate1)Relationship Predicates
// Articles with a specific category
fetchRequest.predicate = NSPredicate(format: "category.name == %@", "Swift")
// Articles with any attachments
fetchRequest.predicate = NSPredicate(format: "attachments.@count > 0")
// Articles with more than 5 attachments
fetchRequest.predicate = NSPredicate(format: "attachments.@count > 5")
// Using ANY
fetchRequest.predicate = NSPredicate(format: "ANY attachments.size > %d", 1000000)
// Using ALL
fetchRequest.predicate = NSPredicate(format: "ALL attachments.isDownloaded == YES")IN Predicate
let names = ["Swift", "iOS", "Core Data"]
fetchRequest.predicate = NSPredicate(format: "name IN %@", names)NSFetchedResultsController
For table and collection views, use NSFetchedResultsController for automatic updates:
class ArticlesViewController: UIViewController {
var fetchedResultsController: NSFetchedResultsController<Article>!
func setupFetchedResultsController() {
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: false)
]
fetchRequest.fetchBatchSize = 20
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: nil,
cacheName: "ArticlesCache"
)
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
}
}With Sections
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: "category.name", // Group by category
cacheName: "ArticlesByCategoryCache"
)Delegate Methods (UITableView)
extension ArticlesViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
if let indexPath = newIndexPath {
tableView.insertRows(at: [indexPath], with: .automatic)
}
case .delete:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
}
case .update:
if let indexPath = indexPath {
tableView.reloadRows(at: [indexPath], with: .automatic)
}
case .move:
if let indexPath = indexPath, let newIndexPath = newIndexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
tableView.insertRows(at: [newIndexPath], with: .automatic)
}
@unknown default:
break
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
}
}Diffable Data Sources (iOS 13+)
Modern approach using NSDiffableDataSourceSnapshot:
class ArticlesViewController: UICollectionViewController {
private var dataSource: UICollectionViewDiffableDataSource<String, NSManagedObjectID>!
private var fetchedResultsController: NSFetchedResultsController<Article>!
func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<String, NSManagedObjectID>(
collectionView: collectionView
) { collectionView, indexPath, objectID in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "ArticleCell",
for: indexPath
) as! ArticleCell
if let article = try? self.viewContext.existingObject(with: objectID) as? Article {
cell.configure(with: article)
}
return cell
}
}
func setupFetchedResultsController() {
let fetchRequest = Article.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
}
}
extension ArticlesViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(snapshot, animatingDifferences: true)
}
}Aggregate Fetching with NSExpression
For statistics and aggregations:
Count
// Simple count
let count = try context.count(for: Article.fetchRequest())
// Count with predicate
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
let count = try context.count(for: fetchRequest)Sum, Average, Min, Max
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
// Sum of views
let sumExpression = NSExpression(format: "@sum.views")
let sumDescription = NSExpressionDescription()
sumDescription.name = "totalViews"
sumDescription.expression = sumExpression
sumDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [sumDescription]
let results = try context.fetch(fetchRequest) as! [[String: Any]]
if let totalViews = results.first?["totalViews"] as? Int {
print("Total views: \(totalViews)")
}Group By with Aggregates
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
// Category name
let categoryExpression = NSExpression(forKeyPath: "category.name")
let categoryDescription = NSExpressionDescription()
categoryDescription.name = "categoryName"
categoryDescription.expression = categoryExpression
categoryDescription.expressionResultType = .stringAttributeType
// Sum of views per category
let sumExpression = NSExpression(format: "@sum.views")
let sumDescription = NSExpressionDescription()
sumDescription.name = "totalViews"
sumDescription.expression = sumExpression
sumDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [categoryDescription, sumDescription]
fetchRequest.propertiesToGroupBy = ["category.name"]
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "categoryName", ascending: true)]
let results = try context.fetch(fetchRequest) as! [[String: Any]]
for result in results {
let category = result["categoryName"] as? String ?? "Unknown"
let views = result["totalViews"] as? Int ?? 0
print("\(category): \(views) views")
}Count Per Group
let fetchRequest = Article.fetchRequest()
fetchRequest.resultType = .dictionaryResultType
let categoryExpression = NSExpression(forKeyPath: "category.name")
let categoryDescription = NSExpressionDescription()
categoryDescription.name = "categoryName"
categoryDescription.expression = categoryExpression
categoryDescription.expressionResultType = .stringAttributeType
let countExpression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "objectID")])
let countDescription = NSExpressionDescription()
countDescription.name = "count"
countDescription.expression = countExpression
countDescription.expressionResultType = .integer64AttributeType
fetchRequest.propertiesToFetch = [categoryDescription, countDescription]
fetchRequest.propertiesToGroupBy = ["category.name"]
let results = try context.fetch(fetchRequest) as! [[String: Any]]Typed Fetch Requests with Managed Protocol
Create a protocol for type-safe fetch requests:
protocol Managed: NSManagedObject {
static var entityName: String { get }
}
extension Managed {
static var entityName: String {
return String(describing: self)
}
static func fetchRequest<T: NSManagedObject>() -> NSFetchRequest<T> {
return NSFetchRequest<T>(entityName: entityName)
}
}
// Conform your entities
extension Article: Managed {}
// Usage
let fetchRequest: NSFetchRequest<Article> = Article.fetchRequest()Asynchronous Fetching
For large datasets, fetch asynchronously:
let fetchRequest = Article.fetchRequest()
let asyncFetchRequest = NSAsynchronousFetchRequest(fetchRequest: fetchRequest) { result in
guard let articles = result.finalResult else { return }
DispatchQueue.main.async {
// Update UI with articles
}
}
try? context.execute(asyncFetchRequest)Faulting Control
Prefetching Relationships
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category", "attachments"]Benefits:
- Reduces number of database trips
- Improves performance when accessing relationships
- Prevents N+1 query problem
Returning Faults
fetchRequest.returnsObjectsAsFaults = falseWhen to use:
- You know you'll access all properties immediately
- Small result sets
- Avoid for large datasets (high memory usage)
Common Patterns
Fetch Single Object by ID
func fetchArticle(withID id: NSManagedObjectID) -> Article? {
return try? context.existingObject(with: id) as? Article
}Fetch or Create
func fetchOrCreateArticle(withName name: String) -> Article {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
fetchRequest.fetchLimit = 1
if let existing = try? context.fetch(fetchRequest).first {
return existing
}
let article = Article(context: context)
article.name = name
return article
}Check Existence
func articleExists(withName name: String) -> Bool {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
fetchRequest.fetchLimit = 1
fetchRequest.resultType = .countResultType
let count = (try? context.count(for: fetchRequest)) ?? 0
return count > 0
}Performance Tips
❌ Don't Fetch Everything
// Bad: Fetches all properties, all objects
let articles = try context.fetch(Article.fetchRequest())
let count = articles.count✅ Use Count Request
// Good: Only counts, doesn't fetch objects
let count = try context.count(for: Article.fetchRequest())❌ Don't Access Relationships in Loops
// Bad: Fires fault for each article
for article in articles {
print(article.category?.name) // Fault!
}✅ Prefetch Relationships
// Good: Prefetches all categories at once
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
let articles = try context.fetch(fetchRequest)
for article in articles {
print(article.category?.name) // No fault!
}❌ Don't Fetch in Loops
// Bad: Multiple fetch requests
for name in names {
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@", name)
let articles = try? context.fetch(fetchRequest)
}✅ Use IN Predicate
// Good: Single fetch request
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name IN %@", names)
let articles = try context.fetch(fetchRequest)Debugging Fetch Requests
Enable SQL Debug
Add launch argument:
-com.apple.CoreData.SQLDebug 1Output:
CoreData: sql: SELECT Z_PK, ZNAME, ZVIEWS FROM ZARTICLE WHERE ZVIEWS > ? ORDER BY ZCREATIONDATE DESC LIMIT 20Measure Fetch Performance
let startTime = CFAbsoluteTimeGetCurrent()
let articles = try context.fetch(fetchRequest)
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
print("Fetch took \(timeElapsed) seconds")Summary
1. Use `propertiesToFetch` to limit fetched properties 2. Set `fetchBatchSize` for large datasets (typically 20-50) 3. Use `fetchLimit` when you only need a few results 4. Always specify sort descriptors for predictable results 5. Use predicates to filter at the database level 6. Use `NSFetchedResultsController` for list views 7. Prefetch relationships to avoid N+1 queries 8. Use count requests instead of fetching for counts 9. Use aggregate expressions for statistics 10. Enable SQL debug to understand query performance
Core Data Glossary
Quick reference for Core Data terminology.
Core Concepts
Core Data Apple's framework for object graph management and persistence.
Persistent Store The underlying storage (typically SQLite database) where data is saved.
Managed Object Model Describes your data schema (entities, attributes, relationships).
Entity A class definition in your data model (like a database table).
Attribute A property of an entity (like a database column).
Relationship A connection between entities (one-to-one, one-to-many, many-to-many).
Stack Components
NSPersistentContainer Encapsulates the Core Data stack (model, coordinator, contexts).
NSPersistentCloudKitContainer Extends NSPersistentContainer with CloudKit sync capabilities.
NSPersistentStoreCoordinator Manages one or more persistent stores and coordinates access.
NSManagedObjectContext Scratch pad for working with managed objects. Changes aren't persisted until saved.
NSManagedObject Base class for Core Data objects. Represents a row in a database table.
NSManagedObjectID Unique, immutable identifier for a managed object. Thread-safe.
Context Types
View Context Main queue context for UI operations. Runs on main thread.
Background Context Private queue context for heavy work. Runs on background thread.
Child Context Context with a parent context. Saves push changes to parent, not to disk.
Fetching
NSFetchRequest Describes a search for objects in the persistent store.
NSFetchedResultsController Manages fetch results for table/collection views with automatic updates.
Predicate Filter condition for fetch requests (like SQL WHERE clause).
Sort Descriptor Defines ordering for fetch results (like SQL ORDER BY).
Faulting Lazy loading mechanism. Object data loaded only when accessed.
Prefetching Loading related objects eagerly to avoid faulting.
Operations
Save Persists changes from context to persistent store.
Fetch Retrieves objects from persistent store.
Insert Creates new object in context.
Delete Marks object for deletion. Removed on save.
Refresh Reloads object from persistent store, discarding in-memory changes.
Reset Clears all objects from context, freeing memory.
Rollback Discards all unsaved changes in context.
Batch Operations
NSBatchInsertRequest Inserts multiple objects at SQL level (iOS 14+).
NSBatchDeleteRequest Deletes multiple objects at SQL level.
NSBatchUpdateRequest Updates multiple objects at SQL level.
Advanced Features
Persistent History Tracking Records all changes in a transaction log for cross-context synchronization.
Derived Attribute Computed attribute stored in database (e.g., articles.@count).
Transformable Custom type stored using value transformer.
Constraint Ensures attribute uniqueness (requires merge policy).
Merge Policy Determines how conflicts are resolved when saving.
Migration
Lightweight Migration Automatic migration for simple model changes.
Staged Migration Complex migration decomposed into steps (iOS 17+).
Deferred Migration Delays cleanup work for better performance (iOS 14+).
Composite Attribute Structured data within single attribute (iOS 17+).
Mapping Model Describes how to migrate from one model version to another.
Version Hash Checksum identifying a specific model version.
Threading
perform Executes block asynchronously on context's queue.
performAndWait Executes block synchronously on context's queue (blocks calling thread).
Thread Confinement Each context must be accessed only from its queue.
automaticallyMergesChangesFromParent Context automatically receives changes from parent context.
Validation
validateForInsert Called before inserting object.
validateForUpdate Called before updating object.
validateForDelete Called before deleting object.
Lifecycle
awakeFromInsert Called once when object first inserted.
awakeFromFetch Called when object loaded from store.
willSave Called before each save.
didSave Called after save completes.
prepareForDeletion Called when object marked for deletion.
CloudKit
Container Identifier Unique ID for CloudKit container (e.g., iCloud.com.example.app).
Development Environment CloudKit environment for testing (schema mutable).
Production Environment CloudKit environment for released apps (schema immutable).
Schema Initialization First run creates CloudKit schema from Core Data model.
Event Notification Notification sent when CloudKit sync events occur.
Debugging
SQL Debug Launch argument to log SQL queries: -com.apple.CoreData.SQLDebug 1
Concurrency Debug Launch argument to catch threading violations: -com.apple.CoreData.ConcurrencyDebug 1
Migration Debug Launch argument to log migration steps: -com.apple.CoreData.MigrationDebug 1
Common Acronyms
CD - Core Data MOC - Managed Object Context (NSManagedObjectContext) MO - Managed Object (NSManagedObject) FRC - Fetched Results Controller (NSFetchedResultsController) PSC - Persistent Store Coordinator (NSPersistentStoreCoordinator) MOD - Managed Object Model (NSManagedObjectModel)
Quick Reference
Thread-safe: NSManagedObjectID, NSPersistentStoreCoordinator Not thread-safe: NSManagedObject, NSManagedObjectContext Main thread only: View context operations Background thread: Background context operations Automatic: Lightweight migration (with NSPersistentContainer) Manual: Staged migration, custom mapping models
Schema Migration
Schema migration is the process of updating your Core Data model as your app evolves. Core Data provides three migration strategies: lightweight, staged (iOS 17+), and deferred (iOS 14+).
When Migration is Required
Core Data refuses to open a store when the model doesn't match:
Error: NSPersistentStoreIncompatibleVersionHashErrorThis means: Your data model changed, and you need to migrate.
Lightweight Migration (Recommended)
Lightweight migration is automatic and handles most common changes.
Enabling Lightweight Migration
With NSPersistentContainer (automatic):
let container = NSPersistentContainer(name: "Model")
// Lightweight migration enabled by defaultWith NSPersistentStoreDescription (automatic):
let description = NSPersistentStoreDescription(url: storeURL)
// Lightweight migration enabled by defaultManual setup (if needed):
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)Supported Operations
Attributes:
- Add attribute
- Remove attribute
- Make optional attribute non-optional (with default value)
- Make non-optional attribute optional
- Rename attribute (using renaming identifier)
Relationships:
- Add relationship
- Remove relationship
- Rename relationship (using renaming identifier)
- Change cardinality (to-one ↔ to-many)
- Change ordering (ordered ↔ non-ordered)
Entities:
- Add entity
- Remove entity
- Rename entity (using renaming identifier)
- Create parent/child entity
- Move attributes up/down hierarchy
- Move entities in/out of hierarchy
Cannot do:
- Merge entity hierarchies (entities without common parent can't share parent)
Renaming Attributes/Entities
Set the renaming identifier to the old name:
// In Data Model Editor:
// 1. Rename attribute from "color" to "paintColor"
// 2. Set Renaming Identifier to "color"This allows chaining renames across versions:
- V1:
color - V2:
paintColor(renaming ID:color) - V3:
primaryColor(renaming ID:paintColor)
Migration works: V1→V2, V2→V3, and V1→V3.
Testing Lightweight Migration
// Check if migration is possible
let sourceModel = // ... load V1 model
let destinationModel = // ... load V2 model
if let mappingModel = try? NSMappingModel.inferredMappingModel(
forSourceModel: sourceModel,
destinationModel: destinationModel
) {
print("Lightweight migration possible")
} else {
print("Lightweight migration not possible")
}Composite Attributes (iOS 17+)
New in iOS 17: Structured data within a single attribute.
Creating Composite Attributes
In Data Model Editor: 1. Add Composite Attribute 2. Add elements (String, Int, Date, etc.) 3. Can nest composite attributes
// Example: ColorScheme composite
// - primary: String
// - secondary: String
// - tertiary: String
class Aircraft: NSManagedObject {
@NSManaged var colorScheme: [String: Any]
}
// Usage
aircraft.colorScheme = [
"primary": "Red",
"secondary": "White",
"tertiary": "Blue"
]
// Querying
fetchRequest.predicate = NSPredicate(format: "colorScheme.primary == %@", "Red")Benefits
- No transformable code needed
- Supports predicates with keypaths
- Better than flattened attributes
- Can prevent faulting across relationships
Staged Migration (iOS 17+)
For complex migrations that exceed lightweight capabilities.
When to Use
- Changes don't fit lightweight patterns
- Need to run custom code during migration
- Need to decompose complex changes into steps
Key Classes
NSStagedMigrationManager- Manages migration event loopNSCustomMigrationStage- Custom code executionNSLightweightMigrationStage- Lightweight-eligible changesNSManagedObjectModelReference- Model references with checksums
Example: Denormalizing Data
Problem: Move flightData attribute to separate entity.
Solution: Decompose into stages:
Stage 1 (Lightweight): Add new entity and relationship
// ModelV1 → ModelV2
// Add FlightData entity
// Add flightParameters relationship to AircraftStage 2 (Custom): Copy data
- Fetch rows using generic
NSManagedObject/NSFetchRequestResulttypes. - Create new entities and copy data inside the migration stage handler.
- Ensure the custom logic is restartable if the process is interrupted.
Stage 3 (Lightweight): Remove old attribute
// ModelV3 → ModelV4
// Remove flightData attribute from AircraftGetting Version Checksum
From Xcode build log:
Compile data model Model.xcdatamodeld
version checksum: ABC123...Deferred Migration (iOS 14+)
Defer cleanup work to keep app responsive.
When to Use
- Removing attributes/relationships
- Changing relationship hierarchy
- Changing relationship ordering
- Any migration with expensive cleanup
How It Works
1. Migration runs synchronously (fast) 2. Cleanup (indices, column drops) is deferred 3. App uses latest schema immediately 4. Finish cleanup when resources available
Enabling Deferred Migration
let description = NSPersistentStoreDescription(url: storeURL)
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreDeferredLightweightMigrationOptionKey
)Checking for Pending Work
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: storeURL
)
if let hasDeferredWork = metadata[NSPersistentStoreDeferredLightweightMigrationOptionKey] as? Bool,
hasDeferredWork {
print("Deferred migration work pending")
}Finishing Deferred Migration
func finishDeferredMigration() {
let coordinator = container.persistentStoreCoordinator
do {
try coordinator.finishDeferredLightweightMigration()
print("Deferred migration completed")
} catch {
print("Failed to finish deferred migration: \(error)")
}
}Scheduling with Background Tasks
import BackgroundTasks
// Register task
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.example.app.migration",
using: nil
) { task in
self.handleMigrationTask(task as! BGProcessingTask)
}
// Schedule task
func scheduleMigration() {
let request = BGProcessingTaskRequest(identifier: "com.example.app.migration")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(request)
}
// Handle task
func handleMigrationTask(_ task: BGProcessingTask) {
task.expirationHandler = {
task.setTaskCompleted(success: false)
}
finishDeferredMigration()
task.setTaskCompleted(success: true)
}Migration Debugging
Enable Migration Debug
-com.apple.CoreData.MigrationDebug 1Output:
CoreData: annotation: Migration: Migrating from version 1 to version 2
CoreData: annotation: Migration: Inferred mapping model
CoreData: annotation: Migration: Completed successfullyCommon Errors
NSPersistentStoreIncompatibleVersionHashError
- Model changed, migration required
- Enable lightweight migration or create mapping model
NSMigrationMissingSourceModelError
- Can't find source model
- Ensure all model versions are in bundle
NSMigrationError
- Migration failed
- Check if changes are lightweight-compatible
- Use staged migration for complex changes
Best Practices
1. Test migrations thoroughly - Test upgrade paths from all previous versions 2. Keep model versions - Don't delete old .xcdatamodel files 3. Use lightweight when possible - Simplest and most reliable 4. Decompose complex changes - Use staged migration for non-lightweight changes 5. Defer expensive cleanup - Use deferred migration for large datasets 6. Version your models - Create new model version for each release 7. Test on real data - Migration behavior differs with large datasets 8. Document changes - Keep migration notes for future reference
Testing Migrations
func testMigration() throws {
// 1. Create store with old model
let oldModelURL = Bundle.main.url(forResource: "ModelV1", withExtension: "momd")!
let oldModel = NSManagedObjectModel(contentsOf: oldModelURL)!
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: oldModel)
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: nil
)
// 2. Add test data
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.persistentStoreCoordinator = coordinator
let entity = NSEntityDescription.insertNewObject(forEntityName: "Article", into: context)
entity.setValue("Test", forKey: "name")
try context.save()
// 3. Close store
try coordinator.remove(coordinator.persistentStores.first!)
// 4. Migrate with new model
let newModelURL = Bundle.main.url(forResource: "ModelV2", withExtension: "momd")!
let newModel = NSManagedObjectModel(contentsOf: newModelURL)!
let newCoordinator = NSPersistentStoreCoordinator(managedObjectModel: newModel)
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try newCoordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)
// 5. Verify data
let newContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
newContext.persistentStoreCoordinator = newCoordinator
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Article")
let results = try newContext.fetch(fetchRequest)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.value(forKey: "name") as? String, "Test")
}Summary
1. Use lightweight migration - Handles most common changes automatically 2. Enable by default - NSPersistentContainer enables it automatically 3. Use renaming identifiers - For renaming attributes/entities/relationships 4. Use composite attributes (iOS 17+) - For structured data 5. Use staged migration (iOS 17+) - For complex, non-lightweight changes 6. Use deferred migration (iOS 14+) - For expensive cleanup operations 7. Test thoroughly - Verify all upgrade paths 8. Keep all model versions - Required for migration 9. Enable migration debug - Helps diagnose issues 10. Document changes - Track what changed in each version
Model Configuration
Core Data's data model offers powerful configuration options beyond basic attributes and relationships. This guide covers constraints, derived attributes, transformables, validation, and lifecycle events.
Constraints
Constraints ensure uniqueness of attribute values. When combined with the correct merge policy, Core Data automatically handles duplicates.
Setting Up Constraints
In Xcode's Data Model Editor: 1. Select your entity 2. In the Data Model Inspector, find "Constraints" 3. Click "+" and add attribute names
Example: Make name unique in the Category entity.
Required Merge Policy
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicyWithout this merge policy, constraint violations will crash your app.
How Constraints Work
// First save
let category1 = Category(context: context)
category1.name = "Swift"
try context.save() // Saves successfully
// Duplicate attempt
let category2 = Category(context: context)
category2.name = "Swift" // Same name
try context.save() // With correct merge policy: keeps first, discards secondMultiple Constraints
// Constraints on multiple attributes
// In model: constraints = ["email", "username"]
// Both must be unique
user1.email = "test@example.com"
user1.username = "testuser"Compound Constraints
// Unique combination of attributes
// In model: constraints = ["firstName,lastName"]
// These are different (unique combinations)
person1.firstName = "John"
person1.lastName = "Doe"
person2.firstName = "John"
person2.lastName = "Smith" // Different combination, allowedDerived Attributes
Derived attributes are computed from other attributes or relationships and stored in the database. They're calculated on save or refresh.
Benefits
- No need to manually update computed values
- Better performance than accessing relationships
- Optimized for queries
Common Derivations
1. Count of Relationships
// In Data Model Editor:
// Derived attribute: articlesCount
// Derivation: articles.@countWhy this is better than `articles.count`:
- Doesn't fire faults
- Faster queries
- Always up-to-date after save
2. Related Object Property
// Derived attribute: categoryName
// Derivation: category.nameUse case: Avoid firing faults when displaying list views.
3. Current Timestamp
// Derived attribute: lastModified
// Derivation: now()Automatically updates on every save.
4. Canonical String (Search Optimization)
// Derived attribute: searchName
// Derivation: canonical:(name)What it does:
- Converts to lowercase
- Removes diacritics
- Perfect for case-insensitive, diacritic-insensitive searches
Example:
// name = "Café"
// searchName = "cafe"
// Search query
fetchRequest.predicate = NSPredicate(format: "searchName CONTAINS %@", "cafe")
// Matches "Café", "CAFE", "café", etc.5. Sum of Related Values
// Derived attribute: totalViews
// Derivation: @sum.articles.viewsImportant Notes
- Derived attributes are calculated on save or refresh
- In-memory changes don't update derived attributes until saved
- Can't be set manually (they're computed)
Example Usage
class Article: NSManagedObject {
@NSManaged var name: String
@NSManaged var category: Category?
// Derived from category.name
@NSManaged var categoryName: String?
// Derived from canonical:(name)
@NSManaged var searchName: String?
}
// Usage
article.name = "Core Data Best Practices"
try context.save()
// After save, derived attributes are updated
print(article.searchName) // "core data best practices"
print(article.categoryName) // "Swift"Transformables
Transformables allow storing custom types that aren't natively supported by Core Data.
Creating a Value Transformer
import UIKit
@objc(ColorTransformer)
class ColorTransformer: ValueTransformer {
override class func transformedValueClass() -> AnyClass {
return NSData.self
}
override class func allowsReverseTransformation() -> Bool {
return true
}
override func transformedValue(_ value: Any?) -> Any? {
guard let color = value as? UIColor else { return nil }
do {
let data = try NSKeyedArchiver.archivedData(
withRootObject: color,
requiringSecureCoding: true
)
return data
} catch {
print("Failed to transform color: \(error)")
return nil
}
}
override func reverseTransformedValue(_ value: Any?) -> Any? {
guard let data = value as? Data else { return nil }
do {
let color = try NSKeyedUnarchiver.unarchivedObject(
ofClass: UIColor.self,
from: data
)
return color
} catch {
print("Failed to reverse transform color: \(error)")
return nil
}
}
}Registering the Transformer
// In your stack setup, before loading stores
ValueTransformer.setValueTransformer(
ColorTransformer(),
forName: NSValueTransformerName("ColorTransformer")
)Configuring in Data Model
1. Select the attribute 2. Set Type to "Transformable" 3. Set "Custom Class" to your type (e.g., UIColor) 4. Set "Transformer" to your transformer name (e.g., ColorTransformer)
Using Transformable Attributes
class Article: NSManagedObject {
@NSManaged var color: UIColor?
}
// Usage
article.color = .systemBlue
try context.save()
// Retrieval
let color = article.color // UIColorNSSecureCoding Requirement
Modern Core Data requires secure coding:
// Make your custom type conform to NSSecureCoding
extension CustomType: NSSecureCoding {
static var supportsSecureCoding: Bool { return true }
func encode(with coder: NSCoder) {
// Encode properties
}
required init?(coder: NSCoder) {
// Decode properties
}
}Validation
Core Data provides built-in validation that runs before saving.
Model-Level Validation
Set in Data Model Editor:
String Validation:
- Minimum Length
- Maximum Length
- Regular Expression
Numeric Validation:
- Minimum Value
- Maximum Value
Example:
Attribute: name
Type: String
Min Length: 3
Max Length: 100Code-Level Validation
Override validation methods in your NSManagedObject subclass:
class Article: NSManagedObject {
@NSManaged var name: String?
// Validate before insert
override func validateForInsert() throws {
try super.validateForInsert()
try validateName()
}
// Validate before update
override func validateForUpdate() throws {
try super.validateForUpdate()
try validateName()
}
// Validate before delete
override func validateForDelete() throws {
try super.validateForDelete()
// Example: Can't delete if has related objects
if let attachments = attachments, !attachments.isEmpty {
throw NSError(
domain: "ArticleValidation",
code: 1001,
userInfo: [NSLocalizedDescriptionKey: "Cannot delete article with attachments"]
)
}
}
// Custom validation
private func validateName() throws {
guard let name = name, !name.isEmpty else {
throw NSError(
domain: "ArticleValidation",
code: 1000,
userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]
)
}
// Check for protected names
let protectedNames = ["Admin", "System", "Root"]
if protectedNames.contains(name) {
throw NSError(
domain: "ArticleValidation",
code: 1002,
userInfo: [NSLocalizedDescriptionKey: "'\(name)' is a protected name"]
)
}
}
}Property-Level Validation
class Article: NSManagedObject {
@NSManaged var name: String?
override func validateName(_ value: AutoreleasingUnsafeMutablePointer<AnyObject?>) throws {
guard let name = value.pointee as? String, !name.isEmpty else {
throw NSError(
domain: "ArticleValidation",
code: 1000,
userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]
)
}
}
}Handling Validation Errors
do {
try context.save()
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain {
switch error.code {
case NSValidationStringTooShortError:
print("String too short")
case NSValidationStringTooLongError:
print("String too long")
case NSManagedObjectValidationError:
print("Validation failed")
default:
print("Other error: \(error.localizedDescription)")
}
}
}Lifecycle Events
Override lifecycle methods to perform actions at specific points in an object's life.
awakeFromInsert()
Called once when object is first inserted into context.
override func awakeFromInsert() {
super.awakeFromInsert()
// Set default values
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
setPrimitiveValue(0, forKey: #keyPath(Article.views))
}Use `setPrimitiveValue` to avoid:
- KVO notifications
- Marking object as changed
- Infinite loops
willSave()
Called before every save. Use for updating modification dates or cleaning up.
override func willSave() {
super.willSave()
// Update modification date
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
// Delete local files if object is deleted
if isDeleted, let localResource = localResourceURL {
try? FileManager.default.removeItem(at: localResource)
}
}Caution: Don't call save() inside willSave() - infinite loop!
didSave()
Called after save completes.
override func didSave() {
super.didSave()
// Post notification, update cache, etc.
NotificationCenter.default.post(
name: .articleDidSave,
object: self
)
}prepareForDeletion()
Called when object is marked for deletion (before save).
override func prepareForDeletion() {
super.prepareForDeletion()
// Cancel ongoing operations
downloadTask?.cancel()
// Don't delete files here! Use willSave() instead
// (prepareForDeletion is called even if save is rolled back)
}Important: Don't delete files in prepareForDeletion(). The deletion might be rolled back, leaving your data inconsistent.
awakeFromFetch()
Called when object is fetched from store.
override func awakeFromFetch() {
super.awakeFromFetch()
// Initialize transient properties
setupObservers()
}Complete Lifecycle Example
class Article: NSManagedObject {
@NSManaged var name: String?
@NSManaged var creationDate: Date?
@NSManaged var lastModified: Date?
@NSManaged var localResourceURL: URL?
override func awakeFromInsert() {
super.awakeFromInsert()
// Set creation date once
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
}
override func willSave() {
super.willSave()
// Update modification date on every save
if !isDeleted && changedValues().keys.contains("name") {
setPrimitiveValue(Date(), forKey: #keyPath(Article.lastModified))
}
// Clean up files when deleted
if isDeleted, let url = localResourceURL {
try? FileManager.default.removeItem(at: url)
}
}
override func prepareForDeletion() {
super.prepareForDeletion()
// Cancel ongoing operations
// Don't delete files here!
}
}Common Pitfalls
❌ Not Setting Merge Policy with Constraints
// Constraint violation will crash
let category = Category(context: context)
category.name = "Duplicate"
try context.save() // CRASH!❌ Manually Setting Derived Attributes
// Derived attributes are read-only
article.categoryName = "Swift" // Ignored!❌ Using KVO Methods in Lifecycle Events
override func awakeFromInsert() {
super.awakeFromInsert()
// ❌ Triggers KVO, marks as changed
self.creationDate = Date()
// ✅ Use primitive values
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
}❌ Deleting Files in prepareForDeletion
override func prepareForDeletion() {
super.prepareForDeletion()
// ❌ Bad: Deletion might be rolled back
try? FileManager.default.removeItem(at: fileURL)
}✅ Correct Approaches
// Set merge policy
viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
// Let derived attributes compute themselves
article.name = "New Name"
try context.save()
print(article.searchName) // Automatically updated
// Use primitive values in lifecycle events
setPrimitiveValue(Date(), forKey: #keyPath(Article.creationDate))
// Delete files in willSave when isDeleted
override func willSave() {
super.willSave()
if isDeleted {
try? FileManager.default.removeItem(at: fileURL)
}
}Summary
1. Use constraints for uniqueness - Requires NSMergeByPropertyStoreTrumpMergePolicy 2. Use derived attributes - Better performance than accessing relationships 3. Use canonical: for search - Case and diacritic insensitive 4. Use transformables for custom types - With NSSecureCoding 5. Validate in code - For complex business rules 6. Use awakeFromInsert for defaults - Called once on creation 7. Use willSave for updates - Called before every save 8. Use setPrimitiveValue - Avoid KVO in lifecycle events 9. Delete files in willSave - When isDeleted is true 10. Don't save in willSave - Causes infinite loop
Performance Optimization
Optimizing Core Data performance requires understanding where bottlenecks occur and applying targeted solutions.
Profiling with Instruments
Time Profiler
1. In Xcode: Product → Profile 2. Select Time Profiler 3. Record while using app 4. Find heaviest stack traces
Look for:
- Excessive faulting
- Slow fetch requests
- Save operations taking too long
Allocations Instrument
1. Product → Profile 2. Select Allocations 3. Monitor memory growth 4. Identify retained objects
Look for:
- Unbounded memory growth
- Objects not being released
- Large allocations
SQL Debug Logging
Enable SQL logging:
-com.apple.CoreData.SQLDebug 1Output:
CoreData: sql: SELECT Z_PK, ZNAME FROM ZARTICLE WHERE ZVIEWS > ? LIMIT 20
CoreData: annotation: sql execution time: 0.0023sAnalyze:
- Query complexity
- Execution time
- Number of queries (N+1 problem)
Common Performance Issues
1. N+1 Query Problem
Problem:
// Fetches articles
let articles = try context.fetch(Article.fetchRequest())
// Each access fires a fault (N queries)
for article in articles {
print(article.category?.name) // Fault!
}Solution:
let fetchRequest = Article.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
let articles = try context.fetch(fetchRequest)
// No faults fired
for article in articles {
print(article.category?.name) // Already loaded
}2. Fetching Too Much Data
Problem:
// Fetches all properties of all objects
let articles = try context.fetch(Article.fetchRequest())
let count = articles.countSolution:
// Only counts, doesn't fetch objects
let count = try context.count(for: Article.fetchRequest())3. Not Using Batch Sizes
Problem:
// Loads 10,000 objects into memory
let fetchRequest = Article.fetchRequest()
let articles = try context.fetch(fetchRequest)Solution:
fetchRequest.fetchBatchSize = 20
// Only loads 20 at a time4. Fetching Unnecessary Properties
Problem:
// Fetches all properties
let fetchRequest = Article.fetchRequest()Solution:
fetchRequest.propertiesToFetch = ["name", "creationDate"]
// Only fetches needed properties5. Saving Too Frequently
Problem:
for item in items {
item.processed = true
try? context.save() // Very slow!
}Solution:
for item in items {
item.processed = true
}
try? context.save() // Save once6. Not Resetting Context
Problem:
// Context accumulates objects
for i in 0..<10000 {
let article = Article(context: context)
// Memory grows unbounded
}Solution:
for i in 0..<10000 {
let article = Article(context: context)
if i % 100 == 0 {
try? context.save()
context.reset() // Clear memory
}
}Memory Management
Context Reset
context.reset()When to use:
- After processing large batches
- When context accumulates many objects
- To free memory
Caution: Invalidates all fetched objects from this context.
Refresh Objects
context.refresh(article, mergeChanges: false)When to use:
- Discard in-memory changes
- Free memory for specific object
- Reload from database
Turn Objects into Faults
context.refreshAllObjects()When to use:
- Free memory across all objects
- After large operations
- When memory is constrained
Fetch Request Optimization
Checklist
let fetchRequest = Article.fetchRequest()
// ✅ Set batch size
fetchRequest.fetchBatchSize = 20
// ✅ Limit properties
fetchRequest.propertiesToFetch = ["name", "views"]
// ✅ Prefetch relationships
fetchRequest.relationshipKeyPathsForPrefetching = ["category"]
// ✅ Use predicate to filter
fetchRequest.predicate = NSPredicate(format: "views > %d", 100)
// ✅ Set fetch limit if applicable
fetchRequest.fetchLimit = 10
// ✅ Specify sort descriptors
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]Batch Operations
For large-scale operations, use batch requests:
// Instead of:
for article in articles {
article.isRead = true
}
try context.save()
// Use:
let batchUpdate = NSBatchUpdateRequest(entityName: "Article")
batchUpdate.propertiesToUpdate = ["isRead": true]
try context.execute(batchUpdate)Benefits:
- 10-20x faster
- Lower memory usage
- SQL-level operations
Data Generators for Testing
Create reproducible test datasets:
class DataGenerator {
func generate(count: Int, in context: NSManagedObjectContext) {
for i in 0..<count {
let article = Article(context: context)
article.name = "Article \(i)"
if i % 100 == 0 {
try? context.save()
context.reset()
}
}
try? context.save()
}
}
// Usage
let generator = DataGenerator()
generator.generate(count: 10000, in: backgroundContext)Profiling Checklist
1. Enable SQL debug - See actual queries 2. Profile with Time Profiler - Find slow operations 3. Profile with Allocations - Find memory issues 4. Test with realistic data - Small datasets hide problems 5. Monitor on device - Simulator performance differs 6. Test on older devices - Performance varies
Quick Wins
1. Use `count(for:)` instead of fetching - 100x faster 2. Set `fetchBatchSize` - Reduces memory 3. Prefetch relationships - Eliminates N+1 queries 4. Use `propertiesToFetch` - Reduces data transfer 5. Reset context periodically - Frees memory 6. Use batch operations - 10-20x faster for bulk changes 7. Save conditionally - Check hasPersistentChanges 8. Use background contexts - Keep UI responsive
Summary
1. Profile first - Measure before optimizing 2. Use Instruments - Time Profiler and Allocations 3. Enable SQL debug - Understand query behavior 4. Optimize fetch requests - Batch size, properties, prefetching 5. Use batch operations - For large-scale changes 6. Reset contexts - Free memory periodically 7. Test with real data - Small datasets hide issues 8. Monitor on devices - Real-world performance matters
Persistent History Tracking
Persistent history tracking enables Core Data to track changes across contexts, app extensions, and batch operations. This is essential for keeping your UI synchronized and supporting multi-target apps.
Why Persistent History Tracking?
Without persistent history tracking:
- Batch operations don't update UI
- App extensions can't notify main app of changes
- Multiple contexts don't stay synchronized
With persistent history tracking:
- All changes are recorded in a transaction log
- Changes can be merged into any context
- Works across app targets (main app, extensions, etc.)
Enabling Persistent History Tracking
In NSPersistentContainer
class PersistentContainer: NSPersistentContainer {
override init(name: String, managedObjectModel model: NSManagedObjectModel) {
super.init(name: name, managedObjectModel: model)
guard let description = persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable persistent history tracking
description.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
// Enable remote change notifications
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
}
}For App Groups (Extensions)
let storeURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.app"
)?.appendingPathComponent("Shared.sqlite")
let description = NSPersistentStoreDescription(url: storeURL!)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.persistentStoreDescriptions = [description]The Four Components
Persistent history tracking typically involves four components:
1. Observer - Listens for remote change notifications 2. Fetcher - Retrieves relevant transactions 3. Merger - Merges transactions into view context 4. Cleaner - Removes old transactions
1. Observer: Listening for Changes
final class PersistentHistoryObserver {
private let coordinator: NSPersistentStoreCoordinator
private let historyContext: NSManagedObjectContext
private let merger: PersistentHistoryMerger
init(container: NSPersistentContainer, viewContext: NSManagedObjectContext) {
self.coordinator = container.persistentStoreCoordinator
self.historyContext = container.newBackgroundContext()
self.historyContext.name = "PersistentHistoryContext"
self.historyContext.transactionAuthor = "PersistentHistory"
self.merger = PersistentHistoryMerger(historyContext: historyContext, viewContext: viewContext)
NotificationCenter.default.addObserver(
self,
selector: #selector(processStoreRemoteChanges),
name: .NSPersistentStoreRemoteChange,
object: coordinator
)
}
@objc private func processStoreRemoteChanges(_ notification: Notification) {
merger.merge()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}2. Fetcher: Retrieving Transactions
class PersistentHistoryFetcher {
private let context: NSManagedObjectContext
private let lastToken: NSPersistentHistoryToken?
init(context: NSManagedObjectContext, lastToken: NSPersistentHistoryToken?) {
self.context = context
self.lastToken = lastToken
}
func fetch() throws -> [NSPersistentHistoryTransaction] {
let fetchRequest = createFetchRequest()
guard let historyResult = try context.execute(fetchRequest) as? NSPersistentHistoryResult,
let transactions = historyResult.result as? [NSPersistentHistoryTransaction] else {
return []
}
return transactions
}
private func createFetchRequest() -> NSPersistentHistoryChangeRequest {
let request: NSPersistentHistoryChangeRequest
if let token = lastToken {
request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
} else {
request = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
}
// Filter out transactions from this app target
if let fetchRequest = request.fetchRequest {
fetchRequest.predicate = NSPredicate(
format: "author != %@",
"MainApp" // Your app's transaction author
)
}
return request
}
}3. Merger: Applying Changes
final class PersistentHistoryMerger {
private let historyContext: NSManagedObjectContext
private let viewContext: NSManagedObjectContext
private var lastToken: NSPersistentHistoryToken?
init(historyContext: NSManagedObjectContext, viewContext: NSManagedObjectContext) {
self.historyContext = historyContext
self.viewContext = viewContext
self.lastToken = loadLastToken()
}
func merge() {
historyContext.perform {
do {
let fetcher = PersistentHistoryFetcher(
context: self.historyContext,
lastToken: self.lastToken
)
let transactions = try fetcher.fetch()
guard !transactions.isEmpty else { return }
self.viewContext.perform {
self.mergeTransactions(transactions)
}
if let newToken = transactions.last?.token {
self.lastToken = newToken
self.saveLastToken(newToken)
}
} catch {
print("Failed to merge history: \(error)")
}
}
}
private func mergeTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else { continue }
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [viewContext])
}
}
private func loadLastToken() -> NSPersistentHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: "lastHistoryToken") else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(
ofClass: NSPersistentHistoryToken.self,
from: data
)
}
private func saveLastToken(_ token: NSPersistentHistoryToken) {
if let data = try? NSKeyedArchiver.archivedData(
withRootObject: token,
requiringSecureCoding: true
) {
UserDefaults.standard.set(data, forKey: "lastHistoryToken")
}
}
}4. Cleaner: Removing Old Transactions
class PersistentHistoryCleaner {
private let context: NSManagedObjectContext
private let targets: [AppTarget]
enum AppTarget {
case mainApp
case shareExtension
case widgetExtension
var lastTokenKey: String {
switch self {
case .mainApp: return "mainApp.lastHistoryToken"
case .shareExtension: return "shareExtension.lastHistoryToken"
case .widgetExtension: return "widgetExtension.lastHistoryToken"
}
}
}
init(context: NSManagedObjectContext, targets: [AppTarget]) {
self.context = context
self.targets = targets
}
func clean() {
context.perform {
// Find the oldest token across all targets
guard let oldestToken = self.findOldestToken() else { return }
// Delete history before that token
let deleteRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: oldestToken)
do {
try self.context.execute(deleteRequest)
} catch {
print("Failed to clean history: \(error)")
}
}
}
private func findOldestToken() -> NSPersistentHistoryToken? {
var oldestDate: Date?
var oldestToken: NSPersistentHistoryToken?
for target in targets {
guard let token = loadToken(for: target) else { continue }
// Get timestamp from token (requires fetching transaction)
let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
historyRequest.fetchRequest?.fetchLimit = 1
guard let result = try? context.execute(historyRequest) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction],
let transaction = transactions.first else {
continue
}
let date = transaction.timestamp
if oldestDate == nil || date < oldestDate! {
oldestDate = date
oldestToken = token
}
}
return oldestToken
}
private func loadToken(for target: AppTarget) -> NSPersistentHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: target.lastTokenKey) else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(
ofClass: NSPersistentHistoryToken.self,
from: data
)
}
}Complete Integration Example
class CoreDataStack {
static let shared = CoreDataStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "Model")
// Configure store
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
self.setupHistoryTracking(container: container)
}
// Configure view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.name = "ViewContext"
container.viewContext.transactionAuthor = "MainApp"
return container
}()
private var historyObserver: PersistentHistoryObserver?
private init() {}
private func setupHistoryTracking(container: NSPersistentContainer) {
historyObserver = PersistentHistoryObserver(container: container, viewContext: container.viewContext)
cleanHistoryPeriodically(container: container)
}
private func cleanHistoryPeriodically(container: NSPersistentContainer) {
Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { _ in
let context = container.newBackgroundContext()
let cleaner = PersistentHistoryCleaner(
context: context,
targets: [.mainApp, .shareExtension]
)
cleaner.clean()
}
}
}Transaction Authors
Set unique transaction authors for each app target:
// Main app
viewContext.transactionAuthor = "MainApp"
// Share extension
viewContext.transactionAuthor = "ShareExtension"
// Widget extension
viewContext.transactionAuthor = "WidgetExtension"Why this matters:
- Filter out your own transactions (avoid redundant merges)
- Identify which target made changes
- Debug multi-target issues
Filtering Transactions
By Author
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let request = fetchRequest.fetchRequest {
request.predicate = NSPredicate(format: "author != %@", "MainApp")
}By Date
let cutoffDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: cutoffDate)By Entity
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let request = fetchRequest.fetchRequest {
request.predicate = NSPredicate(format: "ANY changes.changedObjectID.entity.name == %@", "Article")
}Batch Operations Integration
Persistent history tracking is required for batch operations to update the UI:
// 1. Enable persistent history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// 2. Perform batch operation
let context = container.newBackgroundContext()
context.perform {
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
// Insert logic
return false
}
try? context.execute(batchInsert)
}
// 3. UI updates automatically via persistent history tracking
// The observer detects the change and merges it into the view contextTesting Persistent History
func testPersistentHistory() throws {
// Enable persistent history
let description = container.persistentStoreDescriptions.first!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Create object in background
let backgroundContext = container.newBackgroundContext()
backgroundContext.transactionAuthor = "Test"
let expectation = XCTestExpectation(description: "Save")
backgroundContext.perform {
let article = Article(context: backgroundContext)
article.name = "Test"
try? backgroundContext.save()
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
// Fetch history
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
let result = try container.viewContext.execute(fetchRequest) as? NSPersistentHistoryResult
let transactions = result?.result as? [NSPersistentHistoryTransaction]
XCTAssertNotNil(transactions)
XCTAssertFalse(transactions!.isEmpty)
}Common Pitfalls
❌ Not Enabling Remote Change Notifications
// Only this isn't enough
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Need both!
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)❌ Not Filtering Own Transactions
// Merges own transactions (redundant)
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)❌ Not Cleaning Old Transactions
// History grows unbounded, wastes space
// Always implement cleaning!❌ Not Setting Transaction Authors
// Can't filter transactions by source
context.transactionAuthor = nil // Bad!✅ Correct Approach
// 1. Enable both options
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// 2. Set transaction author
context.transactionAuthor = "MainApp"
// 3. Filter own transactions
fetchRequest.predicate = NSPredicate(format: "author != %@", "MainApp")
// 4. Clean periodically
let cleaner = PersistentHistoryCleaner(context: context, targets: [.mainApp, .shareExtension])
cleaner.clean()Performance Considerations
Clean History Regularly
// Clean daily
Timer.scheduledTimer(withTimeInterval: 86400, repeats: true) { _ in
cleaner.clean()
}
// Or on app launch
func applicationDidFinishLaunching() {
cleaner.clean()
}Limit Fetch Range
// Don't fetch all history
let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
let fetchRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: sevenDaysAgo)Batch Merge Changes
// Merge multiple transactions at once
let transactions = try fetcher.fetch()
for transaction in transactions {
let userInfo = transaction.objectIDNotification().userInfo
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: userInfo!,
into: [viewContext]
)
}Summary
1. Enable persistent history tracking - Required for batch operations and multi-target apps 2. Enable remote change notifications - Required for cross-context updates 3. Set transaction authors - Identify change sources 4. Filter own transactions - Avoid redundant merges 5. Implement all four components - Observer, Fetcher, Merger, Cleaner 6. Clean history regularly - Prevent unbounded growth 7. Use with batch operations - Essential for UI updates 8. Test thoroughly - Verify history tracking works across targets
Project Audit (Core Data)
Use this checklist to quickly discover how a project uses Core Data and which constraints apply (platform availability, CloudKit, history tracking, etc.).
Determine platform constraints
- Find the deployment target (iOS/macOS version). Many recommendations depend on this (e.g. staged migration and composite attributes require iOS 17+/macOS 14+).
- Note whether the project is Swift 6 / strict concurrency enabled (Sendable and isolation warnings change the advice).
Inspect the data model
- Open the model XML (
*.xcdatamodeld/*/contents) and check: - entities, attributes, relationships, constraints
- versioning setup (multiple model versions)
- renaming identifiers (for lightweight migration)
- composite attributes (iOS 17+)
Identify stack setup
Search for:
NSPersistentContainervsNSPersistentCloudKitContainerloadPersistentStoresconfigurationpersistentStoreDescriptions(migration options, history tracking, CloudKit options)viewContextconfiguration (merge policy,automaticallyMergesChangesFromParent, query generations)- background context creation (
newBackgroundContext,performBackgroundTask)
Then consult:
stack-setup.mdfor recommended defaults and merge policiescloudkit-integration.mdif CloudKit is enabled
Check for persistent history tracking (required for some flows)
Search for:
NSPersistentHistoryTrackingKeyNSPersistentStoreRemoteChangeNotificationPostOptionKey- remote change notifications and history processing/merging
Then consult:
persistent-history.mdfor the Observer/Fetcher/Merger/Cleaner pattern
Spot risky concurrency patterns
Search for:
- cross-thread access to managed objects (look for passing
NSManagedObjectinto async tasks/closures) performAndWaitusage (risk of deadlocks / UI blocking)@unchecked Sendableapplied to Core Data types (usually hides a real problem)
Then consult:
threading.mdandconcurrency.md
Useful debugging flags (for repro builds only)
-com.apple.CoreData.ConcurrencyDebug 1(threading violations)-com.apple.CoreData.SQLDebug 1(SQL logging)
Testing Core Data
Testing Core Data requires special setup to avoid conflicts and ensure fast, reliable tests.
In-Memory Stores
Use in-memory stores for fast, isolated tests:
class CoreDataTestCase: XCTestCase {
var container: NSPersistentContainer!
var context: NSManagedObjectContext!
override func setUp() {
super.setUp()
container = NSPersistentContainer(name: "Model", managedObjectModel: Self.sharedModel)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
XCTAssertNil(error)
}
context = container.viewContext
}
override func tearDown() {
context = nil
container = nil
super.tearDown()
}
}Shared Model Pattern
Problem: Multiple model instances cause entity description conflicts.
Error:
Failed to find a unique match for an NSEntityDescriptionSolution: Use shared model instance:
extension NSManagedObjectModel {
static let shared: NSManagedObjectModel = {
guard let modelURL = Bundle.main.url(forResource: "Model", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Failed to load model")
}
return model
}()
}
// Use in tests
container = NSPersistentContainer(name: "Model", managedObjectModel: .shared)Data Generators
Create reproducible test data:
class TestDataGenerator {
static func createArticle(
name: String = "Test Article",
views: Int = 0,
in context: NSManagedObjectContext
) -> Article {
let article = Article(context: context)
article.name = name
article.views = Int64(views)
article.creationDate = Date()
return article
}
static func createArticles(
count: Int,
in context: NSManagedObjectContext
) -> [Article] {
return (0..<count).map { i in
createArticle(name: "Article \(i)", in: context)
}
}
}
// Usage
func testFetchArticles() throws {
let articles = TestDataGenerator.createArticles(count: 10, in: context)
try context.save()
let fetchRequest = Article.fetchRequest()
let results = try context.fetch(fetchRequest)
XCTAssertEqual(results.count, 10)
}Testing Fetch Requests
func testFetchWithPredicate() throws {
// Setup
TestDataGenerator.createArticle(name: "Swift", views: 100, in: context)
TestDataGenerator.createArticle(name: "iOS", views: 50, in: context)
try context.save()
// Test
let fetchRequest = Article.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "views > %d", 75)
let results = try context.fetch(fetchRequest)
// Verify
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.name, "Swift")
}Testing Saves
func testSaveArticle() throws {
let article = TestDataGenerator.createArticle(in: context)
XCTAssertTrue(context.hasChanges)
try context.save()
XCTAssertFalse(context.hasChanges)
// Verify persistence
let fetchRequest = Article.fetchRequest()
let results = try context.fetch(fetchRequest)
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results.first?.name, "Test Article")
}Testing Validation
func testValidation() {
let article = Article(context: context)
article.name = "" // Invalid
XCTAssertThrowsError(try context.save()) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSCocoaErrorDomain)
}
}Testing Relationships
func testArticleCategoryRelationship() throws {
let category = Category(context: context)
category.name = "Swift"
let article = Article(context: context)
article.name = "Test"
article.category = category
try context.save()
XCTAssertEqual(article.category?.name, "Swift")
XCTAssertTrue(category.articles?.contains(article) ?? false)
}Testing Threading
func testBackgroundContext() {
let expectation = XCTestExpectation(description: "Background save")
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
let article = Article(context: backgroundContext)
article.name = "Background Article"
do {
try backgroundContext.save()
expectation.fulfill()
} catch {
XCTFail("Save failed: \(error)")
}
}
wait(for: [expectation], timeout: 5.0)
}Testing CloudKit Sync
func testCloudKitExport() {
let expectation = XCTestExpectation(description: "Export")
let observer = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: nil
) { notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else {
return
}
if event.type == .export && event.endDate != nil {
expectation.fulfill()
}
}
let article = Article(context: context)
article.name = "Test"
try? context.save()
wait(for: [expectation], timeout: 60)
NotificationCenter.default.removeObserver(observer)
}Performance Testing
func testBatchInsertPerformance() {
measure {
let context = container.newBackgroundContext()
context.performAndWait {
var index = 0
let batchInsert = NSBatchInsertRequest(entity: Article.entity()) { object in
guard index < 1000 else { return true }
guard let article = object as? Article else { return true }
article.name = "Article \(index)"
index += 1
return false
}
try? context.execute(batchInsert)
}
}
}Test Utilities
extension XCTestCase {
func createTestContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(
name: "Model",
managedObjectModel: .shared
)
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
let expectation = self.expectation(description: "Load store")
container.loadPersistentStores { _, error in
XCTAssertNil(error)
expectation.fulfill()
}
waitForExpectations(timeout: 5.0)
return container
}
}Best Practices
1. Use in-memory stores - Fast, isolated tests 2. Use shared model - Avoid entity description conflicts 3. Create data generators - Reproducible test data 4. Test on background contexts - Verify threading 5. Use expectations - For asynchronous operations 6. Measure performance - Use measure blocks 7. Clean up - Reset context between tests 8. Test validation - Verify business rules 9. Test relationships - Ensure integrity 10. Test migrations - Verify upgrade paths
Summary
- Use in-memory stores for fast tests
- Share model instance to avoid conflicts
- Create data generators for reproducible tests
- Test fetch requests, saves, validation, and relationships
- Use expectations for async operations
- Measure performance with
measureblocks - Test threading with background contexts
- Test CloudKit sync with event notifications
Related skills
FAQ
What is core-data-expert?
Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persisten
When should I use core-data-expert?
Expert Core Data guidance (iOS/macOS): stack setup, fetch requests & NSFetchedResultsController, saving/merge conflicts, threading & Swift Concurrency, batch operations & persisten
Is core-data-expert safe to install?
Review the Security Audits panel on this page before production use.