
Axiom Data
- 710 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-data is a Swift agent skill that implements Axiom app data models, persistence, CloudKit sync, and secure storage for developers building macOS menu bar applications with reliable local and backed data.
About
axiom-data is a charleswiltgen/axiom skill marked mandatory for any data persistence, database, storage, CloudKit, or serialization work in Axiom macOS menu bar apps. It routes agents through SwiftData, Core Data, GRDB, and SQLiteData choices, schema migrations, CloudKit sync assumptions, Codable and JSON serialization, iCloud Drive and local file storage, Keychain credential storage, and CryptoKit encryption or signing decisions. The skill acts as a gate: agents must consult it before wiring features that read or write durable state so queries and sync behavior stay consistent across releases. Developers reach for axiom-data when implementing new entities, migration paths, or secure credential flows in Axiom projects instead of improvising ad hoc persistence that breaks CloudKit or Keychain expectations on Apple platforms during feature development.
- Axiom data model conventions
- Read/write lifecycle patterns
- Query and persistence guidance
- Schema evolution awareness
- Feature-to-storage mapping
Axiom Data by the numbers
- 710 all-time installs (skills.sh)
- Ranked #104 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 710 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you persist data in Axiom macOS apps?
Implement and wire Axiom app data models, persistence patterns, queries, and sync assumptions so features read and write reliable local or backed data.
Who is it for?
Swift developers building charleswiltgen/axiom menu bar apps who need guided persistence, sync, and secure storage decisions before shipping data-backed features.
Skip if: Developers on non-Apple stacks or web backends where SwiftData, Core Data, and CloudKit patterns do not apply.
When should I use this skill?
The user implements databases, schema migrations, CloudKit sync, Codable models, Keychain secrets, or CryptoKit encryption in an Axiom app.
What you get
Swift data models, persistence layer wiring, migration plans, and CloudKit or Keychain integration notes for Axiom features.
- Data model and persistence wiring
- Migration and sync integration notes
By the numbers
- Covers four Swift persistence backends: SwiftData, Core Data, GRDB, and SQLiteData
Files
Data & Persistence
You MUST use this skill for ANY data persistence, database, storage, CloudKit, or serialization work.
When to Use
Use this skill when working with:
- Databases (SwiftData, Core Data, GRDB, SQLiteData)
- Schema migrations
- CloudKit sync
- File storage (iCloud Drive, local storage)
- Data serialization (Codable, JSON)
- Storage strategy decisions
- Keychain / secure credential storage
- Encryption, signing, key management (CryptoKit)
Quick Reference
| Symptom / Task | Reference |
|---|---|
SwiftData @Model, @Query, ModelContext, sectioned queries / ResultsObserver / @Attribute(.codable) / dynamic compound predicates (OS27) | See skills/swiftdata.md |
| SwiftData schema migration, VersionedSchema | See skills/swiftdata-migration.md |
| SwiftData migration crashes, data loss | See skills/swiftdata-migration-diag.md |
| Migrating from Realm to SwiftData | See skills/realm-migration-ref.md |
| SwiftData vs SQLiteData decision | See skills/sqlitedata-migration.md |
| GRDB queries, ValueObservation, DatabaseMigrator | See skills/grdb.md |
| GRDB performance, indexes, EXPLAIN QUERY PLAN, cursors | See skills/grdb-performance.md |
| Full-text search (FTS5) in GRDB or SQLiteData | See skills/sqlite-fts-ref.md |
| Storing or querying JSON inside a SQLite column (JSON1, JSONB) | See skills/sql-json-ref.md |
| GRDB shared across app + widget/extension (App Group) | See skills/grdb-app-groups.md |
| SQLiteData @Table, CRUD, SyncEngine | See skills/sqlitedata.md |
| SQLiteData advanced patterns, CTEs, views | See skills/sqlitedata-ref.md |
| Core Data stack, relationships, concurrency | See skills/core-data.md |
| Core Data migration crashes, thread errors | See skills/core-data-diag.md |
| ANY schema migration safety | See skills/database-migration.md |
| Codable, JSON encoding/decoding | See skills/codable.md |
| Cloud sync architecture, offline-first | See skills/cloud-sync.md |
| CloudKit, CKSyncEngine, CKRecord | See skills/cloudkit-ref.md |
| iCloud Drive, ubiquitous containers | See skills/icloud-drive-ref.md |
| Cloud sync errors, conflict resolution | See skills/cloud-sync-diag.md |
| Storage strategy, where to store data | See skills/storage.md |
| Storage issues, files disappeared | See skills/storage-diag.md |
| Storage management, disk pressure | See skills/storage-management-ref.md |
| Keychain / secure credential storage | See axiom-security (skills/keychain.md) |
| Keychain errors (errSecDuplicateItem) | See axiom-security (skills/keychain-diag.md) |
| Keychain API reference | See axiom-security (skills/keychain-ref.md) |
| Encryption / signing / key management | See axiom-security (skills/cryptokit.md) |
| CryptoKit API reference | See axiom-security (skills/cryptokit-ref.md) |
| File protection, NSFileProtection | See axiom-security (skills/file-protection-ref.md) |
| tvOS data persistence (no local storage) | See axiom-swift (skills/tvos.md) |
| tvOS + CloudKit SyncEngine | See skills/sqlitedata.md |
Automated Scanning
Core Data audit → Launch core-data-auditor agent or /axiom:audit core-data (safety violations, architectural gaps — migration options, thread-confinement, N+1 queries, merge policies, context isolation) Codable audit → Launch codable-auditor agent or /axiom:audit codable (safety violations, semantic gaps — try? swallowing errors, JSONSerialization, date handling, silent field drops, wrapper-hidden fallbacks, cross-file strategy drift, enum future-case crashes) iCloud audit → Launch icloud-auditor agent or /axiom:audit icloud (entitlement checks, file coordination, incomplete CKError matrix coverage, missing account-change observation, polling vs CKSubscriptions, SwiftData + CloudKit unsupported features, compound risks like uncoordinated I/O across extensions) Storage audit → Launch storage-auditor agent or /axiom:audit storage (wrong file locations, missing backup exclusions, sensitive data on disk vs Keychain, missing App Group containers, unbounded cache growth, orphan files, compound risks like user data in tmp/ + critical content) Database schema audit → Launch database-schema-auditor agent or /axiom:audit database-schema (unsafe ALTER TABLE, DROP operations, missing idempotency, FK constraints declared but not enforced, incomplete upgrade paths, compound risks like INSERT OR REPLACE on FK-referenced tables) GRDB performance audit → Launch grdb-performance-auditor agent or /axiom:audit grdb-performance (raw SQL string interpolation, missing FK indexes in raw SQL, missing PRAGMA optimize for raw-GRDB apps, journal mode mismatch for app-group DBs, missing observesSuspensionNotifications for shared DBs, prefix-redundant indexes in raw SQL, legacy Record subclass) SwiftData audit → Launch swiftdata-auditor agent or /axiom:audit swiftdata (struct models, missing schema registration, array relationships without defaults, background context misuse, N+1 patterns, stale predicates, CloudKit conformance gaps, compound risks like struct model + array relationship)
Decision Tree
1. SwiftData? → skills/swiftdata.md, skills/swiftdata-migration.md 2. Core Data? → skills/core-data.md, skills/core-data-diag.md 3. GRDB? → skills/grdb.md 3a. GRDB perf, slow query, schema design for perf? → skills/grdb-performance.md 3b. FTS5 (full-text search, any layer)? → skills/sqlite-fts-ref.md 3c. DB shared across app + extension/widget/Live Activity? → skills/grdb-app-groups.md 4. SQLiteData? → skills/sqlitedata.md, skills/sqlitedata-ref.md 5. ANY schema migration? → skills/database-migration.md (ALWAYS — prevents data loss) 6. Realm migration? → skills/realm-migration-ref.md 7. SwiftData vs SQLiteData? → skills/sqlitedata-migration.md 8. Cloud sync architecture? → skills/cloud-sync.md 9. CloudKit? → skills/cloudkit-ref.md 10. iCloud Drive? → skills/icloud-drive-ref.md 11. Cloud sync errors? → skills/cloud-sync-diag.md 12. Codable/JSON serialization? → skills/codable.md 13. File storage strategy? → skills/storage.md, skills/storage-diag.md, skills/storage-management-ref.md 14. File protection? → See axiom-security (skills/file-protection-ref.md) 15. Keychain / storing tokens, passwords, secrets securely? → See axiom-security (skills/keychain.md), See axiom-security (skills/keychain-diag.md), See axiom-security (skills/keychain-ref.md) 16. SecItem errors (errSecDuplicateItem, errSecItemNotFound, errSecInteractionNotAllowed)? → See axiom-security (skills/keychain-diag.md) 17. Encryption, signing, Secure Enclave, CryptoKit? → See axiom-security (skills/cryptokit.md), See axiom-security (skills/cryptokit-ref.md) 18. Quantum-secure cryptography, HPKE, ML-KEM? → See axiom-security (skills/cryptokit.md) 19. Want Core Data safety scan? → core-data-auditor (Agent) 20. Want Codable anti-pattern scan? → codable-auditor (Agent) 21. Want iCloud sync audit? → icloud-auditor (Agent) 22. Want storage location audit? → storage-auditor (Agent) 23. Want database schema/migration safety scan? → database-schema-auditor (Agent) 23a. Want GRDB performance/app-group scan? → grdb-performance-auditor (Agent) 24. Want SwiftData code audit? → swiftdata-auditor (Agent) 25. tvOS data persistence? → See axiom-swift (skills/tvos.md) (CRITICAL: no persistent local storage) + skills/sqlitedata.md (CloudKit SyncEngine) 26. SwiftData @MainActor / background context threading? → /skill axiom-concurrency 27. Structured data generation with Foundation Models? → /skill axiom-ai
Sync patterns
- HealthKit anchored/observer queries as a generalizable change-tracking pattern → See axiom-health (skills/sync-and-background.md)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Just adding a column, no migration needed" | Schema changes without migration crash users. database-migration prevents data loss. |
| "I'll handle the migration manually" | Manual migrations miss edge cases. database-migration covers rollback and testing. |
| "Simple query, I don't need the skill" | Query patterns prevent N+1 and thread-safety issues. The skill has copy-paste solutions. |
| "CloudKit sync is straightforward" | CloudKit has 15+ failure modes. cloud-sync-diag diagnoses them systematically. |
| "I know Codable well enough" | Codable has silent data loss traps (try? swallows errors). codable skill prevents production bugs. |
| "I'll use local storage on tvOS" | tvOS has NO persistent local storage. System deletes Caches at any time. See axiom-swift (skills/tvos.md) for the iCloud-first pattern. |
| "UserDefaults is fine for this token" | UserDefaults is unencrypted, backed up to iCloud, and visible to MDM profiles. One audit catches it. keychain stores tokens securely. |
| "I'll encrypt it myself with CommonCrypto" | CryptoKit replaced CommonCrypto's buffer-management nightmares with one-line APIs. cryptokit prevents misuse. |
Critical Pattern: Migrations
ALWAYS read `skills/database-migration.md` when adding/modifying database columns.
This prevents:
- "FOREIGN KEY constraint failed" errors
- "no such column" crashes
- Data loss from unsafe migrations
Example Invocations
User: "I need to add a column to my SwiftData model" → Read: skills/database-migration.md (critical - prevents data loss)
User: "How do I query SwiftData with complex filters?" → Read: skills/swiftdata.md
User: "CloudKit sync isn't working" → Read: skills/cloud-sync-diag.md
User: "Should I use SwiftData or SQLiteData?" → Read: skills/sqlitedata-migration.md
User: "Check my Core Data code for safety issues" → Launch: core-data-auditor agent
User: "Scan for Codable anti-patterns before release" → Launch: codable-auditor agent
User: "Audit my iCloud sync implementation" → Launch: icloud-auditor agent
User: "Check if my files are stored in the right locations" → Launch: storage-auditor agent
User: "Audit my database migrations for safety" → Launch: database-schema-auditor agent
User: "How do I make my GRDB queries faster?" → Read: skills/grdb-performance.md
User: "Add search to my GRDB-backed app" / "Add search to SQLiteData app" → Read: skills/sqlite-fts-ref.md
User: "My widget needs to read the same database as the app" → Read: skills/grdb-app-groups.md
User: "My widget shows stale data from the database" → Read: skills/grdb-app-groups.md
User: "Audit my GRDB code for performance issues" → Launch: grdb-performance-auditor agent
User: "Check my SwiftData models for issues" → Launch: swiftdata-auditor agent
User: "How do I persist data on tvOS?" → Invoke: See axiom-swift (skills/tvos.md) + Read: skills/sqlitedata.md
User: "My tvOS app loses data between launches" → Invoke: See axiom-swift (skills/tvos.md)
User: "How do I store an auth token securely?" → Invoke: See axiom-security (skills/keychain.md)
User: "errSecDuplicateItem but I checked and the item doesn't exist" → Invoke: See axiom-security (skills/keychain-diag.md)
User: "How do I encrypt data with AES in Swift?" → Invoke: See axiom-security (skills/cryptokit.md)
User: "I need to sign data with the Secure Enclave" → Invoke: See axiom-security (skills/cryptokit.md)
User: "What's ML-KEM and should I use it?" → Invoke: See axiom-security (skills/cryptokit.md)
iCloud Sync Diagnostics
Overview
Core principle 90% of cloud sync problems stem from account/entitlement issues, network connectivity, or misunderstanding sync timing—not iCloud infrastructure bugs.
iCloud (both CloudKit and iCloud Drive) handles billions of sync operations daily across all Apple devices. If your data isn't syncing, the issue is almost always configuration, connectivity, or timing expectations.
Red Flags — Suspect Cloud Sync Issue
If you see ANY of these:
- Nothing EVER syncs, no errors, console silent → check
cloudKitContainerOptionsFIRST. ANSPersistentCloudKitContainerwith nodescription.cloudKitContainerOptionsset on its store description silently behaves like a plainNSPersistentContainerand mirrors nothing. This is the #1 "sync was never wired up" cause — diagnose it before anything else. - Works in dev, fails in TestFlight/App Store (often surfacing as
quotaExceeded) → CloudKit schema not deployed to Production. Named signature — recognize it instantly, do not chase it as a storage or network bug. - Files/data not appearing on other devices
- "iCloud account not available" errors
- Persistent sync conflicts
- CloudKit quota exceeded
- Upload/download stuck at 0%
- Works on simulator but not device
- Works on WiFi but not cellular
❌ FORBIDDEN "iCloud is broken, we should build our own sync"
- iCloud infrastructure handles trillions of operations
- Building reliable sync is incredibly complex
- 99% of issues are configuration or connectivity
---
Mandatory First Steps
ALWAYS check these FIRST (before changing code):
0. Confirm the container is actually CloudKit-backed (Core Data)
For NSPersistentCloudKitContainer, mirroring is OFF unless EVERY store description has cloudKitContainerOptions set. Omit it and the container loads fine, saves locally, raises no error, and syncs nothing. This is the first thing to verify when "it never synced once."
let container = NSPersistentCloudKitContainer(name: "Model")
let description = container.persistentStoreDescriptions.first!
// ❌ MISSING THIS LINE = silent no-op. Looks like a plain NSPersistentContainer.
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.example.app"
)
// Required for sync to function at all:
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// Verify at runtime — nil means this store mirrors nothing:
assert(description.cloudKitContainerOptions != nil, "Store is not CloudKit-backed")// 1. Check iCloud account status
func checkICloudStatus() async {
let status = FileManager.default.ubiquityIdentityToken
if status == nil {
print("❌ Not signed into iCloud")
print("Settings → [Name] → iCloud → Sign in")
return
}
print("✅ Signed into iCloud")
// For CloudKit specifically
let container = CKContainer.default()
do {
let status = try await container.accountStatus()
switch status {
case .available:
print("✅ CloudKit available")
case .noAccount:
print("❌ No iCloud account")
case .restricted:
print("❌ iCloud restricted (parental controls?)")
case .couldNotDetermine:
print("⚠️ Could not determine status")
case .temporarilyUnavailable:
print("⚠️ Temporarily unavailable (retry)")
@unknown default:
print("⚠️ Unknown status")
}
} catch {
print("Error checking CloudKit: \(error)")
}
}
// 2. Check entitlements
func checkEntitlements() {
// Verify iCloud container exists
if let containerURL = FileManager.default.url(
forUbiquityContainerIdentifier: nil
) {
print("✅ iCloud container: \(containerURL)")
} else {
print("❌ No iCloud container")
print("Check Xcode → Signing & Capabilities → iCloud")
}
}
// 3. Check network connectivity
func checkConnectivity() {
// Use NWPathMonitor or similar
print("Network: Check if device has internet")
print("Try on different networks (WiFi, cellular)")
}
// 4. Check device storage
func checkStorage() {
let homeURL = FileManager.default.homeDirectoryForCurrentUser
if let values = try? homeURL.resourceValues(forKeys: [
.volumeAvailableCapacityKey
]) {
let available = values.volumeAvailableCapacity ?? 0
print("Available space: \(available / 1_000_000) MB")
if available < 100_000_000 { // <100 MB
print("⚠️ Low storage may prevent sync")
}
}
}---
Decision Tree
CloudKit Sync Issues
CloudKit data not syncing?
├─ Account unavailable?
│ ├─ Check: await container.accountStatus()
│ ├─ .noAccount → User not signed into iCloud
│ ├─ .restricted → Parental controls or corporate restrictions
│ └─ .temporarilyUnavailable → Network issue or iCloud outage
│
├─ CKError.quotaExceeded?
│ └─ This is an UMBRELLA error — three distinct meanings:
│ 1. User iCloud storage full (rare; payload usually small)
│ → Prompt user to purchase more storage / delete old data
│ 2. Schema not deployed to Production environment
│ → Dev builds work, TestFlight/App Store fail with quotaExceeded
│ → Fix: CloudKit Console → Deploy Schema to Production
│ 3. Per-app subscription quota exceeded (100 CKSubscription/db cap)
│ → App creates subscriptions on every launch without dedup
│ → Fix: deterministic subscription IDs + fetch-before-save
│ → DIAGNOSE FIRST: which one? See "CKError.quotaExceeded" below.
│
├─ CKError.networkUnavailable?
│ └─ No internet connection
│ → Check WiFi/cellular
│ → Test on different network
│
├─ CKError.serverRecordChanged (conflict)?
│ └─ Concurrent modifications
│ → Implement conflict resolution
│ → Use savePolicy correctly
│
└─ SwiftData not syncing?
├─ Check ModelConfiguration CloudKit setup
├─ Verify private database only (no public/shared)
└─ Check for @Attribute(.unique) (not supported with CloudKit)iCloud Drive Sync Issues
iCloud Drive files not syncing?
├─ File not uploading?
│ ├─ Check: url.resourceValues(.ubiquitousItemIsUploadingKey)
│ ├─ Check: url.resourceValues(.ubiquitousItemUploadingErrorKey)
│ └─ Error details will indicate issue
│
├─ File not downloading?
│ ├─ Not requested? → startDownloadingUbiquitousItem(at:)
│ ├─ Check: url.resourceValues(.ubiquitousItemDownloadingErrorKey)
│ └─ May need manual download trigger
│
├─ File has conflicts?
│ ├─ Check: url.resourceValues(.ubiquitousItemHasUnresolvedConflictsKey)
│ └─ Resolve with NSFileVersion
│
└─ Files not appearing on other device?
├─ Check iCloud account on both devices (same account?)
├─ Check entitlements match on both
├─ Wait (sync not instant, can take minutes)
└─ Check Settings → iCloud → iCloud Drive → [App] is enabled---
Common CloudKit Errors
CKError.accountTemporarilyUnavailable
Cause: iCloud servers temporarily unavailable or user signed out
Fix:
if error.code == .accountTemporarilyUnavailable {
// Retry with exponential backoff
try await Task.sleep(for: .seconds(5))
try await retryOperation()
}CKError.quotaExceeded
Cause: UMBRELLA error — CloudKit returns quotaExceeded for three distinct conditions. Always disambiguate before alerting the user.
Diagnosis — pick the right meaning (a small per-user payload + dev/TestFlight divergence is almost never the literal storage cause):
// Smoking gun: error.userInfo["CKErrorUserDidResetEncryptedDataKey"] or
// "ServerErrorDescription" often contains the real reason.
if let info = error.userInfo["NSUnderlyingError"] as? NSError {
print("Underlying reason:", info.localizedDescription)
}
// Symptom matrix:
// • Works on dev, fails on TestFlight/App Store?
// → MEANING 2: Production schema not deployed.
// → Fix: CloudKit Console → switch to Development → click
// "Deploy Schema to Production". One-way, permanent.
//
// • Fails on every launch after a subscription burst, payload small?
// → MEANING 3: Per-app subscription quota (100 / database / app / user).
// → Fix: use deterministic CKSubscription IDs and fetch existing
// before saving; treat "already exists" as success.
//
// • User has actually exhausted iCloud storage (verify in Settings)?
// → MEANING 1: Literal storage quota. Prompt to manage iCloud.
if error.code == .quotaExceeded {
// For meaning 1 only — after ruling out 2 and 3:
showAlert(
title: "iCloud Storage Full",
message: "Please free up space in Settings → [Name] → iCloud → Manage Storage"
)
}Why CloudKit reuses this error code: server-side rejection paths for schema-missing and subscription-cap surface as quotaExceeded to clients, even though neither involves user storage. The Production Crisis Scenario below covers the dev-vs-prod path; the subscription cap is documented at https://developer.apple.com/documentation/cloudkit/cksubscription.
CKError.serverRecordChanged
Cause: Record modified on server since your last fetch. Most common root cause: saving a stale record without fetching the latest version first.
Diagnosis — check the simple fix FIRST:
// ❌ WRONG: Saving without fetching latest version
// This causes serverRecordChanged on EVERY concurrent edit
let record = CKRecord(recordType: "Note", recordID: existingID)
record["title"] = "Updated"
try await database.save(record) // Overwrites server version → conflict
// ✅ FIX: Fetch-then-modify-then-save (fixes 80% of cases)
let record = try await database.record(for: existingID) // Get latest
record["title"] = "Updated" // Modify the fetched record
try await database.save(record) // Save with correct changeTagIf fetch-then-save doesn't fix it (true concurrent edits from multiple devices):
if error.code == .serverRecordChanged,
let serverRecord = error.serverRecord,
let clientRecord = error.clientRecord {
// Merge records — only needed for real multi-device conflicts
let merged = mergeRecords(server: serverRecord, client: clientRecord)
try await database.save(merged)
}CKError.networkUnavailable
Cause: No internet connection
Fix:
if error.code == .networkUnavailable {
// Queue for retry when online
queueOperation(for: .whenOnline)
// Or show offline indicator
showOfflineIndicator()
}Silent Data Loss in Batch Operations
Symptom: Sync appears to work but records silently disappear or fail to save.
Common causes:
| Cause | Symptom | Fix |
|---|---|---|
| Record size > 1 MB | Individual records silently dropped from batch | Split large data into CKAsset |
| Batch partial failure | Some records save, others fail silently | Check perRecordSaveBlock for per-record errors |
| Conflict auto-resolution | Last-writer-wins overwrites valid data | Implement merge-based conflict resolution |
| Asset download not triggered | Record syncs but CKAsset content missing | Call fetchRecordZoneChanges with desiredKeys |
Diagnosis:
// ❌ WRONG: Batch save with no per-record error handling
let operation = CKModifyRecordsOperation(recordsToSave: records)
operation.modifyRecordsResultBlock = { result in
// Only catches operation-level failures — misses per-record errors
}
// ✅ CORRECT: Check each record individually
let operation = CKModifyRecordsOperation(recordsToSave: records)
operation.perRecordSaveBlock = { recordID, result in
switch result {
case .success(let record):
print("✅ Saved: \(recordID)")
case .failure(let error):
print("❌ Failed: \(recordID) — \(error)")
// Log for retry — this record was silently lost otherwise
}
}---
Common iCloud Drive Errors
Upload Errors
// ✅ Check upload error
func checkUploadError(url: URL) {
let values = try? url.resourceValues(forKeys: [
.ubiquitousItemUploadingErrorKey
])
if let error = values?.ubiquitousItemUploadingError {
print("Upload error: \(error.localizedDescription)")
if (error as NSError).code == NSFileWriteOutOfSpaceError {
print("iCloud storage full")
}
}
}Download Errors
// ✅ Check download error
func checkDownloadError(url: URL) {
let values = try? url.resourceValues(forKeys: [
.ubiquitousItemDownloadingErrorKey
])
if let error = values?.ubiquitousItemDownloadingError {
print("Download error: \(error.localizedDescription)")
// Common errors:
// - Network unavailable
// - Account unavailable
// - File deleted on server
}
}---
Debugging Patterns
Pattern 1: CloudKit Operation Not Completing
Symptom: Save/fetch never completes, no error
Diagnosis:
// Add timeout
Task {
try await withTimeout(seconds: 30) {
try await database.save(record)
}
}
// Log operation lifecycle
operation.database = database
operation.completionBlock = {
print("Operation completed")
}
operation.qualityOfService = .userInitiated
// Check if operation was cancelled
if operation.isCancelled {
print("Operation was cancelled")
}Common causes:
- No network connectivity
- Account issues
- Operation cancelled prematurely
Pattern 2: SwiftData CloudKit Not Syncing
Symptom: SwiftData saves locally but doesn't sync
Diagnosis:
// 1. Verify CloudKit configuration
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.example.app")
)
// 2. Check for incompatible attributes
// ❌ @Attribute(.unique) not supported with CloudKit
@Model
class Task {
@Attribute(.unique) var id: UUID // ← Remove this
var title: String
}
// Removing .unique means duplicates CAN appear. Replace enforcement with
// convergent dedup-on-import — see Pattern 3.
// 3. Check all properties have defaults or are optional
@Model
class Task {
var title: String = "" // ✅ Has default
var dueDate: Date? // ✅ Optional
}Pattern 3: Deterministic Dedup-on-Import (no unique constraints)
Symptom: Same logical record appears 2-5 times across devices. CloudKit cannot enforce uniqueness, so concurrent first-launch imports each create their own copy.
Why a naive "delete duplicates" pass fails: each device picks a different winner (e.g. "keep the newest" — clocks differ; "keep the first I saw" — order differs), so devices delete each other's survivors and the duplicates resurrect on the next sync. The fix is a convergent dedup: every device must independently compute the SAME winner from the data alone.
// Run on remote-change notification, after merging incoming changes.
// 1. Group by a stable BUSINESS key (not the CloudKit recordID — those differ).
let groups = Dictionary(grouping: allRecords, by: \.businessKey)
for (_, dupes) in groups where dupes.count > 1 {
// 2. Elect a winner deterministically — same input → same winner on EVERY device.
// Use a stable tiebreaker (UUID string), never a clock or local insertion order.
let winner = dupes.min { $0.stableID.uuidString < $1.stableID.uuidString }!
// 3. Merge field values from losers into the winner (don't lose user edits).
for loser in dupes where loser.stableID != winner.stableID {
winner.merge(from: loser)
context.delete(loser)
}
}
try context.save() // Deletions propagate; every device converged on the same winner.Rules that make it converge: (1) group by business key, (2) elect with a deterministic tiebreaker derived from record data (not Date or local order), (3) merge losers' fields into the winner before deleting. Idempotent — running it twice changes nothing.
Pattern 4: File Coordinator Deadlock
Symptom: File operations hang
Diagnosis:
// ❌ WRONG: Nested coordination can deadlock
coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in
// Don't create another coordinator here!
anotherCoordinator.coordinate(...) // ← Deadlock risk
}
// ✅ CORRECT: Single coordinator per operation
coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in
// Direct file operations only
try data.write(to: newURL)
}Pattern 5: Conflicts Not Resolving
Symptom: Conflicts persist even after resolution
Diagnosis:
// ❌ WRONG: Not marking as resolved
let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url)
for conflict in conflicts ?? [] {
// Missing: conflict.isResolved = true
}
// ✅ CORRECT: Mark resolved and remove
for conflict in conflicts ?? [] {
conflict.isResolved = true
}
try NSFileVersion.removeOtherVersionsOfItem(at: url)---
Production Crisis Scenario
SYMPTOM: Users report data not syncing after app update
DIAGNOSIS STEPS (run in order):
1. Check account status (2 min):
// On affected device
let status = FileManager.default.ubiquityIdentityToken
// nil? → Not signed in2. Verify entitlements unchanged (5 min):
- Compare old vs new build entitlements
- Verify container IDs match
3. Check for breaking changes (10 min):
- Did CloudKit schema change?
- Did ubiquitous container ID change?
- Are old and new versions compatible?
4. Test on clean device (15 min):
- Factory reset device or use new test device
- Sign into iCloud
- Install app
- Does sync work on fresh install?
ROOT CAUSES (90% of cases):
- Entitlements changed/corrupted in build
- CloudKit container ID mismatch
- Breaking schema changes
- Account restrictions (new parental controls, etc.)
FIX:
- Verify entitlements in build
- Test migration path from old version
- Add better error handling and user messaging
---
Monitoring
CloudKit Console (recommended - WWDC 2024)
Access: https://icloud.developer.apple.com/dashboard
Monitor:
- Error rates by type
- Latency percentiles (p50, p95, p99)
- Quota usage
- Request volume
Set alerts for:
- High error rate (>5%)
- Quota approaching limit (>80%)
- Latency spikes
Client-Side Logging
// ✅ Log all CloudKit operations
extension CKDatabase {
func saveWithLogging(_ record: CKRecord) async throws {
print("Saving record: \(record.recordID)")
let start = Date()
do {
try await self.save(record)
let duration = Date().timeIntervalSince(start)
print("✅ Saved in \(duration)s")
} catch let error as CKError {
print("❌ Save failed: \(error.code), \(error.localizedDescription)")
throw error
}
}
}---
Quick Diagnostic Checklist
func diagnoseCloudSyncIssue() async {
print("=== Cloud Sync Diagnosis ===")
// 1. Account
await checkICloudStatus()
// 2. Entitlements
checkEntitlements()
// 3. Network
checkConnectivity()
// 4. Storage
checkStorage()
// 5. For CloudKit
let container = CKContainer.default()
do {
let status = try await container.accountStatus()
print("CloudKit status: \(status)")
} catch {
print("CloudKit error: \(error)")
}
// 6. For iCloud Drive
if let url = getICloudContainerURL() {
let values = try? url.resourceValues(forKeys: [
.ubiquitousItemDownloadingErrorKey,
.ubiquitousItemUploadingErrorKey
])
print("Download error: \(values?.ubiquitousItemDownloadingError?.localizedDescription ?? "none")")
print("Upload error: \(values?.ubiquitousItemUploadingError?.localizedDescription ?? "none")")
}
print("=== End Diagnosis ===")
}---
Related Skills
skills/cloudkit-ref.md— CloudKit implementation detailsskills/icloud-drive-ref.md— iCloud Drive implementation detailsskills/storage.md— Choose sync approach
---
Last Updated: 2025-12-12 Skill Type: Diagnostic
Cloud Sync
Overview
Core principle: Choose the right sync technology for the data shape, then implement offline-first patterns that handle network failures gracefully.
Two fundamentally different sync approaches:
- CloudKit — Structured data (records with fields and relationships)
- iCloud Drive — File-based data (documents, images, any file format)
Quick Decision Tree
What needs syncing?
├─ Structured data (records, relationships)?
│ ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+)
│ ├─ Need shared/public database? → CKSyncEngine or raw CloudKit
│ └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+)
│
├─ Documents/files users expect in Files app?
│ └─ iCloud Drive (UIDocument or FileManager)
│
├─ Large binary blobs (images, videos)?
│ ├─ Associated with structured data? → CKAsset in CloudKit
│ └─ Standalone files? → iCloud Drive
│
└─ App settings/preferences?
└─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit)CloudKit vs iCloud Drive
| Aspect | CloudKit | iCloud Drive |
|---|---|---|
| Data shape | Structured records | Files/documents |
| Query support | Full query language | Filename only |
| Relationships | Native support | None (manual) |
| Conflict resolution | Record-level | File-level |
| User visibility | Hidden from user | Visible in Files app |
| Sharing | Record/database sharing | File sharing |
| Offline | Local cache required | Automatic download |
Red Flags
If ANY of these appear, STOP and reconsider:
- ❌ "Store JSON files in CloudKit" — Wrong tool. Use iCloud Drive for files
- ❌ "Build relationships manually in iCloud Drive" — Wrong tool. Use CloudKit
- ❌ "Assume sync is instant" — Network fails. Design offline-first
- ❌ "Skip conflict handling" — Conflicts WILL happen on multiple devices
- ❌ "Use CloudKit for user documents" — Users can't see them. Use iCloud Drive
- ❌ "Sync on app launch only" — Users expect continuous sync
Offline-First Pattern
MANDATORY: All sync code must work offline first.
// ✅ CORRECT: Offline-first architecture
class OfflineFirstSync {
private let localStore: LocalDatabase // GRDB, SwiftData, Core Data
private let syncEngine: CKSyncEngine
// Write to LOCAL first, sync to cloud in background
func save(_ item: Item) async throws {
// 1. Save locally (instant)
try await localStore.save(item)
// 2. Queue for sync (non-blocking)
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(item.recordID)
])
}
// Read from LOCAL (instant)
func fetch() async throws -> [Item] {
return try await localStore.fetchAll()
}
}
// ❌ WRONG: Cloud-first (blocks on network)
func save(_ item: Item) async throws {
// Fails when offline, slow on bad network
try await cloudKit.save(item)
try await localStore.save(item)
}Conflict Resolution Strategies
Conflicts occur when two devices edit the same data before syncing.
Strategy 1: Last-Writer-Wins (Simplest)
// Server always has latest, client accepts it
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
return server // Accept server version
}Use when: Data is non-critical, user won't notice overwrites
Strategy 2: Merge (Most Common)
// Combine changes from both versions
func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
// For each field, apply custom merge logic
merged["notes"] = mergeText(
local["notes"] as? String,
server["notes"] as? String
)
merged["tags"] = mergeSets(
local["tags"] as? [String] ?? [],
server["tags"] as? [String] ?? []
)
return merged
}Use when: Both versions contain valuable changes
Strategy 3: User Choice
// Present conflict to user
func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
let choice = await presentConflictUI(local: local, server: server)
return choice == .keepLocal ? local : server
}Use when: Data is critical, user must decide
Common Patterns
Pattern 1: SwiftData + CloudKit (Recommended for New Apps)
import SwiftData
// Automatic CloudKit sync with zero configuration
@Model
class Note {
var title: String
var content: String
var createdAt: Date
init(title: String, content: String) {
self.title = title
self.content = content
self.createdAt = Date()
}
}
// Container automatically syncs if CloudKit entitlement present
let container = try ModelContainer(for: Note.self)Limitations:
- Private database only (no public/shared)
- Automatic sync (less control over timing)
- No custom conflict resolution
@Attribute(.unique)not supported with CloudKit sync — remove if using CloudKit
Pattern 2: CKSyncEngine (Custom Persistence)
// For GRDB, SQLite, or custom databases
class MySyncManager: CKSyncEngineDelegate {
private let engine: CKSyncEngine
private let database: GRDBDatabase
func handleEvent(_ event: CKSyncEngine.Event) async {
switch event {
case .stateUpdate(let update):
// Persist sync state
await saveSyncState(update.stateSerialization)
case .fetchedDatabaseChanges(let changes):
// Apply changes to local DB
for zone in changes.modifications {
await handleZoneChanges(zone)
}
case .sentRecordZoneChanges(let sent):
// Mark records as synced
for saved in sent.savedRecords {
await markSynced(saved.recordID)
}
}
}
}See skills/cloudkit-ref.md for complete CKSyncEngine setup.
Pattern 3: iCloud Drive Documents
import UIKit
class MyDocument: UIDocument {
var content: Data?
override func contents(forType typeName: String) throws -> Any {
return content ?? Data()
}
override func load(fromContents contents: Any, ofType typeName: String?) throws {
content = contents as? Data
}
}
// Save to iCloud Drive (visible in Files app)
let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
.appendingPathComponent("Documents")
.appendingPathComponent("MyFile.txt")
let doc = MyDocument(fileURL: url!)
doc.content = "Hello".data(using: .utf8)
doc.save(to: url!, for: .forCreating)See skills/icloud-drive-ref.md for NSFileCoordinator and conflict handling.
Anti-Patterns
1. Ignoring Sync State
// ❌ WRONG: No awareness of pending changes
var items: [Item] = [] // Are these synced? Pending? Conflicted?
// ✅ CORRECT: Track sync state
struct SyncableItem {
let item: Item
let syncState: SyncState // .synced, .pending, .conflict
}2. Blocking UI on Sync
// ❌ WRONG: UI blocks until sync completes
func viewDidLoad() async {
items = try await cloudKit.fetchAll() // Spinner forever on airplane
tableView.reloadData()
}
// ✅ CORRECT: Show local data immediately
func viewDidLoad() {
items = localStore.fetchAll() // Instant
tableView.reloadData()
Task {
await syncEngine.fetchChanges() // Background update
}
}3. CloudKit Schema Not Deployed to Production
CloudKit has separate schemas for Development and Production. Your app in the App Store can only access the Production environment. If you add record types, fields, or indexes in Development but never deploy them, queries in Production return empty results with no error.
❌ Works in Xcode/TestFlight (Development) → empty results in App Store (Production)
Queries silently return zero results — no CKError, no crash, no clue.
✅ Before every App Store submission:
1. CloudKit Console → Select container
2. "Deploy Schema Changes" → Review changes → Deploy
3. Test with Production environment in Xcode scheme settingsTime cost of skipping: 3-7 days (rejection cycle + debugging "why does it work in TestFlight but not production?"). This is the #1 CloudKit gotcha for first-time submitters.
4. No Retry Logic
// ❌ WRONG: Single attempt
try await cloudKit.save(record)
// ✅ CORRECT: Exponential backoff
func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws {
for attempt in 0..<attempts {
do {
try await cloudKit.save(record)
return
} catch let error as CKError where error.isRetryable {
let delay = pow(2.0, Double(attempt))
try await Task.sleep(for: .seconds(delay))
}
}
throw SyncError.maxRetriesExceeded
}Sync State Indicators
Always show users the sync state:
enum SyncState {
case synced // ✓ (checkmark)
case pending // ↻ (arrows)
case conflict // ⚠ (warning)
case offline // ☁ with X
}
// In SwiftUI
HStack {
Text(item.title)
Spacer()
SyncIndicator(state: item.syncState)
}Entitlement Checklist
Before sync will work:
1. Xcode → Signing & Capabilities
- ✓ iCloud capability added
- ✓ CloudKit checked (for CloudKit)
- ✓ iCloud Documents checked (for iCloud Drive)
- ✓ Container selected/created
2. Apple Developer Portal
- ✓ App ID has iCloud capability
- ✓ CloudKit container exists (for CloudKit)
3. CloudKit Console (before App Store submission)
- ✓ Schema deployed to Production (record types, fields, indexes)
- ✓ Test with Production environment in Xcode scheme to verify queries work
4. Device
- ✓ Signed into iCloud
- ✓ iCloud Drive enabled (Settings → [Name] → iCloud)
Large Dataset Sync
When syncing 10,000+ records, naive approaches cause timeouts and launch slowdowns.
Initial Sync Strategy
// ❌ WRONG: Fetch everything at once
let allRecords = try await database.fetchAll()
syncEngine.state.add(pendingRecordZoneChanges: allRecords.map { .saveRecord($0.recordID) })
// ✅ CORRECT: Batch initial sync
func performInitialSync(batchSize: Int = 200) async throws {
var cursor: CKQueryOperation.Cursor? = nil
repeat {
let (results, nextCursor) = try await database.records(
matching: query,
resultsLimit: batchSize,
desiredKeys: nil,
continuationCursor: cursor
)
// Process batch
try await localStore.saveBatch(results.compactMap { try? $0.1.get() })
cursor = nextCursor
} while cursor != nil
}Incremental Sync (After Initial)
CKSyncEngine handles incremental sync automatically — it fetches only changes since the last sync token. Ensure you persist stateSerialization so the engine doesn't re-fetch everything on next launch.
Performance Guidelines
| Dataset Size | Strategy | Notes |
|---|---|---|
| < 1,000 records | Default CKSyncEngine | Works out of the box |
| 1,000–10,000 | Batch initial sync | 200-record batches, show progress UI |
| 10,000+ | Pagination + background | Use BGProcessingTask for initial sync |
| 100,000+ | Server-side filtering | Only sync what user needs, lazy-load rest |
Key insight: Initial sync is the bottleneck. After initial sync, CKSyncEngine's incremental approach handles large datasets efficiently because it only fetches deltas.
Pressure Scenarios
Scenario 1: "Just skip conflict handling for v1"
Situation: Deadline pressure to ship without conflict resolution.
Risk: Users WILL edit on multiple devices. Data WILL be lost silently.
Response: "Minimum viable conflict handling takes 2 hours. Silent data loss costs users and generates 1-star reviews."
Scenario 2: "Sync on app launch is enough"
Situation: Avoiding continuous sync complexity.
Risk: Users expect changes to appear within seconds, not on next launch.
Response: Use CKSyncEngine or SwiftData which handle continuous sync automatically.
Related Skills
skills/cloudkit-ref.md— Complete CloudKit API referenceskills/icloud-drive-ref.md— File-based sync with NSFileCoordinatorskills/cloud-sync-diag.md— Debugging sync failuresskills/storage.md— Choosing where to store data locally
CloudKit Reference
Purpose: Comprehensive CloudKit reference for database-based iCloud storage and sync Availability: iOS 10.0+ (basic), iOS 17.0+ (CKSyncEngine), iOS 17.0+ (SwiftData integration) Context: Modern CloudKit sync via CKSyncEngine (WWDC 2023) or SwiftData integration
When to Use This Skill
Use this skill when:
- Implementing structured data sync to iCloud
- Choosing between SwiftData+CloudKit, CKSyncEngine, or raw CloudKit APIs
- Setting up public/private/shared databases
- Implementing conflict resolution
- Debugging CloudKit sync issues
- Monitoring CloudKit performance
NOT for: Simple file sync (use skills/icloud-drive-ref.md instead)
Overview
CloudKit is for STRUCTURED DATA sync (records with relationships), not simple file sync.
Three modern approaches: 1. SwiftData + CloudKit (Easiest, iOS 17+) 2. CKSyncEngine (Custom persistence, iOS 17+, WWDC 2023) 3. Raw CloudKit APIs (Maximum control, more complexity)
---
Approach 1: SwiftData + CloudKit (Recommended)
When to use: iOS 17+ apps with SwiftData models
Limitations:
- Private database only (no public/shared)
- Automatic sync (less control)
@Attribute(.unique)not supported with CloudKit sync — remove if using CloudKit- SwiftData constraints apply
// ✅ CORRECT: SwiftData with CloudKit sync
import SwiftData
@Model
class Task {
var title: String
var isCompleted: Bool
var dueDate: Date
init(title: String, isCompleted: Bool = false, dueDate: Date) {
self.title = title
self.isCompleted = isCompleted
self.dueDate = dueDate
}
}
// Configure CloudKit container
let container = try ModelContainer(
for: Task.self,
configurations: ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.example.app")
)
)
// That's it! Sync happens automaticallyEntitlements required:
- iCloud capability
- CloudKit container
Use `skills/swiftdata.md` skill for SwiftData details
---
Approach 2: CKSyncEngine (Modern, WWDC 2023)
When to use: Custom persistence (SQLite, GRDB, JSON) with cloud sync
Advantages over raw CloudKit:
- Manages fetch/upload cycles automatically
- Handles conflicts
- Manages account changes
- Recommended over manual CKDatabase operations
// ✅ CORRECT: CKSyncEngine setup
import CloudKit
class SyncManager {
let syncEngine: CKSyncEngine
init() throws {
let config = CKSyncEngine.Configuration(
database: CKContainer.default().privateCloudDatabase,
stateSerialization: loadSyncState(),
delegate: self
)
syncEngine = try CKSyncEngine(config)
}
// Implement delegate methods
}
extension SyncManager: CKSyncEngineDelegate {
// Handle events
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
switch event {
case .stateUpdate(let stateUpdate):
saveSyncState(stateUpdate.stateSerialization)
case .accountChange(let change):
handleAccountChange(change)
case .fetchedDatabaseChanges(let changes):
applyDatabaseChanges(changes)
case .fetchedRecordZoneChanges(let changes):
applyRecordChanges(changes)
case .sentRecordZoneChanges(let changes):
handleSentChanges(changes)
case .willFetchChanges, .didFetchChanges,
.willSendChanges, .didSendChanges:
// Optional lifecycle events
break
@unknown default:
break
}
}
// Next batch of changes to send
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
// Return pending local changes
let pendingChanges = getPendingLocalChanges()
return CKSyncEngine.RecordZoneChangeBatch(
pendingSaves: pendingChanges,
recordIDsToDelete: []
)
}
}Key concepts:
- State serialization: Persist sync state between app launches
- Events: Delegate receives events for changes
- Batches: You provide pending changes, engine uploads them
- Automatic conflict resolution: Engine handles basic conflicts
---
Approach 3: Raw CloudKit APIs (Legacy)
When to use: Only if CKSyncEngine doesn't fit (rare)
Core types:
CKContainer— Entry pointCKDatabase— Public/private/shared scopeCKRecord— Individual data recordCKRecordZone— Logical groupingCKAsset— Binary file storage
Basic Operations
// ✅ Container and database
let container = CKContainer.default()
let privateDatabase = container.privateCloudDatabase
let publicDatabase = container.publicCloudDatabase
// ✅ Create record
let record = CKRecord(recordType: "Task")
record["title"] = "Buy groceries"
record["isCompleted"] = false
record["dueDate"] = Date()
// ✅ Save record
try await privateDatabase.save(record)
// ✅ Fetch record
let recordID = CKRecord.ID(recordName: "task-123")
let fetchedRecord = try await privateDatabase.record(for: recordID)
// ✅ Query records
let predicate = NSPredicate(format: "isCompleted == NO")
let query = CKQuery(recordType: "Task", predicate: predicate)
let (matchResults, _) = try await privateDatabase.records(matching: query)
for result in matchResults {
if case .success(let record) = result.1 {
print("Task: \(record["title"] as? String ?? "")")
}
}
// ✅ Delete record
try await privateDatabase.deleteRecord(withID: recordID)Update Record
// ✅ Fetch-then-modify-then-save (prevents serverRecordChanged errors)
let record = try await privateDatabase.record(for: recordID)
record["title"] = "Updated title"
record["isCompleted"] = true
try await privateDatabase.save(record)
// ✅ Batch modify (save + delete in one operation)
let operation = CKModifyRecordsOperation(
recordsToSave: [updatedRecord1, updatedRecord2],
recordIDsToDelete: [deletedID]
)
operation.perRecordSaveBlock = { recordID, result in
switch result {
case .success: print("Saved: \(recordID)")
case .failure(let error): print("Failed: \(recordID) — \(error)")
}
}
try await privateDatabase.add(operation)Conflict Resolution
// ✅ Handle conflicts with savePolicy
let operation = CKModifyRecordsOperation(
recordsToSave: [record],
recordIDsToDelete: nil
)
// Save only if server version unchanged
operation.savePolicy = .ifServerRecordUnchanged
// OR: Always overwrite server
operation.savePolicy = .changedKeys // Only changed fields
operation.modifyRecordsResultBlock = { result in
switch result {
case .success:
print("Saved")
case .failure(let error as CKError):
if error.code == .serverRecordChanged {
// Conflict - merge manually
let serverRecord = error.serverRecord
let clientRecord = error.clientRecord
let merged = mergeRecords(server: serverRecord, client: clientRecord)
// Retry with merged record
}
}
}
privateDatabase.add(operation)---
Database Scopes
| Scope | Accessibility | SwiftData Support | Use Case |
|---|---|---|---|
| Private | User only | ✅ Yes | Personal user data |
| Public | All users | ❌ No | Shared/public content |
| Shared | Invited users | ❌ No | Collaboration |
Private Database
// ✅ Private database (most common)
let privateDB = CKContainer.default().privateCloudDatabase
// User must be signed into iCloud
// Data syncs across user's devices
// Not visible to other usersPublic Database
// ✅ Public database (for shared content)
let publicDB = CKContainer.default().publicCloudDatabase
// Accessible to all app users
// Even unauthenticated users can read
// Writes require authentication
// Use for: Leaderboards, public content, discoveryShared Database
// ✅ Shared database (collaboration)
let sharedDB = CKContainer.default().sharedCloudDatabase
// For CKShare-based collaboration
// Users invited to specific record zones
// Use for: Shared documents, team data---
CloudKit Assets (Files)
// ✅ Store files as CKAsset
let imageURL = saveImageToTempFile(image) // Must be file URL
let asset = CKAsset(fileURL: imageURL)
let record = CKRecord(recordType: "Photo")
record["image"] = asset
record["caption"] = "Sunset"
try await privateDatabase.save(record)
// ✅ Retrieve asset
let fetchedRecord = try await privateDatabase.record(for: recordID)
if let asset = fetchedRecord["image"] as? CKAsset,
let fileURL = asset.fileURL {
let imageData = try Data(contentsOf: fileURL)
let image = UIImage(data: imageData)
}Important: CKAsset requires a file URL, not Data. Write data to temp file first.
---
CloudKit Console (Monitoring - WWDC 2024)
Developer Notifications
Set up alerts for:
- Schema changes
- Quota exceeded
- High error rates
- Custom thresholds
Telemetry
Monitor:
- Request count
- Error rate
- Latency (p50, p95, p99)
- Bandwidth usage
Logs
View:
- Individual requests
- Error details
- Performance bottlenecks
Access: https://icloud.developer.apple.com/dashboard
---
Common Patterns
Pattern 1: Initial Sync
// ✅ Fetch all records on first launch
func performInitialSync() async throws {
let predicate = NSPredicate(value: true) // All records
let query = CKQuery(recordType: "Task", predicate: predicate)
let (results, _) = try await privateDatabase.records(matching: query)
for result in results {
if case .success(let record) = result.1 {
saveToLocalDatabase(record)
}
}
}Pattern 2: Incremental Sync
// ✅ Use CKServerChangeToken for incremental fetches
func fetchChanges(since token: CKServerChangeToken?) async throws {
let zoneID = CKRecordZone.ID(zoneName: "Tasks")
let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration(
previousServerChangeToken: token
)
let operation = CKFetchRecordZoneChangesOperation(
recordZoneIDs: [zoneID],
configurationsByRecordZoneID: [zoneID: config]
)
operation.recordWasChangedBlock = { recordID, result in
if case .success(let record) = result {
updateLocalDatabase(with: record)
}
}
operation.recordWithIDWasDeletedBlock = { recordID, _ in
deleteFromLocalDatabase(recordID)
}
operation.recordZoneFetchResultBlock = { zoneID, result in
if case .success(let (token, _, _)) = result {
saveChangeToken(token) // For next fetch
}
}
try await privateDatabase.add(operation)
}---
Entitlements
Required entitlements in Xcode:
<!-- iCloud capability -->
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<!-- CloudKit container -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.example.app</string>
</array>Setup: 1. Xcode → Target → Signing & Capabilities 2. "+ Capability" → iCloud 3. Check "CloudKit" 4. Select or create container
---
Subscriptions (Push Notifications)
Database Subscription
// ✅ Get notified of ANY change in private database
let subscription = CKDatabaseSubscription(subscriptionID: "all-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // Silent push
subscription.notificationInfo = notificationInfo
try await privateDatabase.save(subscription)Query Subscription
// ✅ Get notified when records matching a query change
let predicate = NSPredicate(format: "priority > 3")
let subscription = CKQuerySubscription(
recordType: "Task",
predicate: predicate,
subscriptionID: "high-priority-tasks",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.alertBody = "High priority task changed"
notificationInfo.shouldBadge = true
subscription.notificationInfo = notificationInfo
try await privateDatabase.save(subscription)Zone Subscription
// ✅ Get notified of any change in a specific zone
let zoneID = CKRecordZone.ID(zoneName: "Tasks")
let subscription = CKRecordZoneSubscription(
zoneID: zoneID,
subscriptionID: "tasks-zone"
)
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true
subscription.notificationInfo = notificationInfo
try await privateDatabase.save(subscription)Handling Push Notifications
// In AppDelegate
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)
if notification.subscriptionID == "all-changes" {
try? await fetchChanges(since: savedChangeToken)
return .newData
}
return .noData
}---
Sharing Records
Create a Share
// ✅ Share a record with other users
let record = try await privateDatabase.record(for: recordID)
// Record must be in a custom zone (not default zone)
let share = CKShare(rootRecord: record)
share[CKShare.SystemFieldKey.title] = "Shared Task List"
share.publicPermission = .none // Invite-only
// Save both the record and share together
let operation = CKModifyRecordsOperation(
recordsToSave: [record, share],
recordIDsToDelete: nil
)
try await privateDatabase.add(operation)Present Sharing UI
import CloudKit
import UIKit
// ✅ UIKit sharing controller
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.delegate = self
present(sharingController, animated: true)
// Delegate methods
extension ViewController: UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController,
failedToSaveShareWithError error: Error) {
print("Share failed: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
return "My Shared List"
}
}Manage Participants
// ✅ Check participants
for participant in share.participants {
print("\(participant.userIdentity.nameComponents?.givenName ?? "Unknown")")
print(" Acceptance: \(participant.acceptanceStatus)")
print(" Permission: \(participant.permission)")
// .readOnly, .readWrite, .none
}
// ✅ Remove participant
share.removeParticipant(participant)
try await privateDatabase.save(share)Accept a Share
// In SceneDelegate or AppDelegate
func userDidAcceptCloudKitShareWith(_ cloudKitShareMetadata: CKShare.Metadata) {
let operation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata])
operation.acceptSharesResultBlock = { result in
switch result {
case .success: print("Share accepted")
case .failure(let error): print("Accept failed: \(error)")
}
}
CKContainer(identifier: cloudKitShareMetadata.containerIdentifier)
.add(operation)
}---
Quick Reference
| Task | Modern API (iOS 17+) | Legacy API |
|---|---|---|
| Structured data sync | SwiftData + CloudKit | CKSyncEngine or CKDatabase |
| Custom persistence sync | CKSyncEngine | CKDatabase |
| Conflict resolution | Automatic (SwiftData/CKSyncEngine) | Manual (savePolicy) |
| Account changes | Handled automatically | Manual detection |
| Monitoring | CloudKit Console telemetry | Manual logging |
---
Related Skills
skills/swiftdata.md— SwiftData implementation detailsskills/storage.md— Choose CloudKit vs iCloud Driveskills/icloud-drive-ref.md— File-based iCloud syncskills/cloud-sync-diag.md— Debug CloudKit sync issues
---
Last Updated: 2025-12-12 Skill Type: Reference Minimum iOS: 10.0 (basic), 17.0 (CKSyncEngine, SwiftData integration) WWDC Sessions: 2023-10188 (CKSyncEngine), 2024-10122 (CloudKit Console)
Swift Codable Patterns
Comprehensive guide to Codable protocol conformance for JSON and PropertyList encoding/decoding in Swift 6.x.
Quick Reference
Decision Tree: When to Use Each Approach
Has your type...
├─ All properties Codable? → Automatic synthesis (just add `: Codable`)
├─ Property names differ from JSON keys? → CodingKeys customization
├─ Needs to exclude properties? → CodingKeys customization
├─ Enum with associated values? → Check enum synthesis patterns
├─ Needs structural transformation? → Manual implementation + bridge types
├─ Needs data not in JSON? → DecodableWithConfiguration (iOS 15+)
└─ Complex nested JSON? → Manual implementation + nested containersCommon Triggers
| Error | Solution |
|---|---|
| "Type 'X' does not conform to protocol 'Decodable'" | Ensure all stored properties are Codable |
| "No value associated with key X" | Check CodingKeys match JSON keys |
| "Expected to decode X but found Y instead" | Type mismatch; check JSON structure or use bridge type |
| "keyNotFound" | JSON missing expected key; make property optional or provide default |
| "Date parsing failed" | Configure dateDecodingStrategy on decoder |
---
Part 1: Automatic Synthesis
Swift automatically synthesizes Codable conformance when all stored properties are Codable.
Struct Synthesis
// ✅ Automatic synthesis
struct User: Codable {
let id: UUID // Codable
var name: String // Codable
var membershipPoints: Int // Codable
}
// JSON: {"id":"...", "name":"Alice", "membershipPoints":100}Requirements:
- All stored properties must conform to Codable
- Properties use standard Swift types or other Codable types
- No custom initialization logic needed
Enum Synthesis Patterns
Pattern 1: Raw Value Enums
enum Direction: String, Codable {
case north, south, east, west
}
// Encodes as: "north"The raw value itself becomes the JSON representation.
Pattern 2: Enums Without Associated Values
enum Status: Codable {
case success
case failure
case pending
}
// Encodes as: {"success":{}}Each case becomes an object with the case name as the key and empty dictionary as value.
Pattern 3: Enums With Associated Values
enum APIResult: Codable {
case success(data: String, count: Int)
case error(code: Int, message: String)
}
// success case encodes as:
// {"success":{"data":"example","count":5}}Gotcha: Unlabeled associated values generate _0, _1 keys:
enum Command: Codable {
case store(String, Int) // ❌ Unlabeled
}
// Encodes as: {"store":{"_0":"value","_1":42}}Fix: Always label associated values for predictable JSON:
enum Command: Codable {
case store(key: String, value: Int) // ✅ Labeled
}
// Encodes as: {"store":{"key":"value","value":42}}When Synthesis Breaks
Automatic synthesis fails when: 1. Computed properties - Only stored properties are encoded 2. Non-Codable properties - Custom types without Codable conformance 3. Property wrappers - @Published, @State (except @AppStorage with Codable types) 4. Class inheritance - Subclasses must implement init(from:) manually
---
Part 2: CodingKeys Customization
Use CodingKeys enum to customize encoding/decoding without full manual implementation.
Renaming Keys
struct Article: Codable {
let url: URL
let title: String
let body: String
enum CodingKeys: String, CodingKey {
case url = "source_link" // JSON uses "source_link"
case title = "content_name" // JSON uses "content_name"
case body // Matches JSON key
}
}
// JSON: {"source_link":"...", "content_name":"...", "body":"..."}Excluding Properties
Omit properties from CodingKeys to exclude them from encoding/decoding:
struct NoteCollection: Codable {
let name: String
let notes: [Note]
var localDrafts: [Note] = [] // ✅ Must have default value
enum CodingKeys: CodingKey {
case name
case notes
// localDrafts omitted - not encoded/decoded
}
}Rule: Excluded properties require default values or you must implement init(from:) manually.
Snake Case Conversion
For consistent snake_case → camelCase conversion:
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// JSON: {"first_name":"Alice", "last_name":"Smith"}
// Decodes to: User(firstName: "Alice", lastName: "Smith")Enum Associated Value Keys
Customize keys for enum associated values using {CaseName}CodingKeys:
enum Command: Codable {
case store(key: String, value: Int)
case delete(key: String)
enum StoreCodingKeys: String, CodingKey {
case key = "identifier" // Renames "key" to "identifier"
case value = "data" // Renames "value" to "data"
}
enum DeleteCodingKeys: String, CodingKey {
case key = "identifier"
}
}
// store case encodes as: {"store":{"identifier":"x","data":42}}Pattern: {CaseName}CodingKeys with capitalized case name.
---
Part 3: Manual Implementation
For structural differences between JSON and Swift models, implement init(from:) and encode(to:).
Container Types
| Container | When to Use |
|---|---|
| Keyed | Dictionary-like data with string keys |
| Unkeyed | Array-like sequential data |
| Single-value | Wrapper types that encode as a single value |
| Nested | Hierarchical JSON structures |
Nested Containers Example
Flatten hierarchical JSON:
// JSON:
// {
// "latitude": 37.7749,
// "longitude": -122.4194,
// "additionalInfo": {
// "elevation": 52
// }
// }
struct Coordinate {
var latitude: Double
var longitude: Double
var elevation: Double // Nested in JSON, flat in Swift
enum CodingKeys: String, CodingKey {
case latitude, longitude, additionalInfo
}
enum AdditionalInfoKeys: String, CodingKey {
case elevation
}
}
extension Coordinate: Decodable {
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
latitude = try values.decode(Double.self, forKey: .latitude)
longitude = try values.decode(Double.self, forKey: .longitude)
let additionalInfo = try values.nestedContainer(
keyedBy: AdditionalInfoKeys.self,
forKey: .additionalInfo
)
elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
}
}
extension Coordinate: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(latitude, forKey: .latitude)
try container.encode(longitude, forKey: .longitude)
var additionalInfo = container.nestedContainer(
keyedBy: AdditionalInfoKeys.self,
forKey: .additionalInfo
)
try additionalInfo.encode(elevation, forKey: .elevation)
}
}Bridge Types for Structural Mismatches
When JSON structure fundamentally differs from Swift model:
// JSON: {"USD": 1.0, "EUR": 0.85, "GBP": 0.73}
// Want: [ExchangeRate]
struct ExchangeRate {
let currency: String
let rate: Double
}
// Bridge type for decoding
private extension ExchangeRate {
struct List: Decodable {
let values: [ExchangeRate]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dictionary = try container.decode([String: Double].self)
values = dictionary.map { ExchangeRate(currency: $0, rate: $1) }
}
}
}
// Public interface
extension ExchangeRate {
static func decode(from data: Data) throws -> [ExchangeRate] {
let list = try JSONDecoder().decode(List.self, from: data)
return list.values
}
}---
Part 4: Date Handling
Built-in Strategies
let decoder = JSONDecoder()
// 1. ISO 8601 (recommended)
decoder.dateDecodingStrategy = .iso8601
// Expects: "2024-02-15T17:00:00+01:00"
// 2. Unix timestamp (seconds)
decoder.dateDecodingStrategy = .secondsSince1970
// Expects: 1708012800
// 3. Unix timestamp (milliseconds)
decoder.dateDecodingStrategy = .millisecondsSince1970
// Expects: 1708012800000
// 4. Custom formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX") // ✅ Always set
formatter.timeZone = TimeZone(secondsFromGMT: 0) // ✅ Always set
decoder.dateDecodingStrategy = .formatted(formatter)
// 5. Custom closure
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = ISO8601DateFormatter().date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode date string \(dateString)"
)
}ISO 8601 Nuances
Default: 2024-02-15T17:00:00+01:00 Timezone required: Without timezone offset, decoding may fail across regions
// ❌ No timezone - parsing depends on device locale
"2024-02-15T17:00:00"
// ✅ With timezone - unambiguous
"2024-02-15T17:00:00+01:00"Performance Consideration
Custom closures run for every date - optimize expensive operations:
// ❌ Creates new formatter for every date
decoder.dateDecodingStrategy = .custom { decoder in
let formatter = DateFormatter() // Expensive!
// ...
}
// ✅ Reuse formatter
let sharedFormatter = DateFormatter()
sharedFormatter.dateFormat = "yyyy-MM-dd"
decoder.dateDecodingStrategy = .custom { decoder in
// Use sharedFormatter
}---
Part 5: Type Transformation
StringBacked Wrapper
Handle APIs that encode numbers as strings:
protocol StringRepresentable: CustomStringConvertible {
init?(_ string: String)
}
extension Int: StringRepresentable {}
extension Double: StringRepresentable {}
struct StringBacked<Value: StringRepresentable>: Codable {
var value: Value
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let value = Value(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot convert '\(string)' to \(Value.self)"
)
}
self.value = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.description)
}
}
// Usage
struct Product: Codable {
let name: String
private let _price: StringBacked<Double>
var price: Double {
get { _price.value }
set { _price = StringBacked(value: newValue) }
}
enum CodingKeys: String, CodingKey {
case name
case _price = "price"
}
}
// JSON: {"name":"Widget","price":"19.99"}
// Decodes to: Product(name: "Widget", price: 19.99)Type Coercion
For loosely typed APIs that may return different types:
struct FlexibleValue: Codable {
let stringValue: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
stringValue = string
} else if let int = try? container.decode(Int.self) {
stringValue = String(int)
} else if let double = try? container.decode(Double.self) {
stringValue = String(double)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Cannot decode value to String, Int, or Double"
)
}
}
}Warning: Avoid this pattern unless the API is truly unpredictable. Prefer strict types.
---
Part 6: Advanced Patterns
DecodableWithConfiguration (iOS 15+)
For types that need data unavailable in JSON:
struct User: Encodable, DecodableWithConfiguration {
let id: UUID
var name: String
var favorites: Favorites // Not in JSON, injected via configuration
enum CodingKeys: CodingKey {
case id, name
}
init(from decoder: Decoder, configuration: Favorites) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
favorites = configuration // Injected
}
}
// Usage (iOS 17+)
let favorites = try await fetchFavorites()
let user = try JSONDecoder().decode(
User.self,
from: data,
configuration: favorites
)userInfo Workaround (iOS 15-16)
extension JSONDecoder {
private struct ConfigurationDecodingWrapper<T: DecodableWithConfiguration>: Decodable {
var wrapped: T
init(from decoder: Decoder) throws {
let config = decoder.userInfo[configurationUserInfoKey] as! T.DecodingConfiguration
wrapped = try T(from: decoder, configuration: config)
}
}
func decode<T: DecodableWithConfiguration>(
_ type: T.Type,
from data: Data,
configuration: T.DecodingConfiguration
) throws -> T {
let decoder = JSONDecoder()
decoder.userInfo[Self.configurationUserInfoKey] = configuration
let wrapper = try decoder.decode(ConfigurationDecodingWrapper<T>.self, from: data)
return wrapper.wrapped
}
}
private let configurationUserInfoKey = CodingUserInfoKey(rawValue: "configuration")!Partial Decoding
Decode only the fields you need:
struct ArticlePreview: Decodable {
let id: UUID
let title: String
// Omit body, comments, etc.
}
// JSON has many more fields, but we only decode id and title---
Part 7: Debugging
DecodingError Cases
do {
let user = try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
print("Missing key '\(key)' at path: \(context.codingPath)")
} catch DecodingError.typeMismatch(let type, let context) {
print("Type mismatch for \(type) at path: \(context.codingPath)")
} catch DecodingError.valueNotFound(let type, let context) {
print("Value not found for \(type) at path: \(context.codingPath)")
} catch DecodingError.dataCorrupted(let context) {
print("Data corrupted at path: \(context.codingPath)")
} catch {
print("Other error: \(error)")
}Debugging Techniques
1. Pretty-print JSON
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let jsonData = try encoder.encode(user)
print(String(data: jsonData, encoding: .utf8)!)2. Inspect coding path
// In custom init(from:)
print("Decoding at path: \(decoder.codingPath)")3. Validate JSON structure
// Quick check: Can it decode as Any?
let json = try JSONSerialization.jsonObject(with: data)
print(json) // See actual structure---
Anti-Patterns
| Anti-Pattern | Cost | Better Approach |
|---|---|---|
| Manual JSON string building | Injection vulnerabilities, escaping bugs, no type safety | Use JSONEncoder |
| `try?` swallowing DecodingError | Silent failures, debugging nightmares, data loss | Handle specific error cases |
| Optional properties to avoid decode errors | Runtime crashes, nil checks everywhere, masks structural issues | Fix JSON/model mismatch or use DecodableWithConfiguration |
| Duplicating partial models | 2-5 hours maintenance per change, sync issues, fragile | Use bridge types or configuration |
| Ignoring date timezone | Intermittent bugs across regions, data corruption | Always use ISO8601 with timezone or explicit UTC |
| `JSONSerialization` for Codable types | 3x more boilerplate, manual type casting, error-prone | Use JSONDecoder/JSONEncoder |
| No locale on DateFormatter | Parsing fails in non-US locales | Set locale = Locale(identifier: "en_US_POSIX") |
Why try? is Dangerous
// ❌ Silent failure - production bug waiting to happen
let user = try? JSONDecoder().decode(User.self, from: data)
// If this fails, user is nil - why? No idea.
// ✅ Explicit error handling
do {
let user = try JSONDecoder().decode(User.self, from: data)
} catch {
logger.error("Failed to decode user: \(error)")
// Now you know WHY it failed
}---
Pressure Scenarios
Scenario 1: "Just Use try? to Make It Compile"
Context: API integration deadline tomorrow, decoder failing on some edge case.
Pressure: "We can debug it later, just make it work now."
Why You'll Rationalize:
- "It's only failing on 1% of requests"
- "We can add logging later"
- "Customers won't notice"
What Actually Happens:
- Silent data loss for that 1%
- No logs, so you can't debug in production
- Customer complaints 3 months later
- You've forgotten the context by then
Discipline Response:
"Using try? here means we'll lose data silently. Let me spend 5 minutes handling the specific error case. If it's truly rare, I'll log it so we can fix the root cause."5-Minute Fix:
do {
return try decoder.decode(User.self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
logger.error("Missing key '\(key)' in API response", metadata: [
"path": .string(context.codingPath.description),
"rawJSON": .string(String(data: data, encoding: .utf8) ?? "")
])
throw APIError.invalidResponse(reason: "Missing key: \(key)")
} catch {
logger.error("Failed to decode User", error: error)
throw APIError.decodingFailed(error)
}Result: You discover the API sometimes omits the email field for deleted users. Fix: make email optional only for that case, not all users.
---
Scenario 2: "Dates Are Intermittent, Must Be Server Bug"
Context: Date parsing works in your timezone but fails for European QA team.
Pressure: "It works for me, QA must be doing something wrong."
Why You'll Rationalize:
- "My tests pass locally"
- "The server is probably sending bad data"
- "It's their device settings"
What Actually Happens:
- Server sends dates without timezone:
"2024-12-14T10:00:00" - Your device (PST) interprets as 10:00 PST
- QA device (CET) interprets as 10:00 CET
- Different absolute times, intermittent bugs
Discipline Response:
"Intermittent date failures are almost always timezone issues. Let me check if we're using ISO8601 with timezone offsets."
Check:
// ❌ Current (fails across timezones)
decoder.dateDecodingStrategy = .iso8601
// Server sends: "2024-12-14T10:00:00" (no timezone)
// PST device: Dec 14, 10:00 PST
// CET device: Dec 14, 10:00 CET
// Bug: Different times!
// ✅ Fix: Require server to send timezone
// "2024-12-14T10:00:00+00:00"
// OR: Explicitly parse as UTC
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.timeZone = TimeZone(secondsFromGMT: 0) // Force UTC
guard let date = formatter.date(from: dateString) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid ISO8601 date: \(dateString)"
)
}
return date
}Result: Bug fixed, server adds timezone to API (or you parse explicitly as UTC). No more intermittent failures.
---
Scenario 3: "Just Make It Optional"
Context: New API field causes decoding to fail. Product manager wants a fix in 1 hour.
Pressure: "Can't you just make that field optional? We need this shipped."
Why You'll Rationalize:
- "It's faster than fixing the API"
- "We can make it non-optional later"
- "Users won't notice"
What Actually Happens:
- Field is actually required for the feature
- You add
user.email ?? ""everywhere - 3 months later: production crash because
emailwas nil - Now you can't remember why it was optional
Discipline Response:
"Making it optional masks the real problem. Let me check if the API is wrong or our model is wrong. This will take 10 minutes."
Investigation:
// Step 1: Print raw JSON
do {
let json = try JSONSerialization.jsonObject(with: data)
print(json)
} catch {
print("Invalid JSON: \(error)")
}
// Step 2: Check if key exists but value is null
// {"email": null} vs key missing entirely
// Step 3: Check API docs - is email actually required?Common Outcomes: 1. API is wrong: Field should be there → File bug, get hotfix 2. Model is wrong: Field is optional in some flows → Use proper optionality with clear documentation 3. Structural mismatch: Field is nested → Use nested container
Result: You discover email is nested in user.contact.email in the new API version. Fix with nested container, not optionality.
// ✅ Correct fix
struct User: Decodable {
let id: UUID
let email: String // Still required
enum CodingKeys: CodingKey {
case id, contact
}
enum ContactKeys: CodingKey {
case email
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
let contact = try container.nestedContainer(
keyedBy: ContactKeys.self,
forKey: .contact
)
email = try contact.decode(String.self, forKey: .email)
}
}---
Related Skills
- axiom-concurrency — Codable types crossing actor boundaries must be
Sendable - `skills/swiftdata.md` —
@Modeltypes use Codable for CloudKit sync - axiom-networking —
Coderprotocol wraps Codable for Network.framework - axiom-integration —
AppEnumparameters use Codable serialization
---
Key Takeaways
1. Prefer automatic synthesis — Add : Codable when structure matches JSON 2. Use CodingKeys for simple mismatches — Rename or exclude without manual code 3. Manual implementation for structural differences — Nested containers, bridge types 4. Always set locale and timezone — DateFormatter requires en_US_POSIX and explicit timezone 5. Never swallow errors with try? — Handle DecodingError cases explicitly 6. Codable + Sendable — Value types (structs/enums) are ideal for async networking
Core Principle: Codable is Swift's universal serialization protocol. Master it once, use it everywhere.
Core Data Diagnostics & Migration
Overview
Core Data issues manifest as production crashes from schema mismatches, mysterious concurrency errors, performance degradation under load, and data corruption from unsafe migrations. Core principle 85% of Core Data problems stem from misunderstanding thread-confinement, schema migration requirements, and relationship query patterns—not Core Data defects.
Red Flags — Suspect Core Data Issue
If you see ANY of these, suspect a Core Data misunderstanding, not framework breakage:
- 🚩 #1 root cause Edited the existing
.xcdatamodelin place instead of adding a new model version. Any field added/renamed/retyped this way silently rewrites v1 of the model — the on-disk store now mismatches it, and existing devices crash with "model is incompatible with the one used to create the store". Check first: is there more than one.xcdatamodelinside the.xcdatamodeldbundle? - Crash on production launch: "Unresolvable fault" after schema change
- Crash: "The model used to open the store is incompatible with the one used to create the store"
- Thread-confinement error: "Accessing NSManagedObject on a different thread"
- App suddenly slow after adding a User→Posts relationship
- SwiftData app needs complex features; considering mixing Core Data alongside
- Schema migration works in simulator but crashes on production
- ❌ FORBIDDEN "Core Data is broken, we need a different database"
- Core Data handles trillions of records in production apps
- Schema mismatches and thread errors are always developer code, not framework
- Do not rationalize away the issue—diagnose it
Critical distinction Simulator deletes the database on each rebuild, hiding schema mismatch issues. Real devices keep persistent databases and crash immediately on schema mismatch. MANDATORY: Test migrations on real device with real data before shipping.
First fix to try — enable automatic lightweight migration
Most "incompatible store" crashes from additive changes are resolved by turning ON inferred migration (it is OFF by default on a raw coordinator). Always confirm this is set before authoring any custom migration:
// NSPersistentContainer (preferred)
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
// Raw NSPersistentStoreCoordinator
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL,
options: [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
)This only works when the change is inferable (add optional, add required-with-default, remove, rename-with-renaming-identifier) AND a new model version exists. It can NEVER infer an attribute type change.
Mandatory First Steps
ALWAYS run these FIRST (before changing code):
// 1. Identify the crash/issue type
// Screenshot the crash message and note:
// - "Unresolvable fault" = schema mismatch
// - "different thread" = thread-confinement
// - Slow performance = N+1 queries or fetch size issues
// - Data corruption = unsafe migration
// Record: "Crash type: [exact message]"
// 2. Check if it's schema mismatch
// Compare these:
let coordinator = persistentStoreCoordinator
let model = coordinator.managedObjectModel
let store = coordinator.persistentStores.first
// Get actual store schema version:
do {
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: storeURL,
options: nil
)
print("Store version identifier: \(metadata[NSStoreModelVersionIdentifiersKey] ?? "unknown")")
// Get app's current model version:
print("App model version: \(model.versionIdentifiers)")
// If different = schema mismatch
} catch {
print("Schema check error: \(error)")
}
// Record: "Store version vs. app model: match or mismatch?"
// 3. Check thread-confinement for concurrency errors
// For any NSManagedObject access:
print("Main thread? \(Thread.isMainThread)")
print("Context concurrency type: \(context.concurrencyType.rawValue)")
print("Accessing from: \(Thread.current)")
// Record: "Thread mismatch? Yes/no"
// 4. Profile relationship access for N+1 problems
// In Xcode, run with arguments:
// -com.apple.CoreData.SQLDebug 1
// Check Console for SQL queries:
// SELECT * FROM USERS; (1 query)
// SELECT * FROM POSTS WHERE user_id = 1; (1 query per user = N+1!)
// Record: "N+1 found? Yes/no, how many extra queries"
// 5. Check SwiftData vs. Core Data confusion
if #available(iOS 17.0, *) {
// If using SwiftData @Model + Core Data simultaneously:
// Error: "Store is locked" or "EXC_BAD_ACCESS"
// = trying to access same database from both layers
print("Using both SwiftData and Core Data on same store?")
}
// Record: "Mixing SwiftData + Core Data? Yes/no"What this tells you
- Schema mismatch → Proceed to Pattern 1 (lightweight migration decision)
- Thread-confinement error → Proceed to Pattern 2 (async/await concurrency)
- N+1 queries → Proceed to Pattern 3 (relationship prefetching)
- SwiftData + Core Data conflict → Proceed to Pattern 4 (bridging)
- Slow after migration → Proceed to Pattern 5 (testing safety)
MANDATORY INTERPRETATION
Before changing ANY code, identify ONE of these:
1. If crash is "Unresolvable fault" AND store/model versions differ → Schema mismatch (not user error) 2. If crash mentions "different thread" AND you're using DispatchQueue → Thread-confinement (not thread-safe design) 3. If performance degrades with relationship access → N+1 queries (check SQL log) 4. If SwiftData and Core Data code exist together → Conflicting data layers (architectural issue) 5. If migration test passes but production fails → Edge case in real data (testing gap)
If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add print statements to every NSManagedObject access (thread check)
- Add
-com.apple.CoreData.SQLDebug 1and count SQL queries - Establish baseline: what's actually happening vs. what you assumed
Decision Tree
Core Data problem suspected?
├─ Crash: "Unresolvable fault" / "incompatible store"?
│ └─ YES → Schema mismatch (store ≠ app model)
│ ├─ Edited the .xcdatamodel in place? → Add a NEW model version first
│ ├─ Add new required field / remove / rename? → Pattern 1a (lightweight migration)
│ ├─ Change an attribute type? → Pattern 1b (rename → add → backfill recipe)
│ └─ Don't know how to fix? → Pattern 1c (testing safety)
│
├─ Crash: "different thread"?
│ └─ YES → Thread-confinement violated
│ ├─ Using DispatchQueue for background work? → Pattern 2a (async context)
│ ├─ Mixing Core Data with async/await? → Pattern 2b (structured concurrency)
│ └─ SwiftUI @FetchRequest causing issues? → Pattern 2c (@FetchRequest safety)
│
├─ Performance: App became slow?
│ └─ YES → Likely N+1 queries
│ ├─ Accessing user.posts in loop? → Pattern 3a (prefetching)
│ ├─ Large result set? → Pattern 3b (batch sizing)
│ └─ Just added relationships? → Pattern 3c (relationship tuning)
│
├─ Using both SwiftData and Core Data?
│ └─ YES → Data layer conflict
│ ├─ Need Core Data features SwiftData lacks? → Pattern 4a (drop to Core Data)
│ ├─ Already committed to SwiftData? → Pattern 4b (stay in SwiftData)
│ └─ Unsure which to use? → Pattern 4c (decision framework)
│
└─ Migration works locally but crashes in production?
└─ YES → Testing gap
├─ Didn't test with real data? → Pattern 5a (production testing)
├─ Schema change affects large dataset? → Pattern 5b (migration safety)
└─ Need verification before shipping? → Pattern 5c (pre-deployment checklist)Common Patterns
Pattern Selection Rules (MANDATORY)
Apply ONE pattern at a time, starting with diagnostics
1. Always start with Mandatory First Steps — Identify the actual problem 2. Run decision tree — Narrow to specific pattern 3. Apply ONE pattern — Don't combine patterns 4. Test on real device — Simulator hides issues 5. Verify with migration test — Before deploying
FORBIDDEN
- ❌ Changing code without diagnostics
- ❌ Skipping real device testing
- ❌ Using simulator success as proof of migration safety
- ❌ Mixing multiple migration patterns
- ❌ Deploying migrations without pre-deployment verification
---
Pattern 1a: Lightweight Migration (Simple Schema Changes)
PRINCIPLE Core Data can automatically migrate simple schemas (additive changes) without data loss if done correctly.
✅ SAFE Lightweight Migrations
- Adding new optional field:
@NSManaged var nickname: String? - Adding new required field WITH default: Create attribute with default value
- Renaming entity or attribute: Use mapping model with automatic mapping
- Removing unused field: Just delete from model (data stays on disk, ignored)
❌ WRONG (Crashes production)
// BAD: Adding required field without migration
@NSManaged var userID: String // Required, no default
// BAD: Assuming simulator = production
// Works in simulator (deletes DB), crashes on real device
// BAD: Modifying field type
@NSManaged var createdAt: Date // Was String, now Date
// Core Data can't automatically convert✅ CORRECT (Safe lightweight migration)
// 1. In Xcode: Editor → Add Model Version
// Creates new .xcdatamodel version file
// 2. In new version, add required field WITH default:
@NSManaged var userID: String = UUID().uuidString
// 3. Mark as current model version:
// File Inspector → Versioned Core Data Model
// Check "Current Model Version"
// 4. Test:
// Simulate old version: delete app, copy old database, run with new code
// Real app loads → migration succeeded
// 5. Deploy when confidentWhen this works
- Adding optional fields (always safe)
- Adding required fields WITH default values
- Removing fields
- Renaming entities/attributes with mapping model
When this FAILS (don't try lightweight)
- Changing field type (String → Int)
- Making optional field required (data has nulls, can't convert)
- Complex relationship changes
- Custom data transformations needed
Time cost 5-10 minutes for lightweight migration setup
---
Pattern 1b: Custom Migration (Complex Schema Changes)
PRINCIPLE When lightweight migration can't infer the change, use staged migration (iOS 17+) for custom transformation logic. Reach for hand-authored mapping models + NSEntityMigrationPolicy only on iOS 16 and earlier.
Use when
- Changing field types (String → Date) — NEVER inferable, always custom
- Making optional required (need to populate existing nulls)
- Complex relationship restructuring
- Custom data transformations (e.g., split "firstName lastName" into separate fields)
Modern path (iOS 17+) — NSStagedMigrationManager
NSStagedMigrationManager replaces hand-authored mapping models. Express each hop between model versions as a stage: NSLightweightMigrationStage for inferable changes, NSCustomMigrationStage for code-driven transforms. Stages chain across multiple versions automatically and run inside a single transaction.
let v1 = NSManagedObjectModelReference(model: modelV1, versionChecksum: modelV1.versionChecksum)
let v2 = NSManagedObjectModelReference(model: modelV2, versionChecksum: modelV2.versionChecksum)
let stage = NSCustomMigrationStage(migratingFrom: v1, to: v2)
stage.didMigrateHandler = { context, _ in
// Backfill the new typed attribute from the renamed legacy one (see recipe below)
let users = try context.fetch(NSFetchRequest<NSManagedObject>(entityName: "User"))
let parser = ISO8601DateFormatter()
for user in users {
let legacy = user.value(forKey: "createdAt_str") as? String
user.setValue(legacy.flatMap(parser.date(from:)) ?? Date(), forKey: "createdAt")
}
try context.save()
}
let manager = NSStagedMigrationManager([stage])
let description = container.persistentStoreDescriptions.first!
description.setOption(manager, forKey: NSPersistentStoreStagedMigrationManagerOptionKey)Recipe: attribute type change (rename → add → backfill)
An attribute type change can NEVER be inferred and NEVER edited in place — Core Data has no way to reinterpret existing bytes (e.g. a String column as a Date). Always do this three-step recipe in a NEW model version:
1. Rename the old attribute (e.g. createdAt → createdAt_str, keep its original String type). Use a renaming identifier so Core Data maps the column, not drops it. 2. Add the new attribute with the target type (createdAt: Date), optional or with a default. 3. Backfill in code — in a NSCustomMigrationStage.didMigrateHandler (iOS 17+) or a guarded one-shot on first launch:
// Guarded one-shot fallback (works on any iOS version)
func backfillCreatedAtOnce(_ context: NSManagedObjectContext) throws {
let key = "didBackfillCreatedAt_v2"
guard !UserDefaults.standard.bool(forKey: key) else { return }
let parser = ISO8601DateFormatter()
let users = try context.fetch(NSFetchRequest<NSManagedObject>(entityName: "User"))
for user in users where user.value(forKey: "createdAt") == nil {
let legacy = user.value(forKey: "createdAt_str") as? String
user.setValue(legacy.flatMap(parser.date(from:)) ?? Date(), forKey: "createdAt")
}
try context.save()
UserDefaults.standard.set(true, forKey: key)
}The UserDefaults guard makes the backfill idempotent — without it, a relaunch re-runs the loop over already-migrated rows. Drop createdAt_str only in a LATER release, once every user has run the backfill.
Legacy path (iOS 16 and earlier) — mapping model + policy
Create a mapping model (File → New → Mapping Model), subclass NSEntityMigrationPolicy, and set it as the Custom Policy Class in the mapping inspector. Use only when you must support iOS 16 — NSStagedMigrationManager is unavailable there.
Critical safety rules
- ALWAYS backup database before testing migration
- Test migration on COPY of production data
- Verify data integrity after migration (spot checks)
- Create rollback plan if migration fails
Time cost 30-60 minutes per migration + testing
---
Pattern 2a: Async Context for Background Fetching
PRINCIPLE Core Data objects are thread-confined. Fetch on background thread, convert to lightweight representations for main thread.
❌ WRONG (Thread-confinement crash)
DispatchQueue.global().async {
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
let results = try! context.fetch(request)
DispatchQueue.main.async {
self.objects = results // ❌ CRASH: objects faulted on background thread
}
}✅ CORRECT (Use private queue context for background work)
// Create background context
let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.parent = viewContext
// Fetch on background thread
backgroundContext.perform {
do {
let results = try backgroundContext.fetch(userRequest)
// Convert to lightweight representation BEFORE main thread
let userIDs = results.map { $0.id } // Just the IDs, not full objects
DispatchQueue.main.async {
// On main thread, fetch full objects from main context
let mainResults = try self.viewContext.fetch(request)
self.objects = mainResults
}
} catch {
print("Fetch error: \(error)")
}
}Why this works
- Background context fetches on background thread (safe)
- Converts heavy objects to lightweight values (safe to pass to main)
- Main context fetches on main thread (safe)
- No thread-confined objects crossing thread boundaries
Time cost 10 minutes to restructure
---
Pattern 2b: Structured Concurrency (async/await with Core Data)
PRINCIPLE Use NSPersistentContainer or NSManagedObjectContext async methods for Swift Concurrency compatibility.
✅ CORRECT (iOS 13+ async APIs)
// iOS 13+: Use async perform
let users = try await viewContext.perform {
try viewContext.fetch(userRequest)
}
// Executes fetch on correct thread, returns to caller
// iOS 17+: Use Swift Concurrency async/await directly
let users = try await container.mainContext.fetch(userRequest)
// For background work:
let backgroundUsers = try await backgroundContext.perform {
try backgroundContext.fetch(userRequest)
}
// Fetch happens on background queue, thread-safe❌ WRONG (Mixing Swift Concurrency with DispatchQueue)
async {
DispatchQueue.global().async {
try context.fetch(request) // ❌ Wrong thread!
}
}Time cost 5 minutes to convert from DispatchQueue to async/await
---
Pattern 3a: Relationship Prefetching (Prevent N+1)
PRINCIPLE Tell Core Data to fetch relationships eagerly instead of lazy-loading on access.
❌ WRONG (N+1 query pattern)
let users = try context.fetch(userRequest)
for user in users {
let posts = user.posts // ❌ Triggers fetch for EACH user!
// 1 fetch for users + N fetches for relationships = N+1 total
}✅ CORRECT (Prefetch relationships)
var request = NSFetchRequest<User>(entityName: "User")
// Tell Core Data to fetch relationships eagerly
request.relationshipKeyPathsForPrefetching = ["posts", "comments"]
// Now relationships are fetched in a single query per relationship
let users = try context.fetch(request)
for user in users {
let posts = user.posts // ✅ INSTANT: Already fetched
// Total: 1 fetch for users + 1 fetch for all posts = 2 queries
}Other optimization patterns
// Batch size: fetch in chunks for large result sets
request.fetchBatchSize = 100
// Faulting behavior: convert faults to lightweight snapshots
request.returnsObjectsAsFaults = false // Keep objects in memory
// Use carefully—can cause memory pressure with large results
// Distinct: remove duplicates from relationship fetches
request.returnsDistinctResults = trueTime cost 2-5 minutes to add prefetching
---
Pattern 3b: Fetch Batch Sizing
PRINCIPLE For large result sets, fetch in batches to manage memory.
Example: Scrolling through 100,000 users
var request = NSFetchRequest<User>(entityName: "User")
request.fetchBatchSize = 100 // Fetch 100 at a time
// Set sort descriptor for stable pagination
request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)]
let results = try context.fetch(request)
// Memory footprint: ~100 users at a time, not all 100,000
for user in results {
// Accessing user 0-99: in memory
// Accessing user 100: batch refetch (user 100-199)
// Auto-pagination, minimal memory usage
}Time cost 3 minutes to tune batch size
---
Pattern 4a: Core Data Features SwiftData Doesn't Have
Scenario You chose SwiftData, but need features it lacks.
SwiftData lacks
- Complex migrations (auto-migration only)
- Custom validation (before save)
- Relationship delete rules (cascade, deny, nullify)
- Direct SQL queries
- Advanced prefetching
- Faulting control
When to drop to Core Data from SwiftData
- Need custom migrations
- Need validation logic
- Need complex relationship rules
- Need raw SQL for performance
- Need fault tolerance patterns
✅ CORRECT (Hybrid approach when necessary)
// Keep SwiftData for simple entities
@Model final class Note {
var id: String
var title: String
}
// Drop to Core Data for complex operations
let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
backgroundContext.parent = container.viewContext
// Fetch with Core Data, convert to SwiftData models
let results = try backgroundContext.perform {
try backgroundContext.fetch(coreDataRequest)
}CRITICAL Do NOT access the same entity from both SwiftData and Core Data simultaneously. One or the other, not both.
Time cost 30-60 minutes to create bridging layer
---
Pattern 4b: Stay in SwiftData (Recommended for New Projects)
Scenario You're in SwiftData and wondering if you need Core Data.
SwiftData provides 80% of Core Data functionality for modern apps
- Type-safe models (@Model)
- Reactive queries (@Query)
- CloudKit sync (built-in)
- Automatic migrations (for simple changes)
- Proper async/await integration
When SwiftData is sufficient
- Simple schemas (users, notes, todos)
- Minimal relationship complexity
- CloudKit sync needed
- iOS 17+ requirement acceptable
- No legacy Core Data code to maintain
Decision: Stay in SwiftData if you can answer YES to 3+ of these
- ✅ iOS 17+ only (no iOS 16 support needed)
- ✅ Simple relationships (1-to-many, not many-to-many)
- ✅ Standard migrations (add fields, remove fields)
- ✅ CloudKit sync beneficial
- ✅ Type safety important
Decision: Drop to Core Data if
- ❌ Need iOS 16 support (SwiftData iOS 17+ only)
- ❌ Complex relationship rules (cascade rules, constraints)
- ❌ Custom migrations required
- ❌ Raw SQL needed for performance
- ❌ Already have Core Data codebase
Time cost 0 minutes (decision only)
---
Pattern 5a: Safe Production Testing Before Migration
PRINCIPLE Never deploy a migration without testing against real data.
MANDATORY Pre-Deployment Checklist
// Step 1: Export production database
// From running app in simulator or real device:
// ~/Library/Developer/CoreData/[AppName]/
// Copy entire [AppName].sqlite database
// Step 2: Create migration test
@Test func testProductionDataMigration() throws {
// Copy production database to test location
let testDB = tempDirectory.appendingPathComponent("test.sqlite")
try FileManager.default.copyItem(from: prodDatabase, to: testDB)
// Attempt migration
var config = ModelConfiguration(url: testDB, isStoredInMemory: false)
let container = try ModelContainer(for: User.self, configurations: [config])
// Verify data integrity
let context = container.mainContext
let allUsers = try context.fetch(FetchDescriptor<User>())
// Spot checks: verify specific records migrated correctly
guard let user1 = allUsers.first(where: { $0.id == "test-id-1" }) else {
throw MigrationError.missingUser
}
// Check derived data is correct
XCTAssertEqual(user1.name, "Expected Name")
XCTAssertNotNil(user1.createdAt)
// Check relationships
XCTAssertEqual(user1.posts.count, expectedPostCount)
}
// Step 3: Run test against real production data
// Pass ✓ before shippingSafety rules
- ❌ NEVER test migrations with simulator (simulator deletes DB)
- ✅ ALWAYS test with copy of real production data
- ✅ ALWAYS verify spot checks (specific records)
- ✅ ALWAYS check relationships loaded correctly
- ✅ ALWAYS have rollback plan documented
Time cost 15-30 minutes to create migration test
---
Pattern 5c: Pre-Deployment Verification Checklist
MANDATORY before shipping ANY Core Data change
- [ ] Did you create a new .xcdatamodel version? (Not just editing the existing one)
- [ ] Does the new version have a mapping model if needed?
- [ ] Did you test migration with real production data? (Not simulator)
- [ ] Did you verify 5+ specific records migrated correctly?
- [ ] Did you check relationships loaded?
- [ ] Did you test on real device (oldest supported)?
- [ ] Does app launch without crashing? (Fresh install)
- [ ] Does app launch with old data? (Migration path)
- [ ] Is rollback plan documented? (In case production fails)
If you answer NO to any item
- ❌ DO NOT SHIP
- Go back, fix the issue, re-test
- One "NO" = data loss risk
Time cost 5 minutes checklist
---
Quick Reference Table
| Issue | Check | Fix |
|---|---|---|
| "Incompatible store" crash | Is there >1 .xcdatamodel in the bundle? | Add a NEW model version; never edit the existing one in place |
| "Unresolvable fault" crash | Do store/model versions match? | Enable shouldInfer/MigrateStoreAutomatically; add a model version |
| Changed an attribute's type | Can it be inferred? (No — never) | Rename old → add new typed attribute → in-code backfill |
| "Different thread" crash | Is fetch happening on main thread? | Use private queue context for background work |
| App became slow | Are relationships being prefetched? | Add relationshipKeyPathsForPrefetching |
| N+1 query performance | Check -com.apple.CoreData.SQLDebug 1 logs | Add prefetching or convert to lightweight representation |
| Custom migration needed | Targeting iOS 17+? | Use NSStagedMigrationManager (mapping model only for iOS 16) |
| Not sure about SwiftData vs. Core Data | Do you need iOS 16 support? | Use Core Data for iOS 16, SwiftData for iOS 17+ |
| Migration test works, production fails | Did you test with real data? | Create migration test with production database copy |
---
When You're Stuck After 30 Minutes
If you've spent >30 minutes and the Core Data issue persists:
STOP. You either
1. Skipped mandatory diagnostics (most common) 2. Misidentified the actual problem 3. Applied wrong pattern for your symptom 4. Haven't tested on real device/real data 5. Have edge case requiring custom NSEntityMigrationPolicy
MANDATORY checklist before claiming "skill didn't work"
- [ ] I ran all Mandatory First Steps diagnostics
- [ ] I identified the problem type (schema, concurrency, performance, bridging, testing)
- [ ] I checked Core Data SQL debug logs (
-com.apple.CoreData.SQLDebug 1) - [ ] I tested on real device with real data (not simulator)
- [ ] I applied the FIRST matching pattern from Decision Tree
- [ ] I created a migration test if schema changed
- [ ] I verified at least 3 specific records migrated correctly
- [ ] I have a rollback plan documented
If ALL boxes are checked and still broken
- You need custom NSEntityMigrationPolicy (not covered by basic patterns)
- Time cost: 60-90 minutes for complex migration
- Ask: "What data transformation is actually needed?" and implement custom policy
Time cost transparency
- Pattern 1 (lightweight migration): 5-10 minutes
- Pattern 1 (heavy migration with custom policy): 60-90 minutes
- Pattern 2 (concurrency): 5-10 minutes
- Pattern 3 (prefetching): 2-5 minutes
- Pattern 4 (bridging): 30-60 minutes
- Pattern 5 (testing): 15-30 minutes
---
Common Mistakes
❌ Testing migration in simulator only
- Simulator deletes database on rebuild, hiding schema mismatches
- Fix: ALWAYS test on real device or with production database copy
❌ Assuming default values protect against data loss
- Default values only work for new records, not existing data
- Fix: Backfill existing rows via a staged migration (iOS 17+) or one-shot — see Pattern 1b
❌ Accessing Core Data objects across threads without conversion
- Objects are thread-confined, can't cross thread boundaries
- Fix: Convert to lightweight representations before passing to other threads
❌ Not realizing relationship access = database query
user.poststriggers a fetch for EACH user (N+1)- Fix: Use relationshipKeyPathsForPrefetching or extract IDs first
❌ Mixing SwiftData and Core Data on same store
- Both layers can't access the same database simultaneously
- Fix: Choose one layer, or use hybrid approach with separate entities
❌ Deploying migrations without pre-deployment testing
- Edge cases in production data cause crashes
- Fix: MANDATORY migration test with real production data
❌ Rationalizing: "I'll just delete the data"
- ❌ FORBIDDEN: Users won't appreciate losing their data
- Users uninstall and leave bad reviews
- Fix: Invest in safe migration testing
---
Production Crisis Pressure: Defending Safe Migration Patterns
The Problem
Under production crisis pressure, you'll face requests to:
- "Users are crashing - just delete the database and start fresh"
- "Migration is taking too long - skip the testing and ship it"
- "We can't wait 2 days for proper migration - hack it together"
- "Schema mismatch? Just force-create a new store"
These sound like pragmatic crisis responses. But they cause data loss and permanent user trust damage. Your job: defend using data safety principles and customer impact, not fear of pressure.
Red Flags — PM/Manager Requests That Cause Data Loss
If you hear ANY of these during a production crisis, STOP and reference this skill:
- ❌ "Delete the persistent store and start fresh" – Users lose ALL their data permanently
- ❌ "Force lightweight migration without testing" – High risk of data corruption in production
- ❌ "Skip migration and create new store" – Abandons existing user data
- ❌ "We'll fix data issues after launch" – Impossible to recover lost/corrupted data
- ❌ "Just ship it, we can handle support tickets" – Data loss creates permanent user churn
- ❌ "Test on simulator is enough" – Simulator deletes database on rebuild, hides schema mismatches
How to Push Back Professionally
Step 1: Quantify the Customer Impact
"I want to resolve this crash ASAP, but let me show you what deleting the store means:
Current situation:
- 10,000 active users with data
- Average 50 items per user (500,000 total records)
- Users have 1 week to 2 years of accumulated data
If we delete the store:
- 10,000 users lose ALL their data on next app launch
- Uninstall rate: 60-80% (industry standard after data loss)
- App Store reviews: Expect 1-star reviews citing data loss
- Recovery: Impossible - data is gone permanently
Safe alternative:
- Test migration on real device with production data copy (2-4 hours)
- Deploy migration that preserves user data
- Uninstall rate: <5% (standard update churn)"Step 2: Demonstrate the Risk
Show the PM/manager what happens: 1. Copy production database from device backup 2. Run proposed "quick fix" (delete store) 3. Show: All user data gone permanently 4. Show alternative: Safe migration preserving data 5. Time comparison: 30 min hack vs. 2-4 hour safe migration
Reference
- "Users don't forgive data loss" (App Store review patterns)
- Migration testing on real device prevents 95% of production crashes
- Schema mismatch crashes affect 100% of existing users
Step 3: Offer Compromise
"I can get us through this crisis while protecting user data:
#### Fast track (4 hours total)
1. Copy production database from TestFlight user (30 min)
2. Write and test migration on real device copy (2 hours)
3. Submit build with tested migration (30 min)
4. Monitor first 100 updates for crashes (1 hour)
#### Fallback if migration fails
- Have "delete store" build ready as Plan B
- Only deploy if migration shows 100% failure rate
- Communicate data loss to users proactively
This approach:
- Tries safe path first (protects user data)
- Has emergency fallback (if migration impossible)
- Honest timeline (4 hours vs. "just delete it" 30 min)"Step 4: Document the Decision
If overruled (PM insists on deleting store):
Slack message to PM + team:
"Production crisis: Schema mismatch causing crashes for existing users.
PM decision: Delete persistent store to resolve immediately.
Impact assessment:
- 10,000 users lose ALL data permanently on next app launch
- Expected uninstall rate: 60-80% based on data loss patterns
- App Store review damage: High risk of 1-star reviews
- Customer support: Expect high volume of data loss complaints
- Recovery: Impossible - deleted data cannot be recovered
Alternative proposed (4-hour safe migration) was declined due to urgency.
I'm flagging this decision proactively so we can:
1. Prepare support team for data loss complaints
2. Draft App Store response to expected negative reviews
3. Consider user communication about data loss before launch"Why this works
- You're not questioning their judgment under pressure
- You're quantifying user impact (business consequences)
- You're offering a solution with honest timeline
- You're providing fallback option (not blocking progress)
- You're documenting the decision (protects you post-launch)
Real-World Example: Production Crash (500K Active Users)
Scenario
- Production app crashing for 100% of users after update
- Error: "The model used to open the store is incompatible with the one used to create the store"
- CTO says: "Delete the database and ship hotfix in 2 hours"
- 500,000 active users with average 6 months of data each
What to do
// ❌ WRONG - Deletes all user data (CTO's request)
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
let storeURL = /* persistent store URL */
try? FileManager.default.removeItem(at: storeURL) // 500K users lose data
try! coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: nil)
// ✅ CORRECT - Safe lightweight migration (4-hour timeline)
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
do {
try coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options)
// Migration succeeded - user data preserved
} catch {
// Migration failed — NOW consider deleting with user communication
print("Migration error: \(error)")
}In the meeting, show
1. Schema version mismatch causing crash 2. Lightweight migration can fix automatically 3. Testing on production database copy (2 hours) 4. Time comparison: 2 hours (safe) vs. immediate (data loss)
Time estimate 4 hours total (2 hours migration testing, 2 hours build/deploy)
Result
- Honest timeline manages expectations
- Safe migration preserves 500K users' data
- Uninstall rate: 3% (standard update churn)
- App Store reviews: No data loss complaints
Alternative if migration truly impossible
- Document why migration failed
- Communicate data loss to users proactively
- Provide export feature in next version
When to Accept Data Loss (Even If You Disagree)
Sometimes data loss is the only option. Accept if:
- [ ] Migration is genuinely impossible (tried on production data copy)
- [ ] PM/CTO understand 60-80% expected uninstall rate
- [ ] Team commits to user communication about data loss
- [ ] You've documented technical reasons migration failed
Document in Slack
"Production crisis: Migration failed on production data copy after 4-hour testing.
Technical details:
- Attempted lightweight migration: Failed with [error]
- Attempted heavy migration with mapping model: Failed with [error]
- Root cause: [specific schema incompatibility]
Data loss decision:
- No safe migration path exists
- PM approved delete persistent store approach
- Expected impact: 60-80% uninstall rate (500K → 100-200K users)
Mitigation plan:
- Add data export feature before next schema change
- Communicate data loss to users via in-app message
- Prepare support team for complaints
- Monitor uninstall rates post-launch"This protects you and shows you exhausted safe options first.
---
Real-World Impact
Before Core Data debugging 3-8 hours per issue
- Crashes in production (schema mismatch)
- Performance gradually degrades (N+1 queries)
- Thread errors in background operations
- Data corruption from unsafe migrations
- Customer trust damaged
After 30 minutes to 2 hours with systematic diagnosis
- Identify problem type with diagnostics (5 min)
- Apply correct pattern (5-10 min)
- Test on real device (varies)
- Deploy with confidence
Key insight Core Data has well-established patterns for every common issue. The problem is developers don't know which pattern applies to their symptom.
---
Last Updated: 2025-11-30 Status: TDD-tested with pressure scenarios Framework: Core Data (Foundation framework) Complements: SwiftData skill (understanding relationship to Core Data)
Migrating from SwiftData to SQLiteData
When to Switch
┌─────────────────────────────────────────────────────────┐
│ Should I switch from SwiftData to SQLiteData? │
├─────────────────────────────────────────────────────────┤
│ │
│ Performance problems with 10k+ records? │
│ YES → SQLiteData (10-50x faster for large datasets) │
│ │
│ Need CloudKit record SHARING (not just sync)? │
│ YES → SQLiteData (SwiftData cannot share records) │
│ │
│ Complex queries across multiple tables? │
│ YES → SQLiteData + raw GRDB when needed │
│ │
│ Need Sendable models for Swift 6 concurrency? │
│ YES → SQLiteData (value types, not classes) │
│ │
│ Testing @Model classes is painful? │
│ YES → SQLiteData (pure structs, easy to mock) │
│ │
│ Happy with SwiftData for simple CRUD? │
│ YES → Stay with SwiftData (simpler for basic apps) │
│ │
└─────────────────────────────────────────────────────────┘---
Pattern Equivalents
| SwiftData | SQLiteData |
|---|---|
@Model class Item | @Table nonisolated struct Item |
@Attribute(.unique) | @Column(primaryKey: true) or SQL UNIQUE |
@Relationship var tags: [Tag] | var tagIDs: [Tag.ID] + join query |
@Query var items: [Item] | @FetchAll var items: [Item] |
@Query(sort: \.title) | @FetchAll(Item.order(by: \.title)) |
@Query(filter: #Predicate { $0.isActive }) | @FetchAll(Item.where(\.isActive)) |
@Environment(\.modelContext) | @Dependency(\.defaultDatabase) |
context.insert(item) | Item.insert { Item.Draft(...) }.execute(db) |
context.delete(item) | Item.find(id).delete().execute(db) |
try context.save() | Automatic in database.write { } block |
ModelContainer(for:) | prepareDependencies { $0.defaultDatabase = } |
---
Code Example
SwiftData (Before)
import SwiftData
@Model
class Task {
var id: UUID
var title: String
var isCompleted: Bool
var project: Project?
init(title: String) {
self.id = UUID()
self.title = title
self.isCompleted = false
}
}
struct TaskListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \.title) private var tasks: [Task]
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
func addTask(_ title: String) {
let task = Task(title: title)
context.insert(task)
}
func deleteTask(_ task: Task) {
context.delete(task)
}
}SQLiteData (After)
import SQLiteData
@Table
nonisolated struct Task: Identifiable {
let id: UUID
var title = ""
var isCompleted = false
var projectID: Project.ID?
}
struct TaskListView: View {
@Dependency(\.defaultDatabase) var database
@FetchAll(Task.order(by: \.title)) var tasks
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
func addTask(_ title: String) {
try database.write { db in
try Task.insert {
Task.Draft(title: title)
}
.execute(db)
}
}
func deleteTask(_ task: Task) {
try database.write { db in
try Task.find(task.id).delete().execute(db)
}
}
}Key differences:
class→structwithnonisolated@Model→@Table@Query→@FetchAll@Environment(\.modelContext)→@Dependency(\.defaultDatabase)- Implicit save → Explicit
database.write { }block - Direct init →
.Drafttype for inserts @Relationship→ Explicit foreign key + join
---
CloudKit Sharing (SwiftData Can't Do This)
SwiftData supports CloudKit sync but NOT sharing. SQLiteData is the only Apple-native option for record sharing.
// 1. Setup SyncEngine with sharing
prepareDependencies {
$0.defaultDatabase = try! appDatabase()
$0.defaultSyncEngine = try SyncEngine(
for: $0.defaultDatabase,
tables: Task.self, Project.self
)
}
// 2. Share a record
@Dependency(\.defaultSyncEngine) var syncEngine
@State var sharedRecord: SharedRecord?
func shareProject(_ project: Project) async throws {
sharedRecord = try await syncEngine.share(record: project) { share in
share[CKShare.SystemFieldKey.title] = "Join my project!"
}
}
// 3. Present native sharing UI
.sheet(item: $sharedRecord) { record in
CloudSharingView(sharedRecord: record)
}Sharing enables: Collaborative lists, shared workspaces, family sharing, team features.
---
Performance Comparison
| Operation | SwiftData | SQLiteData | Improvement |
|---|---|---|---|
| Insert 50k records | ~4 minutes | ~45 seconds | 5x |
| Query 10k with predicate | ~2 seconds | ~50ms | 40x |
| Memory (10k objects) | ~80MB | ~20MB | 4x smaller |
| Cold launch (large DB) | ~3 seconds | ~200ms | 15x |
Benchmarks approximate, vary by device and data shape.
---
Migrating Existing User Data
Critical: Schema migration alone loses all user data. You must export from SwiftData and import into SQLiteData.
// 1. Read all records from SwiftData's backing store
func migrateExistingData(from modelContext: ModelContext, to database: any DatabaseWriter) throws {
// Fetch all SwiftData records
let descriptor = FetchDescriptor<SwiftDataTask>()
let existingTasks = try modelContext.fetch(descriptor)
// 2. Bulk insert into SQLiteData
try database.write { db in
for task in existingTasks {
try SQLiteTask.insert {
SQLiteTask.Draft(
id: task.id,
title: task.title,
isCompleted: task.isCompleted,
projectID: task.project?.id
)
}
.execute(db)
}
}
// 3. Verify migration
let count = try database.read { db in
try SQLiteTask.fetchCount(db)
}
assert(count == existingTasks.count, "Migration count mismatch!")
}Migration checklist:
- [ ] Export all models before deleting SwiftData container
- [ ] Migrate relationships (fetch parent IDs for foreign keys)
- [ ] Verify record counts match after migration
- [ ] Keep SwiftData container as backup until confirmed working
- [ ] Run migration on first launch with a version flag in UserDefaults
Gradual Migration Strategy
You don't have to migrate everything at once:
1. Add SQLiteData for new features — Keep SwiftData for existing simple CRUD 2. Migrate one model at a time — Start with the performance bottleneck 3. Use separate databases initially — SQLiteData for heavy data/sharing, SwiftData for preferences 4. Consolidate if needed — Or keep hybrid if it works
---
Common Gotchas
Relationships → Foreign Keys
// SwiftData: implicit relationship
@Relationship var tasks: [Task]
// SQLiteData: explicit column + query
// In child: var projectID: Project.ID
// To fetch: Task.where { $0.projectID.eq(#bind(project.id)) }Cascade Deletes
// SwiftData: @Relationship(deleteRule: .cascade)
// SQLiteData: Define in SQL schema
// "REFERENCES parent(id) ON DELETE CASCADE"No Automatic Inverse
// SwiftData: @Relationship(inverse: \Task.project)
// SQLiteData: Query both directions manually
let tasks = Task.where { $0.projectID.eq(#bind(project.id)) }
let project = Project.find(task.projectID)---
Related Skills:
skills/sqlitedata.md— Full SQLiteData API referenceskills/swiftdata.md— SwiftData patterns if staying with Apple's frameworkskills/grdb.md— Raw GRDB for complex queries
---
History: See git log for changes
Related skills
How it compares
Pick axiom-data over generic Swift persistence skills when building charleswiltgen/axiom menu bar apps with mandated CloudKit and Keychain patterns.
FAQ
When must developers use axiom-data in Axiom projects?
axiom-data states agents MUST use the skill for any data persistence, database, storage, CloudKit, serialization, Keychain, or CryptoKit work in Axiom apps. It prevents features from shipping with inconsistent query, sync, or secure storage assumptions across SwiftData, Core Data
Which persistence backends does axiom-data cover?
axiom-data covers SwiftData, Core Data, GRDB, and SQLiteData plus schema migrations, CloudKit sync, Codable JSON, iCloud Drive and local files, Keychain credentials, and CryptoKit encryption or signing when choosing storage for Axiom macOS menu bar features.