Comprehensive guide for Apple-platform data persistence: SwiftData, Core Data, CloudKit, UserDefaults, FileManager, and Keychain with migrations, sync patterns, and security. Use when: building iOS/macOS apps with local or cloud-synced data, schema design, data migration planning, SwiftData vs Core Data decisions, CloudKit sync implementation, offline-first architectures, or troubleshooting persistence issues.
Batteries-included skill for Apple-platform data layers: SwiftData, Core Data, CloudKit, UserDefaults, FileManager, and Keychain — with migrations, offline-first sync patterns, schema generators, and production-ready best practices.
⚠️ CRITICAL: SwiftData evolves rapidly. Core Data is mature but still largely unchanged since iOS 18, while SwiftData has seen incremental fixes in iOS 19 (model inheritance and persistent history). Always verify current capabilities before committing to a persistence strategy.
flowchart TD
Start{Need data<br/>persistence?}
Start -->|Simple prefs| UD[UserDefaults]
Start -->|Structured data| DB{New or<br/>existing?}
Start -->|Files/documents| FS[FileManager]
Start -->|Secrets/tokens| KC[Keychain]
DB -->|New app| iOS{Target<br/>iOS version?}
DB -->|Existing Core Data| Migrate{Migrate to<br/>SwiftData?}
iOS -->|iOS 17+ only| Complexity{Data<br/>complexity?}
iOS -->|iOS 16 or earlier| CD[Core Data]
Complexity -->|Simple/moderate| Consider[Consider SwiftData<br/>⚠️ Check current issues]
Complexity -->|Complex/production| CD
Migrate -->|Yes| Risk[⚠️ High risk<br/>Test thoroughly]
Migrate -->|No| CD
Consider --> Verify[✓ Search current status<br/>✓ Test performance<br/>✓ Verify features work]
style Consider fill:#fff3cd
style Risk fill:#f8d7da
style Verify fill:#d1ecf1
Decision Matrix (October 2025):
| Factor | SwiftData | Core Data |
|---|---|---|
| iOS Version | 17+ only | All versions |
| Maturity | ⚠️ Rapidly evolving, breaking changes in iOS 18 | ✅ Mature, stable |
| Performance | ⚠️ 2x memory vs Core Data (iOS 18) | ✅ Optimized |
| CloudKit Sync | ⚠️ Private DB only, reliability issues | ✅ NSPersistentCloudKitContainer proven |
| Migration Tools | ⚠️ Limited, no heavyweight | ✅ Full lightweight + heavyweight |
| Documentation | ⚠️ Sparse, community-driven | ✅ Extensive, 15+ years |
| Learning Curve | ✅ SwiftUI-native, cleaner API | ⚠️ Steeper, Objective-C heritage |
| Production Ready | ⚠️ "Becoming viable" — iOS 19 adds model inheritance & persistent history, but many limitations remain; always check the latest release notes before shipping | ✅ Battle‑tested at scale |
Recommendation (October 2025):
SwiftData is NEW (introduced iOS 17) and changes significantly with each iOS release.
BEFORE using SwiftData, Claude MUST search:
# Required searches before SwiftData implementation
queries = [
"SwiftData iOS 18 known issues 2025",
"SwiftData performance problems iOS 18",
"SwiftData vs Core Data 2025",
"SwiftData migration issues iOS 18",
"SwiftData {specific_feature} bugs" # e.g., @ModelActor, #Index, relationships
]
When to search:
iOS 17 → iOS 18/19 Breaking Changes:
@ModelActor broken (fixed in iOS 26 beta)iOS 18 New Features:
#Index macro for performance (⚠️ does NOT back-deploy to iOS 17)#Unique macro for compound uniqueness constraints#Expression macro for complex predicatesiOS 19 Improvements:
@Model types (e.g., class Employee: Person { … }). This enables code reuse and improved schema evolution but still lacks support for deep inheritance chains【779207448288905†L23-L29】.These improvements are incremental; many limitations listed below still apply. Read the latest release notes before using them.
Known Limitations (verify current):
Good practices (current):
@ModelActor for concurrent operations@Attribute(.externalStorage)FetchDescriptor with limits for performanceCore Data received ZERO updates at WWDC24. It's mature but Apple's focus is on SwiftData.
When to search Core Data info:
iOS 18 Known Issues:
NSPersistentContainer and NSPersistentCloudKitContainer based on iCloud statusCore Data + iOS 18 Best Practices:
// NEW iOS 18 pattern: switch container based on iCloud status
func initCoreDataStack() {
if usesiCloud {
pc = NSPersistentCloudKitContainer(name: "YourModel")
} else {
pc = NSPersistentContainer(name: "YourModel")
}
guard let description = pc.persistentStoreDescriptions.first else {
fatalError("No store description")
}
// ALWAYS enable persistent history (even without CloudKit)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
if usesiCloud {
let options = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.your.container"
)
description.cloudKitContainerOptions = options
}
pc.viewContext.automaticallyMergesChangesFromParent = true
pc.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
pc.loadPersistentStores { description, error in
if let error = error {
// Handle error
}
}
}
CloudKit sync is NOT real-time. Timing is opportunistic based on system conditions.
When to search CloudKit info:
Critical CloudKit Facts:
CloudKit + SwiftData vs CloudKit + Core Data:
Recommendation: For production CloudKit sync, use Core Data with NSPersistentCloudKitContainer.
# 1) Generate SwiftData @Model classes from schema.json
python3 scripts/generate_swiftdata_models.py --schema schema.json --out swift/SwiftDataExample/Models.swift
# 2) Generate Core Data NSManagedObject subclasses + in-code NSManagedObjectModel
python3 scripts/generate_coredata_model_code.py --schema schema.json --out swift/CoreDataExample/ModelBuilder.swift
# 3) Diff two schemas and emit SwiftData VersionedSchema + SchemaMigrationPlan skeleton
python3 scripts/diff_migration_plan_swiftdata.py --old old_schema.json --new new_schema.json --out swift/SwiftDataExample/MigrationPlan.swift
# 4) Visualize schema as Mermaid ER diagram
python3 scripts/schema_to_mermaid.py --schema schema.json --out docs/schema.mmd
Schema Format (JSON):
{
"models": [
{
"name": "Project",
"attributes": [
{"name": "id", "type": "UUID", "unique": true},
{"name": "name", "type": "String"},
{"name": "createdAt", "type": "Date", "default": "now"}
],
"relationships": [
{
"name": "tasks",
"destination": "Task",
"toMany": true,
"deleteRule": "cascade",
"inverse": "project"
}
]
}
]
}
Based on community reports and official documentation:
Stable features:
@Model and ModelContext@Query property wrapper for SwiftUI integration@Attribute(.unique) for single-property uniquenessiOS 18 improvements:
#Index for query performance (iOS 18+, no back-deploy)#Unique for compound constraints (iOS 18+)#ExpressionPerformance:
CloudKit Sync:
Relationships:
Threading:
@ModelActor view updates broken in iOS 18 (fixed iOS 26 beta)@ModelActorEXC_BAD_ACCESS when accessing models from wrong threadMigrations:
Testing:
Use SwiftData IF:
Use Core Data IF:
Hybrid Approach:
⚠️ HIGH RISK. Test exhaustively. Have rollback plan.
Before migrating:
Current migration process (iOS 18):
Generate SwiftData models from Core Data:
Set up coexistence (if incremental):
// Namespace to avoid collisions
enum CoreDataModels {
// Keep NSManagedObject subclasses here
}
// SwiftData models
@Model class Task { ... }
Migration options:
Known migration gotchas:
Recommendation: For most production apps, do NOT migrate from Core Data to SwiftData in 2025. Wait for iOS 26+ when SwiftData stabilizes further.
iOS 17 vs iOS 18 differences:
VersionedSchema from day oneMigration pattern:
// ALWAYS version from day one
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Task.self, Project.self]
}
@Model class Task {
var title: String
var isDone: Bool
// ... properties
}
}
// When schema changes
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Task.self, Project.self]
}
@Model class Task {
var title: String
@Attribute(originalName: "isDone") var isCompleted: Bool // Renamed
var priority: Int = 0 // Added
// ...
}
}
// Migration plan
enum TaskMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self] // ORDER MATTERS
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Optional: pre-migration logic
},
didMigrate: { context in
// Optional: post-migration logic
}
)
}
Lightweight migrations (automatic):
@Attribute(originalName:))Custom migrations (manual):
Lightweight (automatic):
let description = NSPersistentStoreDescription()
description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption)
description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption)
Heavyweight (custom mapping model):
NSEntityMigrationPolicy subclass for custom logic⚠️ CloudKit schema changes are PERMANENT in production.
Before updating CloudKit schema:
.dryRun option to preview changes// Preview schema changes
try? container.initializeCloudKitSchema(options: .dryRun)
// Apply schema changes
try? container.initializeCloudKitSchema(options: .printSchema)
Issue: Sync Not Starting
Checklist:
CKContainer.default().accountStatus-com.apple.CoreData.CloudKitDebug 1Issue: Conflicts Not Resolving
Core Data approach:
// Set merge policy
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy // Last write wins
// Or custom merge policy
context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType)
// For fine-grained control, implement custom NSMergePolicy subclass
CloudKit raw API approach:
let modifyOp = CKModifyRecordsOperation(...)
modifyOp.savePolicy = .ifServerRecordUnchanged
modifyOp.modifyRecordsResultBlock = { result in
switch result {
case .success:
// Success
case .failure(let error):
if let ckError = error as? CKError,
ckError.code == .serverRecordChanged {
// Handle conflict
let serverRecord = ckError.serverRecord
// Merge logic here
}
}
}
Issue: Sync Performance Slow
Solutions:
CKFetchRecordZoneChangesOperation for delta syncCKSyncEngine (iOS 17+) for automatic optimizationIssue: "Syncing with iCloud Paused"
Common causes:
Solution: Educate users, provide manual sync button, design for offline-first
Issue: Data Deleted When iCloud Disabled
NEW iOS 18 behavior:
NSPersistentCloudKitContainer may delete data when user disables iCloud// Switch based on iCloud status
if isICloudAvailable() {
container = NSPersistentCloudKitContainer(name: "Model")
} else {
container = NSPersistentContainer(name: "Model")
// Enable history tracking even without CloudKit
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
}
Query optimization:
// ❌ Bad: Loads all data
@Query var tasks: [Task]
// ✅ Good: Filter in predicate
@Query(filter: #Predicate<Task> { $0.isCompleted == false })
var incompleteTasks: [Task]
// ✅ Good: Use FetchDescriptor with limits
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate { $0.priority > 5 },
sortBy: [SortDescriptor(\.dueDate)],
fetchLimit: 20
)
let topTasks = try context.fetch(descriptor)
// ✅ Good: Prefetch relationships
var descriptor = FetchDescriptor<Project>()
descriptor.relationshipKeyPathsForPrefetching = [\.tasks]
let projects = try context.fetch(descriptor)
Large data handling:
// Mark large data for external storage
@Model class Photo {
@Attribute(.externalStorage) var imageData: Data
// SwiftData loads this only when accessed
}
Background operations:
// Use ModelActor for background work
@ModelActor
actor DataProcessor {
func processLargeDataset() async {
// Heavy work here, off main thread
let items = try? modelContext.fetch(FetchDescriptor<Item>())
// Process items...
}
}
Batch operations (not available in SwiftData):
// Batch update
let batchUpdate = NSBatchUpdateRequest(entityName: "Task")
batchUpdate.propertiesToUpdate = ["isArchived": true]
batchUpdate.predicate = NSPredicate(format: "completed == YES")
try context.execute(batchUpdate)
// Batch delete
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Task")
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
try context.execute(batchDelete)
Faulting & prefetching:
let fetchRequest = NSFetchRequest<Project>(entityName: "Project")
fetchRequest.relationshipKeyPathsForPrefetching = ["tasks", "tasks.assignee"]
let projects = try context.fetch(fetchRequest)
Indexing:
// In Core Data model editor:
- Select entity attribute
- Check "Indexed" in Data Model Inspector
Batch operations:
// Batch save (max 400 records)
let modifyOp = CKModifyRecordsOperation(
recordsToSave: records, // Array of CKRecord
recordIDsToDelete: nil
)
database.add(modifyOp)
Change tracking:
// Use server change tokens for delta sync
let fetchZoneChanges = CKFetchRecordZoneChangesOperation(
recordZoneIDs: [zoneID],
configurationsByRecordZoneID: [
zoneID: CKFetchRecordZoneChangesOperation.ZoneConfiguration(
previousServerChangeToken: savedToken,
resultsLimit: 100
)
]
)
Recommended for production apps:
// Setup
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "Model")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
// Enable history tracking
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// CloudKit options
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourapp.container"
)
description.cloudKitContainerOptions = cloudKitOptions
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Core Data failed to load: \(error.localizedDescription)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
Architecture:
Observing changes:
// Watch for remote changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRemoteChange),
name: .NSPersistentStoreRemoteChange,
object: nil
)
@objc func handleRemoteChange(_ notification: Notification) {
// Refresh UI or handle conflicts
}
Current best practices (iOS 18):
// Store token securely
func saveToken(_ token: String, for key: String) {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, // or ThisDeviceOnly
kSecAttrSynchronizable as String: false // Don't sync via iCloud Keychain (usually)
]
// Delete old value if exists
SecItemDelete(query as CFDictionary)
// Add new value
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Failed to save to keychain: \(status)")
return
}
}
// Retrieve token
func retrieveToken(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
return nil
}
return token
}
Access levels:
kSecAttrAccessibleWhenUnlocked - Most secure, requires device unlockkSecAttrAccessibleAfterFirstUnlock - Available after first unlock (recommended for most)kSecAttrAccessibleWhenUnlockedThisDeviceOnly - Don't sync via iCloudkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - CombinationAccess groups (for sharing between apps):
query[kSecAttrAccessGroup as String] = "TEAM_ID.com.yourcompany.shared"
Encrypted SQLite store:
let description = NSPersistentStoreDescription()
description.setOption(FileProtectionType.complete as NSObject,
forKey: NSPersistentStoreFileProtectionKey)
Sensitive data:
NSValueTransformer for encrypted propertiesFileManager attributes:
// Exclude from backup
var url = fileURL
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try? url.setResourceValues(resourceValues)
// Set file protection
try? FileManager.default.setAttributes(
[FileAttributeKey.protectionKey: FileProtectionType.complete],
ofItemAtPath: url.path
)
Challenges:
Approaches:
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Task.self, configurations: config)
let context = container.mainContext
// Test operations...
// Make testable by abstracting SwiftData
protocol TaskRepository {
func fetchTasks() async throws -> [Task]
func save(_ task: Task) async throws
}
class SwiftDataTaskRepository: TaskRepository {
let modelContext: ModelContext
// Implement methods...
}
// Test with mock
class MockTaskRepository: TaskRepository {
// Easy to test
}
Unit tests:
class CoreDataTests: XCTestCase {
var container: NSPersistentContainer!
var context: NSManagedObjectContext!
override func setUp() {
super.setUp()
container = NSPersistentContainer(name: "Model")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
XCTAssertNil(error)
}
context = container.viewContext
}
func testTaskCreation() {
let task = Task(context: context)
task.title = "Test"
try? context.save()
let fetchRequest: NSFetchRequest<Task> = Task.fetchRequest()
let results = try? context.fetch(fetchRequest)
XCTAssertEqual(results?.count, 1)
XCTAssertEqual(results?.first?.title, "Test")
}
}
SwiftData migration tests:
func testMigrationV1toV2() throws {
// 1. Create V1 container and seed data
let v1Container = try ModelContainer(
for: SchemaV1.Task.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: false)
)
let v1Context = v1Container.mainContext
// Add V1 data...
try v1Context.save()
// 2. Create V2 container (triggers migration)
let v2Container = try ModelContainer(
for: SchemaV2.Task.self,
migrationPlan: TaskMigrationPlan.self,
configurations: ModelConfiguration(url: storeURL)
)
let v2Context = v2Container.mainContext
// 3. Verify migrated data
let fetchedTasks = try v2Context.fetch(FetchDescriptor<SchemaV2.Task>())
XCTAssertEqual(fetchedTasks.count, expectedCount)
// Verify properties migrated correctly...
}
Enable logging:
# SQLite debug
-com.apple.CoreData.SQLDebug 1
# CloudKit debug
-com.apple.CoreData.CloudKitDebug 1
Common errors:
EXC_BAD_ACCESS - Accessing model from wrong thread (use @ModelActor)Instruments templates:
SQL logging:
-com.apple.CoreData.SQLDebug 1 # Basic
-com.apple.CoreData.SQLDebug 3 # Verbose
CloudKit debugging:
-com.apple.CoreData.CloudKitDebug 1
Persistent history:
// Query history
let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
let historyResult = try context.execute(historyRequest) as? NSPersistentHistoryResult
let transactions = historyResult?.result as? [NSPersistentHistoryTransaction]
Production monitoring:
Key metrics:
Before shipping:
SwiftData apps:
Core Data apps:
CloudKit sync:
Official:
WWDC Sessions:
Forums:
Key Blogs:
When to search:
SKILL.md - This fileswift/SwiftDataExample/ - SwiftData models, migration plans, helpersswift/CoreDataExample/ - Core Data stack, CloudKit mirroring, history trackingscripts/ - Schema generators, migration diff tools, visualizersdocs/MIGRATIONS.md - Detailed migration guidedocs/PERFORMANCE.md - Performance optimization deep-divedocs/THREADING.md - Concurrency patternsdocs/BACKUP_RESTORE.md - Backup/restore implementationdocs/PITFALLS_DEBUGGING.md - Common issues and solutionsschema.json - Example schema definitionSwiftData:
#Index, #Unique, #Expression, custom data storesCore Data:
CloudKit:
Recommendation: For new production apps in October 2025, use Core Data unless you have specific reasons to use SwiftData and have thoroughly tested it for your use case. SwiftData is "becoming viable" (iOS 26) but not yet proven at scale.
Last Updated: October 28, 2025 Next Review: January 2026 (post-iOS 18.3 release)
When in doubt: Search for current state, test thoroughly, choose Core Data for production.