4.7 — Data persistence

Opening scenario

Three tickets land the same week:

  1. “User settings reset after the app updates. Use UserDefaults better.”
  2. “Cache 500 articles offline so the app works on the subway.”
  3. “Encrypt the user’s auth token. Audit failed last quarter.”

Three different storage problems, three different APIs:

ProblemTool
Key-value preferencesUserDefaults
Files (images, JSON dumps, exports)FileManager + sandbox directories
Secrets (tokens, keys)Keychain (Security framework)
Structured data, queries, relationshipsCore Data or SwiftData (iOS 17+)
Sync across devicesCloudKit (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 Settings struct with computed properties backed by UserDefaults, or use @AppStorage if 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:

PathUseBacked upCleared by OS
Documents/User-generated content visible in Files.appYesNo
Library/Application Support/App-managed persistent data, not user-visibleYesNo
Library/Caches/Re-downloadable cacheNoYes, when device low on space
tmp/Truly temporary filesNoYes, 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()) with isExcludedFromBackup = 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 (.xcdatamodeld file 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

  1. “UserDefaults is fine for the auth token.” No. It’s plaintext, backed up to iCloud, readable on a jailbroken device. Always Keychain for secrets.
  2. “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.
  3. NSManagedObject is thread-safe.” No — strictly bound to its context’s queue. Cross-thread access crashes.
  4. “SwiftData replaces Core Data.” SwiftData wraps Core Data. Core Data is still the deeper API; SwiftData is sugar.
  5. “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:

  1. Pick the right tool per data type. Don’t unify everything into Core Data or everything into JSON files; each has the right cases.
  2. 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.
  3. 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 with errSecDuplicateItem is the common one. Always SecItemDelete (or use SecItemUpdate) 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