4.7 — Data persistence
Opening scenario
Three tickets land the same week:
- “User settings reset after the app updates. Use UserDefaults better.”
- “Cache 500 articles offline so the app works on the subway.”
- “Encrypt the user’s auth token. Audit failed last quarter.”
Three different storage problems, three different APIs:
| Problem | Tool |
|---|---|
| Key-value preferences | UserDefaults |
| Files (images, JSON dumps, exports) | FileManager + sandbox directories |
| Secrets (tokens, keys) | Keychain (Security framework) |
| Structured data, queries, relationships | Core Data or SwiftData (iOS 17+) |
| Sync across devices | CloudKit (Apple) or app-specific backend |
Choose deliberately. Misusing them (token in UserDefaults, settings in Keychain, blobs in Core Data) is a classic anti-pattern.
Concept → Why → How → Code
UserDefaults — key-value preferences
For small, non-sensitive, user-visible preferences: theme, last-selected tab, “don’t show this tip again.”
let defaults = UserDefaults.standard
defaults.set(true, forKey: "didCompleteOnboarding")
defaults.set("dark", forKey: "theme")
defaults.set(Date(), forKey: "lastFetchedAt")
let onboarded = defaults.bool(forKey: "didCompleteOnboarding")
let theme = defaults.string(forKey: "theme") ?? "system"
Constraints:
- Not encrypted. Anyone with file-system access (jailbroken device, backup) can read it.
- Synced via iCloud Backup by default — fine for preferences, never for secrets.
- Loaded into memory at app launch; large values (>4KB) slow startup. Don’t dump arrays of model objects here.
- Typed wrappers help: define a
Settingsstruct with computed properties backed byUserDefaults, or use@AppStorageif you’re mixing SwiftUI in.
For app extensions (Today widget, share sheet) you need an App Group and UserDefaults(suiteName: "group.com.your.app") to share.
File system — FileManager and the sandbox
iOS apps live in a sandbox. Standard directories:
| Path | Use | Backed up | Cleared by OS |
|---|---|---|---|
Documents/ | User-generated content visible in Files.app | Yes | No |
Library/Application Support/ | App-managed persistent data, not user-visible | Yes | No |
Library/Caches/ | Re-downloadable cache | No | Yes, when device low on space |
tmp/ | Truly temporary files | No | Yes, between launches |
let appSupport = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let articleCacheURL = appSupport.appendingPathComponent("articles.json")
let data = try JSONEncoder().encode(articles)
try data.write(to: articleCacheURL, options: .atomic)
let loaded = try JSONDecoder().decode([Article].self, from: Data(contentsOf: articleCacheURL))
Hygiene:
- Write with
.atomic(write to temp, rename) to avoid corrupt half-written files - Exclude large caches from iCloud backup:
URL.setResourceValues(URLResourceValues())withisExcludedFromBackup = true - Don’t store user secrets here — sandbox is not encryption
Keychain — secrets only
The Keychain is the encrypted, OS-managed secret store. Backed by Secure Enclave on devices with one; survives app uninstall (intentional — for sticky session tokens) unless you opt out.
Raw Security framework API is C-style and painful. Pattern:
import Security
enum KeychainError: Error { case unhandled(OSStatus), notFound, badData }
enum Keychain {
static func save(_ data: Data, service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // remove existing
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
static func read(service: String, account: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status != errSecItemNotFound else { throw KeychainError.notFound }
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.unhandled(status)
}
return data
}
static func delete(service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandled(status)
}
}
}
// Usage
let token = "secret-token-value"
try Keychain.save(Data(token.utf8), service: "com.myapp.auth", account: "accessToken")
let stored = try String(data: Keychain.read(service: "com.myapp.auth", account: "accessToken"), encoding: .utf8)
kSecAttrAccessible controls when the secret is decryptable:
AfterFirstUnlock— works after first unlock until reboot (use for background-needed secrets)WhenUnlocked— only while device is unlocked (most user secrets)WhenPasscodeSetThisDeviceOnly— won’t migrate to a new device via iCloud restore (good for device-bound credentials)
For Face/Touch ID-gated secrets, add SecAccessControl:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)
// Add kSecAttrAccessControl to the query
For OAuth: persist refresh token in Keychain, never in UserDefaults or files.
Core Data — the mature option
Core Data (iOS 3+) is Apple’s object graph and persistence framework. Powerful, mature, has every feature you’d want — and has a learning curve. The pieces:
- Persistent Store (SQLite under the hood, almost always)
- Managed Object Model (
.xcdatamodeldfile in Xcode) NSManagedObjectContext(your scratchpad)NSPersistentContainer(sets it all up)
Setup:
import CoreData
final class Persistence {
static let shared = Persistence()
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data failed to load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
Read & write on viewContext (main thread) or newBackgroundContext() for heavy work:
let article = Article(context: container.viewContext)
article.id = UUID()
article.title = "Hello"
article.body = "World"
article.createdAt = Date()
try container.viewContext.save()
// Fetch
let req = Article.fetchRequest()
req.predicate = NSPredicate(format: "title CONTAINS[c] %@", "hello")
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
req.fetchLimit = 50
let results = try container.viewContext.fetch(req)
Background work:
container.performBackgroundTask { ctx in
// bulk import 10k articles
for raw in payload {
let a = Article(context: ctx)
a.title = raw.title
// ...
}
try? ctx.save()
}
Threading rule: each NSManagedObjectContext is bound to its queue. Don’t pass managed objects between threads — pass NSManagedObjectIDs and re-fetch.
For UIKit: NSFetchedResultsController integrates with UITableView/UICollectionView diffable data source, animating inserts/deletes as the store changes.
let frc = NSFetchedResultsController(
fetchRequest: req,
managedObjectContext: container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
frc.delegate = self
try frc.performFetch()
extension MyVC: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
dataSource.apply(snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>,
animatingDifferences: true)
}
}
SwiftData — the new option (iOS 17+)
SwiftData is Apple’s 2023 successor wrapper over Core Data. Model with Swift macros, no .xcdatamodeld file:
import SwiftData
@Model
final class Article {
var id: UUID
var title: String
var body: String
var createdAt: Date
init(title: String, body: String) {
self.id = UUID()
self.title = title
self.body = body
self.createdAt = .now
}
}
// Setup
let container = try ModelContainer(for: Article.self)
let context = container.mainContext
// Insert & save
let a = Article(title: "Hello", body: "World")
context.insert(a)
try context.save()
// Fetch with FetchDescriptor + #Predicate
let descriptor = FetchDescriptor<Article>(
predicate: #Predicate { $0.title.contains("Hello") },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let results = try context.fetch(descriptor)
SwiftData is great in pure-SwiftUI apps. In UIKit it’s usable but Core Data + NSFetchedResultsController is still more battle-tested for complex apps. As of 2026, choose:
- New SwiftUI-heavy app → SwiftData
- UIKit-heavy or migrating from existing Core Data → Core Data
- Mixed → either; SwiftData wraps Core Data underneath, can interop
Sync to other devices
For multi-device persistence:
- CloudKit + Core Data (
NSPersistentCloudKitContainer) — one flag flips your Core Data store into iCloud-syncing. Apple manages conflict resolution. - CloudKit + SwiftData — same, native in 2024+
- Your own backend — full control, full responsibility (auth, conflict resolution, offline sync). Apps like Notion, Bear use this.
container = NSPersistentCloudKitContainer(name: "Model")
// Configure store description with iCloud container identifier
let desc = container.persistentStoreDescriptions.first!
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
Encryption at rest
iOS encrypts the entire device when a passcode is set (Data Protection). Files marked with NSFileProtectionComplete are only decryptable when device is unlocked. Set on app files:
let attrs: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.complete]
try FileManager.default.setAttributes(attrs, ofItemAtPath: url.path)
For ultra-sensitive data (medical records, financial PII), layer your own encryption (CryptoKit) on top:
import CryptoKit
let key = SymmetricKey(size: .bits256)
let sealed = try ChaChaPoly.seal(plaintext, using: key)
try sealed.combined.write(to: url)
let opened = try ChaChaPoly.SealedBox(combined: Data(contentsOf: url))
let decrypted = try ChaChaPoly.open(opened, using: key)
Store the SymmetricKey in the Keychain (SecKeyCreateRandomKey or Data(key.withUnsafeBytes(...))).
Migrations
Core Data: model versioning. Add a new model version, mark it current, choose mapping (lightweight if you only added/removed/renamed columns; heavyweight if you transformed data).
SwiftData: schema migration via SchemaMigrationPlan and VersionedSchema.
UserDefaults: versioning via a "schemaVersion" key; on app launch, compare and run migration code if needed.
let current = 3
let stored = UserDefaults.standard.integer(forKey: "schemaVersion")
if stored < current {
runMigrations(from: stored, to: current)
UserDefaults.standard.set(current, forKey: "schemaVersion")
}
Test migrations explicitly. Create an app build at the old schema, install, populate data, then upgrade to new build. Verify nothing’s lost. This is how production data-loss bugs ship.
In the wild
- Signal uses SQLite (via SQLCipher) directly for messages — encrypted database. Keychain holds the encryption key.
- Notion iOS uses Core Data for offline cache of pages; sync via their own backend, not CloudKit.
- Apple Notes is Core Data + CloudKit (
NSPersistentCloudKitContainer). Locked notes encrypted with user-derived keys stored in Keychain. - 1Password uses Keychain for the master vault unlock secret, custom encrypted SQLite for the vault. Defense in depth.
- Spotify caches downloaded songs in
Library/Application Support/, marked excluded from backup, with custom DRM.
Common misconceptions
- “UserDefaults is fine for the auth token.” No. It’s plaintext, backed up to iCloud, readable on a jailbroken device. Always Keychain for secrets.
- “Core Data is just SQLite.” It’s an object graph + persistence framework backed by SQLite. The graph (faulting, relationships, validation) is most of what you’re paying for.
- “
NSManagedObjectis thread-safe.” No — strictly bound to its context’s queue. Cross-thread access crashes. - “SwiftData replaces Core Data.” SwiftData wraps Core Data. Core Data is still the deeper API; SwiftData is sugar.
- “My users have storage; size doesn’t matter.” Wrong. Users with full storage uninstall apps with large footprints; Apple shows your app size at install time. Caches must be evictable.
Seasoned engineer’s take
Data persistence bugs are the worst kind: silent, slow to manifest, and corrupt user trust. Three rules:
- Pick the right tool per data type. Don’t unify everything into Core Data or everything into JSON files; each has the right cases.
- Schema versioning from day 1. The first time you ship, write down “schema v1.” When you add a field in v2, write the migration. Test it. Otherwise some user upgrades from v1 to v4 and loses everything.
- Backup hygiene matters. Mark caches
isExcludedFromBackup. Don’t bloat iCloud backups with regenerable data. Apple will throttle apps that do this.
TIP: For tokens, encrypt the user’s actual data — not just the access token. If your backend supports it, request short-lived access tokens + a refresh token; rotate the access token every hour. Loss of the access token then becomes recoverable.
WARNING:
try Keychain.save(...)failing witherrSecDuplicateItemis the common one. AlwaysSecItemDelete(or useSecItemUpdate) before adding. Easy to miss in a hurried first version, leads to “I logged in but the token is still the old one” bugs.
Interview corner
Junior-level: “Where do you store an auth token?”
Keychain, with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly so background tasks can read it but it doesn’t migrate to a new device automatically.
Mid-level: “What’s the difference between Core Data’s viewContext and a background context?”
viewContext is the main-thread context — use for UI-driven fetches and small writes. Background contexts (newBackgroundContext() or performBackgroundTask) run on a private queue for bulk work (large imports, exports). Save in the background, set viewContext.automaticallyMergesChangesFromParent = true to propagate. Never pass NSManagedObject between contexts — pass NSManagedObjectID and re-fetch.
Senior-level: “Design persistence for a note-taking app: 10k notes per user, full-text search, sync across devices, offline-first.”
Storage: Core Data (or SwiftData) with NSPersistentCloudKitContainer for cross-device sync. Note entity has id, title, body, createdAt, updatedAt, deletedAt (soft delete for sync), version (for conflict detection). Full-text search via SQLite FTS5 — add via NSPersistentStoreDescription’s setOption for FTS, or maintain a separate index table updated on save. UI uses NSFetchedResultsController for list, batched fetches with fetchLimit for performance. Conflict resolution policy: NSMergeByPropertyObjectTrumpMergePolicy for simple cases; custom resolver for body conflicts (could surface conflict UI like Notes does). Offline-first: writes always succeed locally; sync queue retries when online. Keychain holds CloudKit user record-ID for re-auth after reinstall. Test: bulk-create 10k notes, measure fetch time; simulate sync conflict by editing same note on two devices offline then bringing both online.
Red flag in candidates: Storing access tokens in UserDefaults. Indicates they’ve never had a security audit.
Lab preview
Lab 4.3 walks the Keychain pattern end-to-end with a real signup form.
Next: 4.8 — Networking