- 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