SwiftData migration crashes when working with relationships

The following complex migration consistently crashes the app with the following error:

SwiftData/PersistentModel.swift:726: Fatal error: What kind of backing data is this? SwiftData._KKMDBackingData<SwiftDataMigration.ItemSchemaV1.ItemList>

My app relies on a complex migration that involves these optional 1 to n relationships. Theoretically I could not assign the relationships in the willMigrate block but afterwards I am not able to tell which list and items belonged together.

Steps to reproduce:

  1. Run project
  2. Change typealias CurrentSchema to ItemSchemaV2 instead of ItemSchemaV1.
  3. Run project again -> App crashes

My setup:

  • Xcode Version 16.2 (16C5032a)
  • MacOS Sequoia 15.4
  • iPhone 12 with 18.3.2 (22D82)

Am I doing something wrong or did I stumble upon a bug? I have a demo Xcode project ready but I could not upload it here so I put the code below.

Thanks for your help

typealias CurrentSchema = ItemSchemaV1

typealias ItemList = CurrentSchema.ItemList
typealias Item = CurrentSchema.Item

@main
struct SwiftDataMigrationApp: App {
    var sharedModelContainer: ModelContainer = {
        do {
            return try ModelContainer(for: ItemList.self, migrationPlan: MigrationPlan.self)
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

This is the migration plan

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [ItemSchemaV1.self, ItemSchemaV2.self]
    }
    
    static var stages: [MigrationStage] = [
        MigrationStage.custom(fromVersion: ItemSchemaV1.self, toVersion: ItemSchemaV2.self, willMigrate: { context in
            print("Started migration")
            let oldlistItems = try context.fetch(FetchDescriptor<ItemSchemaV1.ItemList>())
            for list in oldlistItems {
                let items = list.items.map { ItemSchemaV2.Item(timestamp: $0.timestamp)}
                let newList = ItemSchemaV2.ItemList(items: items, name: list.name, note: "This is a new property")
                context.insert(newList)
                context.delete(list)
            }
            try context.save() // Crash indicated here
            print("Finished willMigrate")
        }, didMigrate: { context in
            print("Did migrate successfully")
        })
    ]
}

The versioned schemas

enum ItemSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    final class Item {
        var timestamp: Date
        var list: ItemSchemaV1.ItemList?
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
    
    @Model
    final class ItemList {
        @Relationship(deleteRule: .cascade, inverse: \ItemSchemaV1.Item.list)
        var items: [Item]
        var name: String
        init(items: [Item], name: String) {
            self.items = items
            self.name = name
        }
    }
}

enum ItemSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Item.self]
    }

    @Model
    final class Item {
        var timestamp: Date
        var list: ItemSchemaV2.ItemList?
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
    
    @Model
    final class ItemList {
        @Relationship(deleteRule: .cascade, inverse: \ItemSchemaV2.Item.list)
        var items: [Item]
        var name: String
        var note: String
        init(items: [Item], name: String, note: String = "") {
            self.items = items
            self.name = name
            self.note = note
        }
    }
}

Last the ContentView:

struct ContentView: View {
    @Query private var itemLists: [ItemList]
    var body: some View {
        NavigationSplitView {
            List {
                ForEach(itemLists) { list in
                    NavigationLink {
                        List(list.items) { item in
                            Text(item.timestamp.formatted(date: .abbreviated, time: .complete))
                        }
                        .navigationTitle(list.name)
                    } label: {
                        Text(list.name)
                    }
                }
            }
            .navigationTitle("Crashing migration demo")
            .onAppear {
                if itemLists.isEmpty {
                    for index in 0..<10 {
                        let items = [Item(timestamp: Date.now)]
                        let listItem = ItemList(items: items, name: "List No. \(index)")
                        modelContext.insert(listItem)
                        
                    }
                    try! modelContext.save()
                }
            }
        } detail: {
            Text("Select an item")
        }
    }
}

Theoretically I could not assign the relationships in the willMigrate block

Yeah, the context passed to willMigrate is tied to the existing store, which doesn't have the knowledge of your new schema, and so trying to persisting an ItemSchemaV2 object, which I believe is a part of the new schema, will fail.

You can consider do the data transformation in didMigrate, where the context is tied to the migrated store, which allows you to access the new schema.

but afterwards I am not able to tell which list and items belonged together.

Would you mind to share why? When didMigrate is called, ItemSchemaV1.Item and ItemSchemaV1.ItemList have been migrated to ItemSchemaV2.Item and ItemSchemaV2.ItemList, and so you can still fetch the item list from ItemSchemaV2.ItemList, update ItemSchemaV2.ItemList.note, and save the change, can't you?

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

SwiftData migration crashes when working with relationships
 
 
Q