SwiftData custom migration crash

Starting point

I have an app that is in production that has a single entity called CDShift. This is the class:

@Model
final class CDShift {
    var identifier: UUID = UUID()
    var date: Date = Date()
    ...
}

This is how this model is written in the current version.

Where I need to go

Now, I'm updating the app and I have to do some modifications, that are:

  • add a new entity, called DayPlan
  • add the relationship between DayPlan and CDShift

What I did is this:

enum SchemaV1: VersionedSchema {

    static var versionIdentifier = Schema.Version(1, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [CDShift.self]
    }

    @Model
    final class CDShift {
        var identifier: UUID = UUID()
        var date: Date = Date()
    }
}

To encapsulate the current CDShift in a version 1 of the schema. Then I created the version 2:

enum SchemaV2: VersionedSchema {

    static var versionIdentifier = Schema.Version(2, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [CDShift.self, DayPlan.self]
    }

    @Model
    final class DayPlan {
        var identifier: UUID = UUID()
        var date: Date = Date()
        @Relationship(inverse: \CDShift.dayPlan) var shifts: [CDShift]? = []
    }

    @Model
    final class CDShift {
        var identifier: UUID = UUID()
        var date: Date = Date()
        var dayPlan: DayPlan? = nil
    }
}

The migration plan

Finally, I created the migration plan:

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }
    
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self) { context in
            // willMigrate, only access to old models
        } didMigrate: { context in
            // didMigrate, only access to new models
            
            let shifts = try context.fetch(FetchDescriptor<SchemaV2.CDShift>())
            
            for shift in shifts {
                let dayPlan = DayPlan(date: shift.date)
                dayPlan.shifts?.append(shift)
                context.insert(dayPlan)
            }
        }


    static var stages: [MigrationStage] {
        print("MigrationPlan | stages called")
        return [migrateV1toV2]
    }
}

The ModelContainer

Last, but not least, how the model container is created in the App:

struct MyApp: App {

    private let container: ModelContainer

    init() {
        container = ModelContainer.appContainer
    }

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

This is the extension of ModelContainer:

extension ModelContainer {
    
    static var appContainer: ModelContainer {
        let schema = Schema([
            CDShift.self,
            DayPlan.self
        ])
        let modelConfiguration = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: Ecosystem.current.isPreview,
            groupContainer: .identifier(Ecosystem.current.appGroupIdentifier)
        )
        
        do {
//            let container = try ModelContainer(for: schema, configurations: modelConfiguration)
            let container = try ModelContainer(for: schema, migrationPlan: MigrationPlan.self, configurations: modelConfiguration)
            AMLogger.verbose("SwiftData path: \(modelConfiguration.url.path)")
            return container
        } catch (let error) {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }
}

The error

This has always worked perfectly until the migration. It crashes on the fatalError line, this is the error:

Unable to find a configuration named 'default' in the specified managed object model.

Notes

  • It seems that the version of the store is never updated to 2, but it keeps staying on 1. I tried also using the lightweight migration, no crash, it seems it recognizes the new entity, but the store version is always 1.
  • iCloud is enabled
  • I thought that the context used in the custom migration blocks is not the "right" one that I use when I create my container
  • If I use the lightweight migration, everything seems to work fine, but I have to manually do the association between the DayPlan and the CDShift objects

Do you have an idea on how to help in this case?

I want to add an important detail, this happens only when CloudKit is enabled.

Here's a test project that prove the issue: https://www.dropbox.com/scl/fi/nzxy7efptdvw0y653zn4j/TestSwiftDataMigration.zip?rlkey=aq3acfbgivc7zhopevuapg8hq&dl=0.

This is a known issue that, most likely, was fixed in the latest beta (3) of iOS 18 + Xcode 16. Would you mind to give it a try? If the issue is still there, I’d suggest that you file a feedback report and post the report ID here.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

@DTS Engineer I will give it a try with the latest beta of iOS 18 and Xcode 16. In the meantime, what is the suggested workaround? Because what I'm doing is this:

extension ModelContainer {
    
    static var myContainer: ModelContainer {
        
        print("ModelContainer | myContainer called")
        
        let schema = Schema([
            CDShift.self
        ])
        
        let cloudConfiguration = ModelConfiguration(
            isStoredInMemoryOnly: Ecosystem.current.isPreview,
            groupContainer: .identifier(Ecosystem.current.appGroupIdentifier),
            cloudKitDatabase: .automatic
        )
        
        let localConfiguration = ModelConfiguration(
            nil,
            schema: schema,
            isStoredInMemoryOnly: Ecosystem.current.isPreview,
            groupContainer: .identifier(Ecosystem.current.appGroupIdentifier),
            cloudKitDatabase: .none
        )
        
        do {
            
            let container: ModelContainer
            if let cloudContainer = try? ModelContainer(
                for: schema,
                migrationPlan: MigrationPlan.self,
                configurations: cloudConfiguration) {
                
                AMLogger.verbose("Cloud container created, CloudKit is enabled!")
                
                container = cloudContainer
            } else {
                
                AMLogger.verbose("Cloud container failed to create, we will now create a local container and then again a cloud container.")
                
                _ = try ModelContainer(
                    for: schema,
                    migrationPlan: MigrationPlan.self,
                    configurations: localConfiguration
                )
                
                container = try ModelContainer(
                    for: schema,
                    migrationPlan: MigrationPlan.self,
                    configurations: cloudConfiguration
                )
                
                AMLogger.verbose("Cloud container created after local one.")
            }
            
            AMLogger.verbose("SwiftData path: \(container.configurations.first!.url.path)")
            
            return container
        } catch (let error) {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }
}

I try to create a cloud container and, if it fails, I create a local one to perform the migration, then try again to create the cloud one. The issue is that it seems that the version of the schema keeps staying on "1.0.0".

The issue is that it seems that the version of the schema keeps staying on "1.0.0".

It may be just that the migration process doesn't update the store metadata, which can be ignored if the data does be migrated.

Meanwhile, you might check if your workaround generates duplicate data – If it doesn't, you should be fine.

I don't have other workaround to suggest.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

If you enable only your local configuration in your model container does both your willMigrate and didMigrate logic in your custom migration stage execute and does your schema version update?

If instead you enable only your cloud configuration, what happens? If this route hits your willMigrate but crashes before it hits didMigrate then the migration is not happening.

Setting up both configurations in series and discarding the local configuration might be masking this issue by preventing a crash while at the same time failing to migrate. Could this be why your schema version is not changing?

The scenario I'm using to answer your questions is starting from the App Store version of my app and launching the project from Xcode, containing the new schema version and the migration code.

If you enable only your local configuration in your model container does both your willMigrate and didMigrate logic in your custom migration stage execute and does your schema version update?

No, I tried launching with only local container enabled and the version is still 1.0.

If instead you enable only your cloud configuration, what happens? If this route hits your willMigrate but crashes before it hits didMigrate then the migration is not happening.

In this case, the migration is not even reached because the fails happens during the creation of the ModelContainer. The error is this:

UserInfo={NSLocalizedFailureReason=Unable to find a configuration named 'default' in the specified managed object model.} with userInfo {
    NSLocalizedFailureReason = "Unable to find a configuration named 'default' in the specified managed object model.";
}

Setting up both configurations in series and discarding the local configuration might be masking this issue by preventing a crash while at the same time failing to migrate. Could this be why your schema version is not changing?

This could be the case, I have to investigate further.

The big issue here is that migration is a very important part of database management and the fact that is crashing while everything seems to be performed in the right way is a major problem.

I agree with your point about it being a big issue.

I face the same problem and have come to the conclusion that custom migration is not supported under SwiftData with iCloud enabled. As SwiftData is currently layered on top of Core Data it must share the same limitations. With Core Data, if you are using iCloud, you must use lightweight migration.

I can't find anything in Apple's documentation that specifically says you can or cannot do a custom migration with SwiftData and iCloud. I sincerely hope someone can prove me wrong and say this is possible.

@ddijitall As I said above I also tried to create a separate test project where the same issue can be replicated, so it is definitely a SwiftData/Core Data with iCloud issue. In my case, I'm trying to make things as easy as possible, to be able to perform a lightweight migration. If I'm not wrong, adding new properties or removing some can be considered lightweight, right?

Looking at your code above, you are trying to migrate from SchemaV1 to SchemaV2 using a custom migration stage. If you think your changes can be done using a lightweight migration, use a lightweight migration stage instead and see what happens.

However, you are trying to establish relationships after migration in your didMigrate logic, which you can't do with a lightweight migration stage, not as part of the migration anyway.

Yes, exactly. I think there's no way to perform code after the lightweight migration, right? Or maybe I should check the schema version before and after (should be 1 before and 2 after) and, in case they differs, perform some custom code to establish the relationship and do what I need to do.

I think that's all you can do for now as lightweight migration gives you no opportunity to intervene and, as far as I know, custom migration fails with iCloud enabled.

This issue should be resolved on the latest betas. Please file a feedback report if it is not.

On the iOS 18 RC this is still an issue to me. The only "change" is that the error is now "Cannot use staged migration with an unknown model version.". There's no way to make it work and this is a huge issue. I'm using a custom migration.

SwiftData custom migration crash
 
 
Q