| name | axiom-swiftdata-migration-diag |
| description | Use when SwiftData migrations crash, fail to preserve relationships, lose data, or work in simulator but fail on device - systematic diagnostics for schema version mismatches, relationship errors, and migration testing gaps |
| license | MIT |
| metadata | {"version":"1.0.0"} |
SwiftData Migration Diagnostics
Overview
SwiftData migration failures manifest as production crashes, data loss, corrupted relationships, or simulator-only success. Core principle 90% of migration failures stem from missing models in VersionedSchema, relationship inverse issues, or untested migration paths—not SwiftData bugs.
Red Flags — Suspect SwiftData Migration Issue
If you see ANY of these, suspect a migration configuration problem:
- App crashes on launch after schema change
- "Expected only Arrays for Relationships" error
- "The model used to open the store is incompatible with the one used to create the store"
- "Failed to fulfill faulting for [relationship]"
- Migration works in simulator but crashes on real device
- Data exists before migration, gone after
- Relationships broken after migration (nil where they shouldn't be)
- ❌ FORBIDDEN "SwiftData migrations are broken, we should use Core Data"
- SwiftData handles millions of migrations in production apps
- Schema mismatches and relationship errors are always configuration, 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.
Mandatory First Steps
ALWAYS run these FIRST (before changing code):
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
}
let schema = Schema(versionedSchema: SchemaV3.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)
: {
models: [ .] {
[., ., .]
}
}
{
(deleteRule: .nullify, inverse: \.notes)
folder: ?
(deleteRule: .nullify, inverse: \.notes)
tags: [] []
}
What this tells you
- "Expected only Arrays for Relationships" → Proceed to Pattern 1 (relationship inverse fix)
- "incompatible model" → Proceed to Pattern 2 (schema version mismatch)
- Missing models in VersionedSchema → Proceed to Pattern 3 (complete schema snapshot)
- Simulator works, device crashes → Proceed to Pattern 4 (migration testing)
- Data lost after migration → Proceed to Pattern 5 (willMigrate/didMigrate misuse)
MANDATORY INTERPRETATION
Before changing ANY code, identify ONE of these:
- If error is "Expected only Arrays" AND relationship inverse missing → Relationship configuration issue
- If error mentions "incompatible" AND schema versions don't match → Version mismatch
- If models are missing from VersionedSchema → Incomplete schema snapshot
- If simulator succeeds but device fails → Untested migration path
- If data exists before but not after → willMigrate/didMigrate limitation violated
If diagnostics are contradictory or unclear
- STOP. Do NOT proceed to patterns yet
- Add
-com.apple.coredata.swiftdata.debug 1 and examine SQL output
- Check file system: does .sqlite file exist? What size?
- Establish baseline: what's actually happening vs. what you assumed
Verifying Migration Completed Successfully
Use this section when migration appears to complete without errors, but you want to verify data integrity.
Quick Verification Checklist
After migration runs without crashing:
let context = container.mainContext
let postMigrationCount = try context.fetch(FetchDescriptor<Note>()).count
print("Post-migration count: \(postMigrationCount)")
let sampleNote = try context.fetch(
FetchDescriptor<Note>(predicate: #Predicate { $0.id == "known-test-id" })
).first
print("Sample note title: \(sampleNote?.title ?? "MISSING")")
if let note = sampleNote {
print("Folder relationship: \(note.folder != nil ? "✓" : "✗")")
print("Tags count: \(note.tags.count)")
if let folder = note.folder {
let folderHasNote = folder.notes.contains { $0.id == note.id }
()
}
}
orphanedNotes context.fetch(
<>(predicate: # { .folder })
)
()
What Successful Migration Looks Like
Console Output:
Post-migration count: 1523 // Matches pre-migration
Sample note title: Test Note // Not "MISSING"
Folder relationship: ✓
Tags count: 3
Inverse relationship: ✓
Orphaned notes: 0
If you see:
- Record count differs → Data loss (check willMigrate logic)
- "MISSING" records → Schema mismatch or fetch error
- Relationships nil → Inverse configuration or prefetching issue
- Orphaned records >0 → Cascade delete rule not working
See patterns below for specific fixes.
Decision Tree
SwiftData migration problem suspected?
├─ Error: "Expected only Arrays for Relationships"?
│ └─ YES → Relationship inverse missing
│ ├─ Many-to-many relationship? → Pattern 1a (explicit inverse)
│ ├─ One-to-many relationship? → Pattern 1b (verify both sides)
│ └─ iOS 17.0 alphabetical bug? → Pattern 1c (default value workaround)
│
├─ Error: "incompatible model" or crash on launch?
│ └─ YES → Schema version mismatch
│ ├─ Latest schema not in plan? → Pattern 2a (add to schemas array)
│ ├─ Migration stage missing? → Pattern 2b (add stage)
│ └─ Container using wrong schema? → Pattern 2c (verify version)
│
├─ Migration runs but data missing?
│ └─ YES → Data loss during migration
│ ├─ Used didMigrate to access old models? → Pattern 3a (use willMigrate)
│ ├─ Forgot to save in willMigrate? → Pattern 3b (add context.save())
│ └─ Custom migration logic wrong? → Pattern 3c (debug transformation)
│
├─ Works in simulator but crashes on device?
│ └─ YES → Untested migration path
│ ├─ Never tested on real device? → Pattern 4a (real device testing)
│ ├─ Never tested upgrade path? → Pattern 4b (test v1 → v2 upgrade)
│ └─ Production data differs from test? → Pattern 4c (test with prod data)
│
└─ Relationships nil after migration?
└─ YES → Relationship integrity broken
├─ Forgot to prefetch relationships? → Pattern 5a (add prefetching)
├─ Inverse relationship wrong? → Pattern 5b (fix inverse)
└─ Delete rule caused cascade? → Pattern 5c (check delete rules)
Common Patterns
Pattern 1a: Fix "Expected only Arrays for Relationships"
PRINCIPLE Many-to-many relationships require explicit inverse declarations.
❌ WRONG (Causes "Expected only Arrays" error)
@Model
final class Note {
var tags: [Tag] = []
}
@Model
final class Tag {
var notes: [Note] = []
}
✅ CORRECT (Explicit inverse)
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
}
@Model
final class Tag {
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = []
}
Why this works SwiftData requires explicit inverse for many-to-many to create junction table correctly.
Time cost 2 minutes to add inverse declarations
Pattern 1b: iOS 17.0 Alphabetical Bug Workaround
PRINCIPLE In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order.
❌ WRONG (Crashes in iOS 17.0)
@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie]
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor]
}
✅ CORRECT (Works in iOS 17.0+)
@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie] = []
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor] = []
}
Fixed in iOS 17.1+
Time cost 1 minute to add default values
Pattern 2a: Schema Version Mismatch
PRINCIPLE Migration plan's schemas array must include ALL versions in order.
❌ WRONG (Missing version causes crash)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
}
✅ CORRECT (All versions in order)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
}
Time cost 2 minutes to add missing version
Pattern 3a: Data Loss from willMigrate/didMigrate Misuse
PRINCIPLE Old models only accessible in willMigrate, new models only in didMigrate.
❌ WRONG (Tries to access old models in didMigrate)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
✅ CORRECT (Transform in willMigrate)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for note in oldNotes {
note.transformed = transformLogic(note.oldValue)
}
try context.save()
},
didMigrate: nil
)
Time cost 5 minutes to move logic to correct closure
Pattern 4a: Real Device Testing
PRINCIPLE Simulator deletes database on rebuild. Real devices keep persistent databases.
Testing Workflow
Migration Test Code
import Testing
import SwiftData
@Test func testMigrationOnRealDevice() throws {
#if targetEnvironment(simulator)
throw XCTSkip("Migration test requires real device")
#endif
let container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
let context = container.mainContext
let notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
#expect(notes.count > 0)
for note in notes {
if note.folder != nil {
#expect(note.folder?.notes.contains { $0.id == note.id } == true)
}
}
}
Time cost 15 minutes to test on real device
Pattern 5a: Relationship Prefetching to Preserve Integrity
PRINCIPLE Fetch relationships eagerly during migration to avoid faulting errors.
❌ WRONG (Relationships may fault and break)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for note in notes {
let folderName = note.folder?.name
}
},
didMigrate: nil
)
✅ CORRECT (Prefetch relationships)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
let folderName = note.folder?.name
let tagCount = note.tags.count
}
try context.save()
},
didMigrate: nil
)
Time cost 3 minutes to add prefetching
Quick Reference: Error → Fix Mapping
| Error Message | Root Cause | Fix | Time |
|---|
| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add @Relationship(inverse:) to both sides | 2 min |
| "The model used to open the store is incompatible" | Schema version mismatch | Add missing version to schemas array | 2 min |
| "Failed to fulfill faulting for [relationship]" | Relationship not prefetched | Add relationshipKeyPathsForPrefetching | 3 min |
| App crashes after schema change | Missing model in VersionedSchema | Include ALL models in models array | 2 min |
| Data lost after migration | Transformation in wrong closure | Move logic from didMigrate to willMigrate | 5 min |
| Simulator works, device crashes | Untested migration path | Test on real device with real data | 15 min |
| Relationships nil after migration | Inverse relationship wrong | Fix @Relationship(inverse:) keypath | 3 min |
Debugging Checklist
When migration fails, verify ALL of these:
When You're Stuck After 30 Minutes
If you've spent >30 minutes and the migration issue persists:
STOP. You either
- Skipped mandatory diagnostics (most common)
- Misidentified the actual problem
- Applied wrong pattern for your symptom
- Haven't tested on real device/real data
- Have complex edge case requiring two-stage migration
MANDATORY checklist before claiming "skill didn't work"
If ALL boxes are checked and still broken
- You need two-stage migration (covered in
axiom-swiftdata-migration skill)
- Time cost: 30-60 minutes for complex type change migration
- Ask: "What data transformation is actually needed?" and implement two-stage pattern
Time Cost Transparency
- Pattern 1 (relationship inverse): 2-3 minutes
- Pattern 2 (schema version): 2-5 minutes
- Pattern 3 (willMigrate fix): 5-10 minutes
- Pattern 4 (real device testing): 15-30 minutes
- Pattern 5 (relationship prefetching): 3-5 minutes
Real-World Impact
Before SwiftData migration debugging 2-8 hours per issue
- App crashes on launch in production
- Data loss for existing users
- Relationships broken after migration
- Simulator success, device failure
- Customer trust damaged
After 15-45 minutes with systematic diagnosis
- Identify problem type with diagnostics (5 min)
- Apply correct pattern (5-10 min)
- Test on real device (15-30 min)
- Deploy with confidence
Key insight SwiftData has well-established patterns for every common migration issue. The problem is developers don't know which diagnostic applies to their error.
Resources
WWDC: 2025-291, 2023-10195
Docs: /swiftdata
Skills: axiom-swiftdata-migration, axiom-swiftdata, axiom-database-migration
Created 2025-12-09
Status Production-ready diagnostic patterns
Framework SwiftData (Apple)
Swift 5.9+