| 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:
- Format — what shape is the data? (queryable records vs files vs key-value)
- 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. Offline REST mutations (queue-and-retry against a backend) also live there. HealthKit State of Mind (mood) reads → 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 |
| Value-type models (structs), CloudKit record sharing, 50k+ rows, near-raw-SQLite perf, type-safe queries | SQLiteData (Point-Free) | 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 |
| iOS 16-and-earlier support, public CloudKit database, an existing Core Data app | Core Data | references/core-data.md |
| Full-text search over any of the above | FTS5 | 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 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 |
Core Data — stack, NSPersistentContainer, threading rules, @FetchRequest, CloudKit | references/core-data.md |
SQLite — GRDB + SQLiteData (@Table, @FetchAll, StructuredQueries, upsert, triggers, FTS5, app-group sharing, performance) | references/sqlite-grdb-sqlitedata.md |
| CloudKit & cloud sync — CKSyncEngine, offline-first, conflict resolution, CKShare sharing, production-schema gotchas | references/cloudkit-sync.md |
iCloud Drive & files — ubiquitous containers, NSFileCoordinator, conflict versions, key-value store | references/icloud-drive.md |
Codable / JSON — synthesis, CodingKeys, manual containers, date strategies, try? traps | 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 |
| File storage & strategy — Documents vs Application Support vs Caches vs tmp, backup exclusion, disk pressure, atomic restore-from-archive | 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 |
HealthKit — State of Mind (HKStateOfMind): valence scale, associations/labels, read-only auth, async fetch | references/healthkit-state-of-mind.md |
SwiftData in 90 seconds (the default)
import SwiftData
@Model
final class Track {
@Attribute(.unique) var id: String
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) {
self.id = id
self.title = title
self.duration = duration
}
}
WindowGroup { ContentView() }
.modelContainer(for: [Track.self, Album.self])
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))
}
}
Full coverage (background contexts, N+1 prefetching, #Index, history tracking, class inheritance, CloudKit constraints) → references/swiftdata.md.
Key-value & preferences (UserDefaults / @AppStorage)
For small, non-sensitive flags and settings only:
@AppStorage("hasOnboarded") var hasOnboarded = false
@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".
@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 § 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 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. |
| "I know Codable well enough" | try? swallows DecodingError → silent data loss for the 1% of payloads that change. 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.