I’ve developed an app some time ago using SwiftData as a backing storage. Recently I needed to update the model, but not merely add or rename a property – these would require a lightweight migration and are done automatically. Boring. No, I needed to take one property and turn it into another one. In my case it was a conversion from date: Date? to dateInt: Int?. Don’t ask me why, it’s a long story but the problem here is that SwiftData wouldn’t know how to automatically convert one to another so I have to show it and do the real complex schema migration. Or do I?

The posts you can find on the internet will show you some very simple use cases, like making a property unique. This still requires a complex (read “manual”) migration where you load all the models and then just filter them. But that ain’t solving my problem — I need to create a whole new model. Documentation for this particular part of SwiftData is quite scarce and no AI agent could help me either so I had to roll up my sleeves and understand what’s going on there, and how these migrations are meant to be done. And now I’m gladly sharing my findings – how should you do the complex migrations and why you might not need one.

First let’s start with what I had originally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// That's my model
@Model
class Foo {
  var date: Date?
}

// That's how I initialized the schema and container
let schema = Schema([Foo.self])
let modelConfiguration = ModelConfiguration(
    schema: schema,
    cloudKitDatabase: .automatic
)

return try ModelContainer(
    for: schema,
    configurations:[modelConfiguration]
)

Fairly simple, not much can be simplified. Now, apparently, a lot can be made more complicated. First, we need to create a migration plan for SwiftData to know how to migrate from the previous model to the new one. But before we do that we should define both models (or rather the new one, we already have the old one). The clean approach to do this is to create something called VersionedSchema. We will use both (v1 and v2) schemas inside the. migration plan but they also serve well as namespaces for the models. It would look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

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

    @Model
    class Foo {
        var date: Date?
    }
}

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

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

    @Model
    class Foo {
        var dateInt: Int?
    }
}

Notice how I moved the existing model to SchemaV1, now it will live there. Now because you have your Foo model namespaced the whole app will complain about not finding it anymore. You could replace all Foos with SchemaV2.Foos and get away with it (note, not V1 cause your app now would want to work with the newer model, right?) but the more elegant approach would be to declare a typealias:

1
typealias Foo = SchemaV2.Foo

… and update it to SchemaV3, …V4 etc when the time comes

Ok, now off to migration plan. The point is to show SwiftData how to, well, migrate from one model to another. This is done using a MigrationStage. You declare a migration stage and it will require you to provide the versions – schemas – and 2 optional closures: willMigrate and didMigrate. Sounds simple, we have access to 2 events, one fires before and one fires after the migration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
enum FooMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static let migrationV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: nil,
        didMigrate: nil
    )

    static var stages: [MigrationStage] {
        [migrationV1toV2]
    }
}

Now, as I mentioned, SwiftData wouldn’t know how to migrate one to another so we’re here to help it. In some tutorials you’ll find that you can migrate everything in willMigrate so let’s do it now:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
...
willMigrate: { context in
    let allFoos = try context.fetch(FetchDescriptor<SchemaV1.Foo>())
	
    for foo in allFoos {
        let newFoo = SchemaV2.Foo(dateInt: foo.date?.toInt())

        context.insert(newFoo)
        context.delete(foo)
    }

    try context.save()
}
...

Seems very easy, innit? Well, sorry to break it to you, this will crash. The reason? Cannot find SchemaV2.Foo model.

WHAT?

Ok, I’m done with irony, here’s the undocumented thing (if there is any documentation, please point me to it): willMigrate’s context is only able to fetch models from the fromVersion and the didMigrate is the opposite – it only has access to toVersion. In our case it’s SchemaV1 and SchemaV2respectively. So I need to fetch the old models in willMigrate and create new models and save them in didMigrate. Let’s do this! Wait! How the heck are we gonna transfer the models between the closures? Oh boy, we have to store the old models somewhere and then retrieve it in didMigrate. And this storage, of course, shouldn’t be SwiftData… Because why would it, right? I promised to stop with irony, I’m sorry.

Here’s what I did:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
enum FooMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    struct FooV1 {
        let date: Date?
    }

    nonisolated(unsafe) private static var foosV1 = [FooV1]()

    static let migrationV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            let allFoos = try context.fetch(FetchDescriptor<SchemaV1.Foo>())
	
            for foo in allFoos {
                let newFoo = FooV1(date: foo.date)

                foosV1.append(newFoo)
                context.delete(foo)
            }

            try context.save()
        },
        didMigrate: { context in	
            for foo in foosV1 {
                let newFoo = SchemaV2.Foo(dateInt: foo.date?.toInt())

                context.insert(newFoo)
            }

            try context.save()
        }
    )

    static var stages: [MigrationStage] {
        [migrationV1toV2]
    }
}

Ok, so now we’re talking, this will work like a charm. Let me know if I did something stupid with all this Modern Swift Concurrency but it seems like the closures are running synchronous so we should be safe with nonisolated(unsafe) here. But basically this is it, this is how you migrate the model with very different sets of parameters.

I could stop here if I wouldn’t opt in for iCloud sync though. The thing is, iCloud sync seems to only support lightweight migration and the one we do is not too light for it. You cannot do these types of migration cause imagine there are 2 clients that are in sync and then 1 is updated and migrated. How should the data be handled on the other device and in the cloud? Tough question. So yeah, I had to delete all the migration code I wrote and do something dirty but very simple and straightforward. I just left the date: Date? in place and added a new dateInt: Int? property to the original model. Then, upon opening the app I check if this “migration” has been already done and if not, then I just loop over all the foos and populate dateInt, setting date to nil. Now I have to make peace with the fact that I’m stuck with a property I’ll not use anymore but well, next time I might opt in for iCloud sync as late as possible.

Thank you very much for reading! As always, please contact me if you have any questions or suggestions: Contact