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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
If you see ANY of these, suspect a migration configuration problem:
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.
ALWAYS run these FIRST (before changing code):
// 1. Identify the crash/issue type
// Screenshot the crash message and note:
// - "Expected only Arrays" = relationship inverse missing
// - "incompatible model" = schema version mismatch
// - "Failed to fulfill faulting" = relationship integrity broken
// - Simulator works, device crashes = untested migration path
// Record: "Error type: [exact message]"
// 2. Check schema version configuration
// In your migration plan:
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
// ✅ VERIFY: All versions in order?
// ✅ VERIFY: Latest version matches container?
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
// ✅ VERIFY: Migration stages match schema transitions?
[migrateV1toV2, migrateV2toV3]
}
}
// In your app:
let schema = Schema(versionedSchema: SchemaV3.self) // ✅ VERIFY: Matches latest in plan?
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self // ✅ VERIFY: Plan is registered?
)
// Record: "Schema version: latest is [version]"
// 3. Check all models included in VersionedSchema
enum SchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
// ✅ VERIFY: Are ALL models listed? (even unchanged ones)
[Note.self, Folder.self, Tag.self]
}
}
// Record: "Missing models? Yes/no"
// 4. Check relationship inverse declarations
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Folder.notes) // ✅ VERIFY: inverse specified?
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes) // ✅ VERIFY: inverse specified?
var tags: [Tag] = []
}
// Record: "Relationship inverses: all specified? Yes/no"
// 5. Enable SwiftData debug logging
// In Xcode scheme, add argument:
// -com.apple.coredata.swiftdata.debug 1
// Run and check Console for SQL queries
// Record: "Debug log shows: [what you see]"
Before changing ANY code, identify ONE of these:
-com.apple.coredata.swiftdata.debug 1 and examine SQL outputUse this section when migration appears to complete without errors, but you want to verify data integrity.
After migration runs without crashing:
// 1. Verify record count matches pre-migration
let context = container.mainContext
let postMigrationCount = try context.fetch(FetchDescriptor<Note>()).count
print("Post-migration count: \(postMigrationCount)")
// Compare to pre-migration count
// 2. Spot-check specific records
let sampleNote = try context.fetch(
FetchDescriptor<Note>(predicate: #Predicate { $0.id == "known-test-id" })
).first
print("Sample note title: \(sampleNote?.title ?? "MISSING")")
// 3. Verify relationships intact
if let note = sampleNote {
print("Folder relationship: \(note.folder != nil ? "✓" : "✗")")
print("Tags count: \(note.tags.count)")
// Verify inverse relationships
if let folder = note.folder {
let folderHasNote = folder.notes.contains { $0.id == note.id }
print("Inverse relationship: \(folderHasNote ? "✓" : "✗")")
}
}
// 4. Check for orphaned data
let orphanedNotes = try context.fetch(
FetchDescriptor<Note>(predicate: #Predicate { $0.folder == nil })
)
print("Orphaned notes (should be 0 if cascade delete worked): \(orphanedNotes.count)")
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:
See patterns below for specific fixes.
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)
PRINCIPLE Many-to-many relationships require explicit inverse declarations.
@Model
final class Note {
var tags: [Tag] = [] // ❌ Missing inverse
}
@Model
final class Tag {
var notes: [Note] = [] // ❌ Missing inverse
}
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Inverse specified
}
@Model
final class Tag {
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ Inverse specified
}
Why this works SwiftData requires explicit inverse for many-to-many to create junction table correctly.
Time cost 2 minutes to add inverse declarations
PRINCIPLE In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order.
@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie] // ❌ No default value
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor] // ❌ No default value
}
// Crashes if "Actor" < "Movie" alphabetically
@Model
final class Actor {
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var movies: [Movie] = [] // ✅ Default value
}
@Model
final class Movie {
@Relationship(deleteRule: .nullify, inverse: \Actor.movies)
var actors: [Actor] = [] // ✅ Default value
}
Fixed in iOS 17.1+
Time cost 1 minute to add default values
PRINCIPLE Migration plan's schemas array must include ALL versions in order.
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV3.self] // ❌ Missing V2!
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3] // References V2 but not in schemas
}
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self] // ✅ All versions
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
}
Time cost 2 minutes to add missing version
PRINCIPLE Old models only accessible in willMigrate, new models only in didMigrate.
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ CRASH: SchemaV1.Note doesn't exist here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// Data lost because transformation never ran
}
)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note exists here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// Transform data while old models still accessible
for note in oldNotes {
note.transformed = transformLogic(note.oldValue)
}
try context.save() // ✅ Save before migration completes
},
didMigrate: nil
)
Time cost 5 minutes to move logic to correct closure
PRINCIPLE Simulator deletes database on rebuild. Real devices keep persistent databases.
# 1. Install v1 on real device
# Build with SchemaV1 as current version
# Run app, create sample data (100+ records)
# 2. Verify data exists
# Check app: should see 100+ records
# 3. Install v2 with migration
# Build with SchemaV2 as current version + migration plan
# Install over existing app (don't delete)
# 4. Verify migration succeeded
# App launches without crash
# Data still exists (100+ records)
# Relationships intact
import Testing
import SwiftData
@Test func testMigrationOnRealDevice() throws {
// This test MUST run on real device, not simulator
#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>())
// Verify data preserved
#expect(notes.count > 0)
// Verify relationships
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
PRINCIPLE Fetch relationships eagerly during migration to avoid faulting errors.
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 {
// ❌ May trigger fault, relationship not loaded
let folderName = note.folder?.name
}
},
didMigrate: nil
)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// ✅ Prefetch relationships
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// ✅ Relationships already loaded
let folderName = note.folder?.name
let tagCount = note.tags.count
}
try context.save()
},
didMigrate: nil
)
Time cost 3 minutes to add prefetching
| 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 |
When migration fails, verify ALL of these:
VersionedSchema.models arraySchemaMigrationPlan.schemas arrayinverse: on both sidesModelContainer initialization-com.apple.coredata.swiftdata.debug 1)willMigrate (not didMigrate)If you've spent >30 minutes and the migration issue persists:
swiftdata-migration skill)Before SwiftData migration debugging 2-8 hours per issue
After 15-45 minutes with systematic diagnosis
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.
Created 2025-12-09 Status Production-ready diagnostic patterns Framework SwiftData (Apple) Swift 5.9+