iOS Persistence
Irbisa · cheatsheetSeptember 13, 2026

iOS Persistence

UserDefaults, Keychain, Core Data, SwiftData, FileManager

Middle Developer20 itemscompressed for a skim
  1. 01

    What are the persistence options on iOS and when do you choose each?

    Easy

    iOS 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 }
    …
  2. 02

    SwiftData or Core Data — which do you pick, and what are the moving parts underneath?

    Medium

    SwiftData 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()
    …
  3. 03

    How do you securely store secrets on iOS using Keychain?

    Medium

    The 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] = [
    …
  4. 04

    How do you handle Core Data / SwiftData migrations safely?

    Medium

    When 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
    …
  5. 05

    When would you choose a third-party SQLite layer (GRDB) over Core Data / SwiftData?

    Medium

    Go 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
    …
  6. 06

    Importing twenty thousand records freezes the UI. How do you write to the store off the main thread?

    Medium

    Do 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
    …
  7. 07

    Your widget needs the user's data. How do you share a store between the app and its extensions?

    Medium

    An 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
    …
  8. 08

    The team wants iCloud sync on models you already shipped. What changes, and what will bite you?

    Hard

    Turning 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()
    …
  9. 09

    Where does a 500 MB downloaded video belong on disk, and why not in Documents?

    Medium

    Anything 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. 10

    How do you unit test code that persists through SwiftData or Core Data?

    Easy

    Give 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. 11

    A list backed by @Query gets slower as the table grows. What do you check first?

    Hard

    Check 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. 12

    Deleting a playlist leaves orphaned tracks behind. How do delete rules and inverses work?

    Medium

    A 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. 13

    A setting that should ship as on reads back as off on the very first launch — what did the code miss?

    Easy

    Nothing was ever written for that key, and bool(forKey:) answers a missing key with false rather 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. 14

    The code says it saved, but a relaunch shows an empty screen — how do you find out what actually landed on disk?

    Easy

    Open 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. 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?

    Medium

    The 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. 16

    Every background sync doubles the number of rows in the table. How do you make the import idempotent?

    Medium

    Make 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. 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?

    Medium

    Never 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. 18

    Logging out has to leave nothing of the user behind on the device. What does deleting their data actually involve?

    Medium

    Deleting 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. 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?

    Hard

    Data protection: every file has a protection class that decides whether its decryption key exists while the device is locked, and a .complete file 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. 20

    Two screens edit the same record, both save without an error, and one user's change quietly disappears. Where did it go?

    Hard

    Each 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 {
    …