iOS Persistence
UserDefaults, Keychain, Core Data, SwiftData, FileManager
- 01
What are the persistence options on iOS and when do you choose each?
EasyiOS gives you a ladder of storage options and the answer is always the lightest one that fits: preferences in UserDefaults, secrets in the Keychain, an object graph in SwiftData or Core Data.
// UserDefaults UserDefaults.standard.set(true, forKey: "hasSeenOnboarding") let seen = UserDefaults.standard.bool(forKey: "hasSeenOnboarding") // Codable JSON file struct Settings: Codable { var theme: String; var fontSize: Int } … - 02
SwiftData or Core Data — which do you pick, and what are the moving parts underneath?
MediumSwiftData is Apple's Swift-first persistence framework, and it is the same Core Data engine underneath with the schema written as Swift code instead of drawn in a model editor.
import SwiftUI import SwiftData @Model final class TodoItem { @Attribute(.unique) var id: UUID = UUID() … - 03
How do you securely store secrets on iOS using Keychain?
MediumThe Keychain is the system's encrypted store for small secrets, and the two attributes that matter on every item are its accessibility class and its access control.
import Security import Foundation enum Keychain { static func save(_ value: Data, account: String) throws { let query: [String: Any] = [ … - 04
How do you handle Core Data / SwiftData migrations safely?
MediumWhen the schema changes between releases you need a migration strategy, or the app crashes on launch against an incompatible store.
// Lightweight Core Data migration — set options on the store description let desc = container.persistentStoreDescriptions.first! desc.shouldMigrateStoreAutomatically = true desc.shouldInferMappingModelAutomatically = true // Custom migration policy … - 05
When would you choose a third-party SQLite layer (GRDB) over Core Data / SwiftData?
MediumGo to SQLite directly when the database is the interesting part of the app and Core Data's object graph is overhead rather than help.
import GRDB struct Player: Codable, FetchableRecord, MutablePersistableRecord { var id: Int64? var name: String var score: Int … - 06
Importing twenty thousand records freezes the UI. How do you write to the store off the main thread?
MediumDo the writing in a context the UI does not read from, and move results across the boundary as identifiers rather than as objects.
@ModelActor actor ImportActor { func importPosts(_ dtos: [PostDTO]) throws -> [PersistentIdentifier] { var ids: [PersistentIdentifier] = [] modelContext.autosaveEnabled = false … - 07
Your widget needs the user's data. How do you share a store between the app and its extensions?
MediumAn extension is a separate process with its own container, so everything shared has to live in an App Group directory both targets are entitled to open.
let groupID = "group.com.example.notes" let containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: groupID)! let storeURL = containerURL.appending(path: "Notes.store") // Shared SwiftData container — identical code in app and widget target … - 08
The team wants iCloud sync on models you already shipped. What changes, and what will bite you?
HardTurning sync on is one line of configuration plus a set of CloudKit schema restrictions that quietly invalidate models most apps have already shipped.
@Model final class Note { // Every attribute optional or defaulted — CloudKit has no required columns var title: String = "" var body: String = "" var updatedAt: Date = Date() … - 09
Where does a 500 MB downloaded video belong on disk, and why not in Documents?
MediumAnything you can download again belongs in Caches or in Application Support with the backup flag cleared, because everything in Documents is copied into the user's iCloud backup.
// Re-downloadable media: caches, purgeable, not backed up let cachesURL = URL.cachesDirectory.appending(path: "videos", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: cachesURL, withIntermediateDirectories: true) let videoURL = cachesURL.appending(path: "\(assetID).mp4") // Moving a finished download into place beats copying the bytes … - 10
How do you unit test code that persists through SwiftData or Core Data?
EasyGive each test its own store that exists only in memory, so every test starts from an empty database and nothing survives to the next one.
import Testing import SwiftData @MainActor private func makeContainer() throws -> ModelContainer { let configuration = ModelConfiguration(isStoredInMemoryOnly: true) … - 11
A list backed by @Query gets slower as the table grows. What do you check first?
HardCheck that the filtering really happens in SQLite: a predicate the store can translate narrows rows in the database, while a closure over the fetched array loads every row into memory first.
// Slow: everything is fetched, then filtered and sorted in Swift let all = try context.fetch(FetchDescriptor<Message>()) let recent = all.filter { $0.isUnread && $0.folder?.name == folderName } .sorted { $0.sentAt > $1.sentAt } // Fast: predicate translated to SQL, sorted on an indexed column, paged … - 12
Deleting a playlist leaves orphaned tracks behind. How do delete rules and inverses work?
MediumA relationship needs an inverse and an explicit delete rule, and the rule is what decides whether deleting the parent also deletes the children, merely unlinks them, or is refused.
@Model final class Playlist { var name: String // Owning side cascades: deleting the playlist deletes its entries @Relationship(deleteRule: .cascade, inverse: \Entry.playlist) … - 13
A setting that should ship as on reads back as off on the very first launch — what did the code miss?
EasyNothing was ever written for that key, and
bool(forKey:)answers a missing key withfalserather than with the default you had in mind.import SwiftUI // Registration domain: consulted when nothing was ever written. // Not persisted — run this on every launch, before the first read. func registerDefaults() { UserDefaults.standard.register(defaults: [ … - 14
The code says it saved, but a relaunch shows an empty screen — how do you find out what actually landed on disk?
EasyOpen the app's real container and read the file yourself, because almost every "it didn't save" turns out to be a missing
save(), a second store in another directory, or a write into a different suite.// Where the data actually is (Simulator) // xcrun simctl get_app_container booted com.example.app data // xcrun simctl get_app_container booted com.example.app groups // xcrun simctl spawn booted defaults read com.example.app // Device: Xcode > Window > Devices and Simulators > app > Download Container… … - 15
After an update every saved photo is missing, although the files were written to Documents and the paths are in the database. What broke?
MediumThe container directory carries a UUID that changes with every install and update, so an absolute path saved yesterday points at a directory that no longer exists — persist the file name, never the path.
// WRONG: the container UUID changes on every install and update let fileURL = URL.documentsDirectory.appending(path: "avatar.jpg") photo.storedPath = fileURL.path() // /var/mobile/Containers/Data/Application/<UUID>/… // after the next update: the file is there, the path is not // RIGHT: persist a name relative to a directory you can rebuild … - 16
Every background sync doubles the number of rows in the table. How do you make the import idempotent?
MediumMake the server's identifier the model's identity with a uniqueness constraint, so importing a record that already exists updates the existing row instead of adding a second one.
// SwiftData: the server id is the identity, and insert becomes an upsert @Model final class Article { @Attribute(.unique) var serverID: String var title: String var updatedAt: Date init(serverID: String, title: String, updatedAt: Date) { … - 17
The app was killed mid-write and the settings file came back as garbage on the next launch — how do you make a file write crash-safe?
MediumNever write over a live file: put the new bytes somewhere else and swap them in with a rename, which the filesystem performs atomically.
// WRONG: truncating the live file — a crash here leaves half a document let handle = try FileHandle(forWritingTo: url) try handle.truncate(atOffset: 0) try handle.write(contentsOf: JSONEncoder().encode(settings)) try handle.close() … - 18
Logging out has to leave nothing of the user behind on the device. What does deleting their data actually involve?
MediumDeleting rows is not deleting data: you destroy the store file together with its sidecars, then clear everything that lives outside it — Keychain, preferences, caches and the App Group container.
// 1. The store is three files — destroy it, do not unlink the .sqlite let coordinator = persistentContainer.persistentStoreCoordinator for store in coordinator.persistentStores { guard let url = store.url else { continue } try coordinator.remove(store) try coordinator.destroyPersistentStore(at: url, type: .sqlite, options: nil) … - 19
A background refresh task writes fine in the simulator but fails on a real device at 3 a.m. with a permission error. What is holding the file shut?
HardData protection: every file has a protection class that decides whether its decryption key exists while the device is locked, and a
.completefile is simply unreadable a few seconds after the screen goes off.// The store is a file: its protection class decides who can open it when let description = NSPersistentStoreDescription(url: storeURL) description.setOption(FileProtectionType.completeUntilFirstUserAuthentication as NSObject, forKey: NSPersistentStoreFileProtectionKey) container.persistentStoreDescriptions = [description] // .complete here = every BGProcessingTask on a locked phone fails to open the store … - 20
Two screens edit the same record, both save without an error, and one user's change quietly disappears. Where did it go?
HardEach context saves against the snapshot it fetched, and when the row in the store no longer matches that snapshot it is the merge policy — not your code — that decides which values survive.
// Default policy: the save throws instead of guessing viewContext.mergePolicy = NSErrorMergePolicy do { try viewContext.save() } catch let error as NSError where error.code == NSManagedObjectMergeError { …