Skip to main content

ios-data-persistence

The definitive guide to iOS/macOS data persistence in the Swift 6 / iOS 26 era — choosing where and how to store data, then implementing it correctly. Covers SwiftData (@Model, @Query), Core Data, SQLite via GRDB and SQLiteData, FTS5 full-text search, CloudKit sync (CKSyncEngine, CKShare), iCloud Drive, Codable/JSON, UserDefaults/@AppStorage, file storage, and schema migrations. Use when the user asks "which storage / database should I use", "where do I store this data", "set up SwiftData / Core Data / GRDB", "add CloudKit sync", "migrate my schema", "data not syncing", or any database / persistence / storage / serialization / migration / sync task.

Source facts

Repository
Emasoft/emasoft-complete-ios-app-authoring
Last source activity
June 11, 2026 at 18:06
Detected SKILL.md language
English
Stars
3
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
11 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
ios-data-persistence
description
The definitive guide to iOS/macOS data persistence in the Swift 6 / iOS 26 era — choosing where and how to store data, then implementing it correctly. Covers SwiftData (@Model, @Query), Core Data, SQLite via GRDB and SQLiteData, FTS5 full-text search, CloudKit sync (CKSyncEngine, CKShare), iCloud Drive, Codable/JSON, UserDefaults/@AppStorage, file storage, and schema migrations. Use when the user asks "which storage / database should I use", "where do I store this data", "set up SwiftData / Core Data / GRDB", "add CloudKit sync", "migrate my schema", "data not syncing", or any database / persistence / storage / serialization / migration / sync task.
# iOS Data & Persistence **Use this skill for ANY data persistence, database, storage, CloudKit, iCloud, serialization, or migration work.** Two questions, always answered in order: 1. **Format** — what shape is the data? (queryable records vs files vs key-value) 2. **Location** — where does it live? (local directory, iCloud, Keychain) Getting the format wrong forces workarounds. Getting the location wrong loses data or bloats backups. > **Golden rule:** choose the right tool for the data *shape*, then the right *location*. Most "data disappeared / not syncing / backup rejected" bugs are a format-or-location mistake made on day one. --- ## Decision tree — start here ### Step 1: Format ``` What is the shape of the data? ├─ STRUCTURED (queryable records, relationships, search, filtering, sorting) │ e.g. tasks, notes, contacts, messages, transactions, library items │ → go to "Structured data" below │ ├─ FILES (documents, images, video, downloads, caches) │ e.g. user photos, PDFs, downloaded media, thumbnails, export staging │ → references/file-storage.md (+ references/icloud-drive.md if syncing) │ ├─ SMALL KEY-VALUE (flags, preferences, last-selected, < ~1 MB) │ → UserDefaults / @AppStorage (see "Key-value & preferences" below) │ └─ SECRETS (tokens, passwords, keys, anything sensitive) → Keychain — NEVER UserDefaults/plist/files. See the ios-security skill. ``` > Not your data? **Ephemeral, server-authoritative API responses** (feeds, catalogs you never edit) go in a response cache in `Caches/`, never your store — [references/response-cache.md](references/response-cache.md). **Offline REST mutations** (queue-and-retry against a backend) also live there. HealthKit **State of Mind** (mood) reads → [references/healthkit-state-of-mind.md](references/healthkit-state-of-mind.md). ### Step 2: Structured data → which framework | You need… | Use | Reference | |---|---|---| | Modern Apple-native, simple CRUD, `@Query` reactive SwiftUI, automatic CloudKit sync | **SwiftData** (iOS 17+) — the default | [references/swiftdata.md](references/swiftdata.md) | | Value-type models (structs), CloudKit record **sharing**, 50k+ rows, near-raw-SQLite perf, type-safe queries | **SQLiteData** (Point-Free) | [references/sqlite-grdb-sqlitedata.md](references/sqlite-grdb-sqlitedata.md) | | Complex raw SQL (4+ table joins, window functions), reactive `ValueObservation`, fine-grained migration control | **GRDB** | [references/sqlite-grdb-sqlitedata.md](references/sqlite-grdb-sqlitedata.md) | | iOS 16-and-earlier support, public CloudKit database, an existing Core Data app | **Core Data** | [references/core-data.md](references/core-data.md) | | Full-text search over any of the above | **FTS5** | [references/sqlite-grdb-sqlitedata.md](references/sqlite-grdb-sqlitedata.md) (§ FTS5) | **Pick ONE per data store and commit.** Mixing SwiftData + Core Data on the same store has sharp edges; SwiftData is built on Core Data but they do not share a context. ### Step 3: Does it sync across the user's devices? ``` Sync needed? ├─ NO → local store (above) is done. └─ YES → what shape? ├─ Structured records → CloudKit │ ├─ SwiftData models? → SwiftData + CloudKit (automatic). references/cloudkit-sync.md │ ├─ SQLiteData? → SyncEngine (+ sharing). references/cloudkit-sync.md │ ├─ Custom store (GRDB/SQLite)→ CKSyncEngine. references/cloudkit-sync.md │ └─ Need public/shared DB? → Core Data + CloudKit, or raw CloudKit. references/cloudkit-sync.md ├─ Files users see in Files.app → iCloud Drive (ubiquitous container). references/icloud-drive.md └─ Small preferences (< 1 MB) → NSUbiquitousKeyValueStore. references/icloud-drive.md ``` ### Step 4: Changing the schema of a live app? **ALWAYS** read [references/migrations.md](references/migrations.md) before adding/changing/removing a column or model property. Migrations are **immutable once shipped**. An unsafe migration crashes users and loses data. This is the single most expensive mistake in this whole skill. --- ## The reference map | Topic | File | |---|---| | SwiftData — `@Model`, `@Query`, `ModelContext`, relationships, inheritance, concurrency, CloudKit, performance | [references/swiftdata.md](references/swiftdata.md) | | Core Data — stack, `NSPersistentContainer`, threading rules, `@FetchRequest`, CloudKit | [references/core-data.md](references/core-data.md) | | SQLite — GRDB + SQLiteData (`@Table`, `@FetchAll`, StructuredQueries, upsert, triggers, FTS5, app-group sharing, performance) | [references/sqlite-grdb-sqlitedata.md](references/sqlite-grdb-sqlitedata.md) | | CloudKit & cloud sync — CKSyncEngine, offline-first, conflict resolution, CKShare sharing, production-schema gotchas | [references/cloudkit-sync.md](references/cloudkit-sync.md) | | iCloud Drive & files — ubiquitous containers, `NSFileCoordinator`, conflict versions, key-value store | [references/icloud-drive.md](references/icloud-drive.md) | | Codable / JSON — synthesis, `CodingKeys`, manual containers, date strategies, `try?` traps | [references/codable-json.md](references/codable-json.md) | | Schema migrations — additive/idempotent/tested; Core Data↔SwiftData feasibility + backfill, Codable-payload evolution, CloudKit append-only rename traps, round-trip coverage | [references/migrations.md](references/migrations.md) | | File storage & strategy — Documents vs Application Support vs Caches vs tmp, backup exclusion, disk pressure, atomic restore-from-archive | [references/file-storage.md](references/file-storage.md) | | Response cache & offline HTTP queue — two-tier SWR cache, cache-policy fallback, offline mutation queue (idempotency / dependsOn / TTL / jitter), background uploads | [references/response-cache.md](references/response-cache.md) | | HealthKit — State of Mind (`HKStateOfMind`): valence scale, associations/labels, read-only auth, async fetch | [references/healthkit-state-of-mind.md](references/healthkit-state-of-mind.md) | --- ## SwiftData in 90 seconds (the default) ```swift import SwiftData @Model final class Track { // final CLASS, never struct @Attribute(.unique) var id: String // (drop .unique if using CloudKit) var title: String var duration: TimeInterval var genre: String? @Relationship(deleteRule: .cascade, inverse: \Album.tracks) var album: Album? init(id: String, title: String, duration: TimeInterval) { // explicit init REQUIRED self.id = id self.title = title self.duration = duration } } // App WindowGroup { ContentView() } .modelContainer(for: [Track.self, Album.self]) // View — reactive, auto-updates struct TracksView: View { @Query(filter: #Predicate<Track> { $0.duration > 180 }, sort: \.title) var tracks: [Track] @Environment(\.modelContext) private var context var body: some View { List(tracks) { Text($0.title) } } func add() { context.insert(Track(id: UUID().uuidString, title: "New", duration: 0)) // auto-saves; call try context.save() to persist immediately } } ``` Full coverage (background contexts, N+1 prefetching, `#Index`, history tracking, class inheritance, CloudKit constraints) → [references/swiftdata.md](references/swiftdata.md). --- ## Key-value & preferences (UserDefaults / @AppStorage) For small, non-sensitive flags and settings only: ```swift @AppStorage("hasOnboarded") var hasOnboarded = false // backed by UserDefaults @AppStorage("sortOrder") var sortOrder = "date" UserDefaults.standard.set(2.0, forKey: "textScale") ``` Limits / rules: - **< ~1 MB**, key-value shape only. Not a database — don't store arrays of records here. - **Never** for secrets — `UserDefaults` is unencrypted, backed up to iCloud, and readable by MDM profiles. Use the Keychain (see the **ios-security** skill). - For SwiftUI **prototyping** with no persistence yet, plain `@Observable` arrays + sample data are fine; reach for SwiftData the moment the user says "save", "persist", or "database". ```swift // Prototyping default — in-memory, no store @Observable @MainActor final class NotesModel { var notes: [Note] = Note.sampleData func add(_ n: Note) { notes.insert(n, at: 0) } } ``` --- ## Secrets → Keychain (pointer) Storing a token, password, API key, or any sensitive value is **out of scope here on purpose** — it belongs in the Keychain with the right accessibility class and (where applicable) the Secure Enclave. See the **ios-security** skill for `SecItem`, `errSecDuplicateItem` handling, and CryptoKit. Never persist secrets via UserDefaults, plist, JSON files, or an unencrypted database column. --- ## tvOS — there is no persistent local storage (CRITICAL) This catches every iOS developer porting to tvOS: - **No Documents directory.** Application Support maps to **Caches** — the system deletes it under storage pressure, **even between launches**. - A local-only SwiftData / Core Data / GRDB / SQLite store **will lose all data**. - **You must use iCloud as the system of record:** SwiftData+CloudKit, **SQLiteData + SyncEngine** (recommended — local DB becomes a rebuildable cache), CloudKit, or `NSUbiquitousKeyValueStore`. Treat all local files as cache. - Core Data + CloudKit on tvOS is risky: CloudKit metadata inflates the local store, which then gets deleted randomly. - `UserDefaults` on tvOS is capped ~500 KB (vs ~4 MB on iOS). Migrations on tvOS must be idempotent and handle a **fresh empty database** every launch (they're effectively create-or-upgrade). See [references/migrations.md](references/migrations.md) § tvOS. --- ## Anti-rationalization table | The thought | The reality | |---|---| | "Just adding a column, no migration needed" | Schema changes without a migration crash existing users. [references/migrations.md](references/migrations.md) prevents data loss. | | "I'll just `ALTER TABLE` to change the type / add NOT NULL" | `cannot add NOT NULL column` on existing rows. Add nullable, backfill in a second migration. | | "Simple query, I don't need the skill" | Naive relationship access in a loop is N+1. Prefetch (`relationshipKeyPathsForPrefetching` / joins) — 100× faster. | | "CloudKit sync is straightforward" | 15+ failure modes. The #1 first-ship bug: schema deployed to *Development* but not *Production* → silent empty results. See [references/cloudkit-sync.md](references/cloudkit-sync.md). | | "I know Codable well enough" | `try?` swallows `DecodingError` → silent data loss for the 1% of payloads that change. [references/codable-json.md](references/codable-json.md). | | "I'll store these tasks as a JSON file" | Can't query/filter/sort, whole file loaded into memory, concurrent-write corruption. Use a database. | | "Downloaded images go in Documents" | Documents is backed up to iCloud → backup bloat → App Store rejection. Re-downloadable content goes in **Caches** with `isExcludedFromBackup`. | | "UserDefaults is fine for this token" | Unencrypted, iCloud-backed, MDM-visible. Tokens go in the Keychain. | | "I'll use local storage on tvOS" | tvOS deletes Caches at any time. iCloud-first or the data vanishes. | | "SwiftData is simpler, let's migrate Core Data now, mid-project" | Mixed Core Data + SwiftData has sharp edges. Finish the milestone; migrate with dedicated time + tests. | | "It works in TestFlight, the server must be wrong" | TestFlight uses CloudKit *Development*; the App Store uses *Production*. Deploy the schema. | --- ## Automated audits (when available) If the developer has audit agents wired up (e.g. an `axiom`-style toolchain), the high-value persistence scans are: - **SwiftData audit** — struct models, missing schema registration, array relationships without defaults, background-context misuse, N+1, stale predicates, CloudKit conformance gaps. - **Core Data audit** — thread-confinement violations, missing merge policies, N+1, migration-options gaps, context isolation. - **Codable audit** — `try?` swallowing errors, `JSONSerialization` misuse, date/timezone handling, silent field drops, enum future-case crashes. - **iCloud / CloudKit audit** — entitlement checks, incomplete `CKError` coverage, missing account-change observation, polling instead of subscriptions, unsupported SwiftData+CloudKit features. - **Storage audit** — wrong file locations, missing backup exclusions, sensitive data on disk, unbounded cache growth, orphan files. - **Database-schema audit** — unsafe `ALTER`/`DROP`, missing idempotency, FK declared-but-unenforced, `INSERT OR REPLACE` on FK-referenced tables. These are optional accelerators; the references below contain the same rules in prose for manual review.
View on GitHub