iOS Data Persistence Skill
Storage Selection Guide
| Data Type |
Storage |
Why |
| User preferences |
UserDefaults / @AppStorage |
Simple, auto-loaded at launch |
| Preferences synced across devices |
NSUbiquitousKeyValueStore |
Simple iCloud sync, <1MB |
| Passwords, tokens, API keys |
Keychain |
Encrypted, survives reinstall |
| Structured app data (iOS 17+) |
SwiftData |
Modern, declarative, queryable |
| Structured app data (iOS 16-) |
Core Data |
Mature, proven, stable |
| Large files (images, video, PDFs) |
FileManager |
Direct file I/O, no DB overhead |
| Complex queries, cross-platform |
SQLite (GRDB) |
Full SQL control, lightweight |
| Public/shared CloudKit data |
Core Data + NSPersistentCloudKitContainer |
SwiftData only supports private DB |
Decision Flowchart
Is the data sensitive (tokens, passwords, keys)?
YES → Keychain (NEVER UserDefaults)
NO ↓
Is it a simple user preference (theme, flag, small string)?
YES → Need sync across devices?
YES → NSUbiquitousKeyValueStore
NO → @AppStorage / UserDefaults
NO ↓
Is it a large binary file (image, video, PDF)?
YES → FileManager (store path/URL reference in DB if needed)
NO ↓
Is it structured/relational data?
YES → iOS 17+ minimum?
YES → SwiftData
NO → Core Data
NO ↓
Need full SQL control or cross-platform DB?
YES → SQLite via GRDB.swift
NO → Codable + FileManager (JSON/plist file)
Core Rules
General
- Use SwiftData for new iOS 17+ projects -- simpler API than Core Data
- Use Keychain for ALL sensitive data (tokens, passwords, API keys) -- NEVER UserDefaults
- Use @AppStorage only for simple preferences -- not large data or collections
- Store large blobs (images, video) on disk via FileManager, keep only the path/URL in DB
- Always handle persistence errors -- do not force-try in production
SwiftData Rules
- Always use
isStoredInMemoryOnly: true for test ModelContainers
- Use
@ModelActor for background SwiftData operations -- Model objects are NOT Sendable
- Pass
PersistentIdentifier (Sendable) between actors, not model objects
- Use
FetchDescriptor.fetchLimit for pagination -- never fetch all records unbounded
- CloudKit models: all properties must have defaults or be optional, no
.unique
- Add
#Index (iOS 18+) on frequently queried properties for performance
- Prefer
@Transient for computed/cached properties that should not be persisted
Core Data Rules
- Batch operations (
NSBatchInsertRequest, etc.) bypass validation -- use for bulk imports (10x faster)
- Always merge batch operation results into viewContext via
NSManagedObjectContext.mergeChanges
- Use
fetchBatchSize (default 0 = fetch all) -- set to ~20 for table/collection views
- Use
newBackgroundContext() or performBackgroundTask for writes -- never block main thread
- Set
automaticallyMergesChangesFromParent = true on viewContext for auto UI refresh
Storage Rules
- UserDefaults synchronize is unnecessary since iOS 12 -- system handles it
- FileManager: use
.applicationSupportDirectory for internal data, .documentDirectory for user-visible files
tmp/ and Library/Caches/ can be purged by system -- do not store critical data there
- iCloud KVS: 1MB total limit, 1024 keys max, values up to 1MB each
- GRDB: use
ValueObservation for reactive SwiftUI integration
SwiftData Quick Reference
Define a Model
import SwiftData
@Model
final class Task {
var title: String
var isCompleted: Bool = false
var createdAt: Date = Date.now
@Attribute(.externalStorage) var imageData: Data?
@Relationship(deleteRule: .cascade, inverse: \Tag.tasks)
var tags: [Tag] = []
init(title: String) {
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var name: String
var tasks: [Task] = []
init(name: String) {
self.name = name
}
}
Setup Container
@main
struct MyApp: App {
var body: some Scene {
WindowGroup { ContentView() }
.modelContainer(for: [Task.self, Tag.self])
}
}
Query and Mutate
struct TaskListView: View {
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
@Environment(\.modelContext) private var context
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
func addTask(title: String) {
let task = Task(title: title)
context.insert(task)
// autosave handles the rest
}
func deleteTask(_ task: Task) {
context.delete(task)
}
}
Predicate
let incomplete = #Predicate<Task> { !$0.isCompleted }
let search = #Predicate<Task> { task in
task.title.localizedStandardContains("meeting")
}
See references/swiftdata.md for full details.
Core Data Quick Reference
Setup
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { fatalError("Store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
Fetch
let request = NSFetchRequest<Task>(entityName: "Task")
request.predicate = NSPredicate(format: "isCompleted == %@", NSNumber(value: false))
request.sortDescriptors = [NSSortDescriptor(keyPath: \Task.createdAt, ascending: false)]
request.fetchBatchSize = 20
let tasks = try context.fetch(request)
Batch Insert (Bulk Performance)
let request = NSBatchInsertRequest(entity: Task.entity(), objects: dictionaries)
request.resultType = .objectIDs
let result = try context.execute(request) as! NSBatchInsertResult
let changes = [NSInsertedObjectIDsKey: result.objectIDs!]
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext])
See references/coredata.md for full details.
Other Storage Quick Reference
@AppStorage
struct SettingsView: View {
@AppStorage("isDarkMode") private var isDarkMode = false
@AppStorage("username") private var username = ""
var body: some View {
Toggle("Dark Mode", isOn: $isDarkMode)
}
}
Keychain (via wrapper)
func saveToKeychain(account: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
FileManager -- Save Codable
func save<T: Encodable>(_ object: T, to filename: String) throws {
let url = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(filename)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(object)
try data.write(to: url, options: .atomic)
}
See references/storage.md for full details.
Common Patterns
Repository Pattern (SwiftData)
@ModelActor
actor TaskRepository {
func create(title: String) throws -> PersistentIdentifier {
let task = Task(title: title)
modelContext.insert(task)
try modelContext.save()
return task.persistentModelID
}
func fetchIncomplete() throws -> [PersistentIdentifier] {
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate { !$0.isCompleted },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try modelContext.fetch(descriptor).map(\.persistentModelID)
}
func complete(id: PersistentIdentifier) throws {
guard let task = modelContext.model(for: id) as? Task else { return }
task.isCompleted = true
try modelContext.save()
}
}
Offline-First Architecture
// 1. Define local SwiftData model as source of truth
// 2. Sync layer: fetch from API → upsert into SwiftData
// 3. UI reads only from SwiftData via @Query
// 4. Writes go to SwiftData first, then queue API calls
// 5. Use ModelContext.enumerate for large dataset processing
Test Container
@MainActor
func makeTestContainer() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: Task.self, Tag.self,
configurations: config
)
return container
}
Performance Checklist
Migration Checklist
SwiftData
- Create a new
VersionedSchema conforming type for each schema version
- Define
SchemaMigrationPlan with ordered list of schemas
- Use
.lightweight stage when only adding/renaming properties
- Use
.custom stage when transforming data between versions
- Pass migration plan to
ModelContainer configuration
Core Data
- Lightweight: adding optional attributes, adding entities -- automatic, no code needed
- Heavyweight: create mapping model (.xcmappingmodel) for complex changes
- Always test migration with production-size dataset before release
File References
- SwiftData Deep Dive -- @Model, @Query, #Predicate, migrations, CloudKit, concurrency
- Core Data Deep Dive -- NSPersistentContainer, fetching, batch ops, CloudKit, migrations
- Storage Options -- UserDefaults, @AppStorage, FileManager, Keychain, iCloud KVS, SQLite/GRDB
1---2name: ios-data3description: iOS data persistence expert skill covering SwiftData (@Model, ModelContainer, @Query, #Predicate, migrations, CloudKit), Core Data (NSPersistentContainer, NSFetchRequest, batch operations, CloudKit), UserDefaults/@AppStorage, FileManager (app sandbox directories), Keychain for sensitive data, iCloud key-value storage, and SQLite/GRDB. Use this skill whenever the user needs to persist data, create data models, query databases, handle migrations, sync with iCloud, or choose a storage strategy. Triggers on: SwiftData, Core Data, @Model, @Query, #Predicate, ModelContainer, NSManagedObject, NSFetchRequest, UserDefaults, @AppStorage, FileManager, documents directory, Keychain, iCloud sync, SQLite, GRDB, persistence, database, migration, schema, data model, fetch, save, delete, storage, cache, offline, or any iOS data storage question.4---5
6# iOS Data Persistence Skill
7
8## Storage Selection Guide
9
10| Data Type | Storage | Why |
11|-----------|---------|-----|
12| User preferences | UserDefaults / @AppStorage | Simple, auto-loaded at launch |
13| Preferences synced across devices | NSUbiquitousKeyValueStore | Simple iCloud sync, <1MB |
14| Passwords, tokens, API keys | Keychain | Encrypted, survives reinstall |
15| Structured app data (iOS 17+) | SwiftData | Modern, declarative, queryable |
16| Structured app data (iOS 16-) | Core Data | Mature, proven, stable |
17| Large files (images, video, PDFs) | FileManager | Direct file I/O, no DB overhead |
18| Complex queries, cross-platform | SQLite (GRDB) | Full SQL control, lightweight |
19| Public/shared CloudKit data | Core Data + NSPersistentCloudKitContainer | SwiftData only supports private DB |
20
21## Decision Flowchart
22
23```
24Is the data sensitive (tokens, passwords, keys)?
25 YES → Keychain (NEVER UserDefaults)
26 NO ↓
27
28Is it a simple user preference (theme, flag, small string)?
29 YES → Need sync across devices?
30 YES → NSUbiquitousKeyValueStore
31 NO → @AppStorage / UserDefaults
32 NO ↓
33
34Is it a large binary file (image, video, PDF)?
35 YES → FileManager (store path/URL reference in DB if needed)
36 NO ↓
37
38Is it structured/relational data?
39 YES → iOS 17+ minimum?
40 YES → SwiftData
41 NO → Core Data
42 NO ↓
43
44Need full SQL control or cross-platform DB?
45 YES → SQLite via GRDB.swift
46 NO → Codable + FileManager (JSON/plist file)
47```
48
49## Core Rules
50
51### General
52- Use SwiftData for new iOS 17+ projects -- simpler API than Core Data
53- Use Keychain for ALL sensitive data (tokens, passwords, API keys) -- NEVER UserDefaults
54- Use @AppStorage only for simple preferences -- not large data or collections
55- Store large blobs (images, video) on disk via FileManager, keep only the path/URL in DB
56- Always handle persistence errors -- do not force-try in production
57
58### SwiftData Rules
59- Always use `isStoredInMemoryOnly: true` for test ModelContainers
60- Use `@ModelActor` for background SwiftData operations -- Model objects are NOT Sendable
61- Pass `PersistentIdentifier` (Sendable) between actors, not model objects
62- Use `FetchDescriptor.fetchLimit` for pagination -- never fetch all records unbounded
63- CloudKit models: all properties must have defaults or be optional, no `.unique`
64- Add `#Index` (iOS 18+) on frequently queried properties for performance
65- Prefer `@Transient` for computed/cached properties that should not be persisted
66
67### Core Data Rules
68- Batch operations (`NSBatchInsertRequest`, etc.) bypass validation -- use for bulk imports (10x faster)
69- Always merge batch operation results into viewContext via `NSManagedObjectContext.mergeChanges`
70- Use `fetchBatchSize` (default 0 = fetch all) -- set to ~20 for table/collection views
71- Use `newBackgroundContext()` or `performBackgroundTask` for writes -- never block main thread
72- Set `automaticallyMergesChangesFromParent = true` on viewContext for auto UI refresh
73
74### Storage Rules
75- UserDefaults synchronize is unnecessary since iOS 12 -- system handles it
76- FileManager: use `.applicationSupportDirectory` for internal data, `.documentDirectory` for user-visible files
77- `tmp/` and `Library/Caches/` can be purged by system -- do not store critical data there
78- iCloud KVS: 1MB total limit, 1024 keys max, values up to 1MB each
79- GRDB: use `ValueObservation` for reactive SwiftUI integration
80
81## SwiftData Quick Reference
82
83### Define a Model
84```swift
85import SwiftData
86
87@Model
88final class Task {
89 var title: String
90 var isCompleted: Bool = false
91 var createdAt: Date = Date.now
92 @Attribute(.externalStorage) var imageData: Data?
93 @Relationship(deleteRule: .cascade, inverse: \Tag.tasks)
94 var tags: [Tag] = []
95
96 init(title: String) {
97 self.title = title
98 }
99}
100
101@Model
102final class Tag {
103 @Attribute(.unique) var name: String
104 var tasks: [Task] = []
105
106 init(name: String) {
107 self.name = name
108 }
109}
110```
111
112### Setup Container
113```swift
114@main
115struct MyApp: App {
116 var body: some Scene {
117 WindowGroup { ContentView() }
118 .modelContainer(for: [Task.self, Tag.self])
119 }
120}
121```
122
123### Query and Mutate
124```swift
125struct TaskListView: View {
126 @Query(sort: \Task.createdAt, order: .reverse)
127 private var tasks: [Task]
128 @Environment(\.modelContext) private var context
129
130 var body: some View {
131 List(tasks) { task in
132 Text(task.title)
133 }
134 }
135
136 func addTask(title: String) {
137 let task = Task(title: title)
138 context.insert(task)
139 // autosave handles the rest
140 }
141
142 func deleteTask(_ task: Task) {
143 context.delete(task)
144 }
145}
146```
147
148### Predicate
149```swift
150let incomplete = #Predicate<Task> { !$0.isCompleted }
151let search = #Predicate<Task> { task in
152 task.title.localizedStandardContains("meeting")
153}
154```
155
156See [references/swiftdata.md](references/swiftdata.md) for full details.
157
158## Core Data Quick Reference
159
160### Setup
161```swift
162let container = NSPersistentContainer(name: "Model")
163container.loadPersistentStores { _, error in
164 if let error { fatalError("Store failed: \(error)") }
165}
166container.viewContext.automaticallyMergesChangesFromParent = true
167```
168
169### Fetch
170```swift
171let request = NSFetchRequest<Task>(entityName: "Task")
172request.predicate = NSPredicate(format: "isCompleted == %@", NSNumber(value: false))
173request.sortDescriptors = [NSSortDescriptor(keyPath: \Task.createdAt, ascending: false)]
174request.fetchBatchSize = 20
175let tasks = try context.fetch(request)
176```
177
178### Batch Insert (Bulk Performance)
179```swift
180let request = NSBatchInsertRequest(entity: Task.entity(), objects: dictionaries)
181request.resultType = .objectIDs
182let result = try context.execute(request) as! NSBatchInsertResult
183let changes = [NSInsertedObjectIDsKey: result.objectIDs!]
184NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext])
185```
186
187See [references/coredata.md](references/coredata.md) for full details.
188
189## Other Storage Quick Reference
190
191### @AppStorage
192```swift
193struct SettingsView: View {
194 @AppStorage("isDarkMode") private var isDarkMode = false
195 @AppStorage("username") private var username = ""
196
197 var body: some View {
198 Toggle("Dark Mode", isOn: $isDarkMode)
199 }
200}
201```
202
203### Keychain (via wrapper)
204```swift
205func saveToKeychain(account: String, data: Data) throws {
206 let query: [String: Any] = [
207 kSecClass as String: kSecClassGenericPassword,
208 kSecAttrAccount as String: account,
209 kSecValueData as String: data
210 ]
211 SecItemDelete(query as CFDictionary)
212 let status = SecItemAdd(query as CFDictionary, nil)
213 guard status == errSecSuccess else {
214 throw KeychainError.saveFailed(status)
215 }
216}
217```
218
219### FileManager -- Save Codable
220```swift
221func save<T: Encodable>(_ object: T, to filename: String) throws {
222 let url = FileManager.default
223 .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
224 .appendingPathComponent(filename)
225 try FileManager.default.createDirectory(
226 at: url.deletingLastPathComponent(),
227 withIntermediateDirectories: true
228 )
229 let data = try JSONEncoder().encode(object)
230 try data.write(to: url, options: .atomic)
231}
232```
233
234See [references/storage.md](references/storage.md) for full details.
235
236## Common Patterns
237
238### Repository Pattern (SwiftData)
239```swift
240@ModelActor
241actor TaskRepository {
242 func create(title: String) throws -> PersistentIdentifier {
243 let task = Task(title: title)
244 modelContext.insert(task)
245 try modelContext.save()
246 return task.persistentModelID
247 }
248
249 func fetchIncomplete() throws -> [PersistentIdentifier] {
250 let descriptor = FetchDescriptor<Task>(
251 predicate: #Predicate { !$0.isCompleted },
252 sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
253 )
254 return try modelContext.fetch(descriptor).map(\.persistentModelID)
255 }
256
257 func complete(id: PersistentIdentifier) throws {
258 guard let task = modelContext.model(for: id) as? Task else { return }
259 task.isCompleted = true
260 try modelContext.save()
261 }
262}
263```
264
265### Offline-First Architecture
266```swift
267// 1. Define local SwiftData model as source of truth
268// 2. Sync layer: fetch from API → upsert into SwiftData
269// 3. UI reads only from SwiftData via @Query
270// 4. Writes go to SwiftData first, then queue API calls
271// 5. Use ModelContext.enumerate for large dataset processing
272```
273
274### Test Container
275```swift
276@MainActor
277func makeTestContainer() throws -> ModelContainer {
278 let config = ModelConfiguration(isStoredInMemoryOnly: true)
279 let container = try ModelContainer(
280 for: Task.self, Tag.self,
281 configurations: config
282 )
283 return container
284}
285```
286
287## Performance Checklist
288
289- [ ] Use `fetchLimit` on all list queries -- never fetch unbounded
290- [ ] Add `#Index` (iOS 18) or Core Data indexes on frequently filtered/sorted properties
291- [ ] Use `@Attribute(.externalStorage)` for Data properties > a few KB
292- [ ] Use `@ModelActor` / background context for writes > 100 objects
293- [ ] Use `enumerate()` instead of `fetch()` for processing large datasets (controls memory)
294- [ ] Set `fetchBatchSize = 20` on Core Data fetch requests for lists
295- [ ] Use batch operations for bulk imports (Core Data)
296- [ ] Profile with Instruments > Core Data template to find slow fetches
297
298## Migration Checklist
299
300### SwiftData
3011. Create a new `VersionedSchema` conforming type for each schema version
3022. Define `SchemaMigrationPlan` with ordered list of schemas
3033. Use `.lightweight` stage when only adding/renaming properties
3044. Use `.custom` stage when transforming data between versions
3055. Pass migration plan to `ModelContainer` configuration
306
307### Core Data
3081. Lightweight: adding optional attributes, adding entities -- automatic, no code needed
3092. Heavyweight: create mapping model (.xcmappingmodel) for complex changes
3103. Always test migration with production-size dataset before release
311
312## File References
313
314- [SwiftData Deep Dive](references/swiftdata.md) -- @Model, @Query, #Predicate, migrations, CloudKit, concurrency
315- [Core Data Deep Dive](references/coredata.md) -- NSPersistentContainer, fetching, batch ops, CloudKit, migrations
316- [Storage Options](references/storage.md) -- UserDefaults, @AppStorage, FileManager, Keychain, iCloud KVS, SQLite/GRDB