How does SwiftData schema migration work and what are the possible schema changing operations

I am trying out SwiftData schema migration to see what is possible. I understand from the WWDC23 sessions that I can

  • rename properties (as an example of a lightweight migration) and
  • make properties unique (as an example of a custom migration)

This has worked well and promising.

But I have tried unsuccessfully to add a new property to an existing model, resulting in fatal errors, like

  • Fatal error: Expected only Arrays for Relationships

Also, I failed adding a new Model to an existing schema with different fatal errors depending on my try-and-error coding.

Questions

In a migration stage, I have access to a model context. How does this reflect the old and new schema? For example if I rename a model, will I have access to both the old and new model?

Are there any examples of doing complex migrations yet?

What are the currently supported migration operations besides making an attribute unique and renaming an attribute?

Any pointer to a more in-depth documentation will be appreciated.

This information would be helpful to me as well. I've noticed that I can add e.g. a string property with a default value to a SwiftData Model and successfully migrate if I'm not providing an explicit SchemaMigrationPlan. But when providing a SchemaMigrationPlan, the migration fails, whether using a .lightweight or .custom migration.

I just ran into a similar problem in my app. After hours of looking around, I couldn't find what I was looking for, but I found a solution that works... Hopefully there is a better solution though.

Problem

Schema v1

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

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

    @Model
    public final class Reminder: Hashable, Identifiable {
        public var id: UUID
        public var title: String
        public var icon: ReminderIconStyle
        // This is an enum, that I want to convert to a struct, so I need to migrate to the new model.
        public var style: ReminderTimeStyle
        ...
    }
}

Schema v2

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

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

    @Model
    public final class Reminder: Hashable, Identifiable {
        public var id: UUID
        public var title: String
        public var icon: ReminderIconStyle

        // New to V2
        public var recurrenceRule: RecurrenceRule?

        // New to V2
        @Attribute(.transformable(by: DateComponentsTransformer.self))
        public var startDate: DateComponents

        // New to V2
        @Attribute(.transformable(by: DateComponentsTransformer.self))
        public var dueDate: DateComponents
        ...
    }
}

I haven't had to do any migrations in the past, since I have only added additional properties. Now I need to remove a property, and convert it to 3 new properties.

Remove

var style: ReminderTimeStyle

Add

var recurrenceRule: RecurrenceRule?
var startDate: DateComponents
var dueDate: DateComponents

In order for me to add the new properties, I need access to style: ReminderTimeStyle so that I can get the relevant values to construct the new properties. The lightweight migration obviously failed, since there are major changes. So I started adding the logic for the custom migration.

First custom migration attempt

enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [.migrateV1toV2]
    }
}

extension MigrationStage {
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            let calendar = Calendar.current

            let oldReminders = try? context.fetch(FetchDescriptor<SchemaV1.Reminder>())

            for old in oldReminders ?? [] {
                var startDate: DateComponents!
                var dueDate: DateComponents!
                var recurrenceRule: RecurrenceRule?

                switch old.style {
                case let .manual(start, duration):
                    startDate = calendar.dateComponents(in: .current, from: start)
                    dueDate = calendar.dateComponents(in: .current, from: start.advanced(by: duration))
                
                case let .repeat(r):
                    startDate = calendar.dateComponents(in: .current, from: r.startDate)
                    dueDate = calendar.dateComponents(in: .current, from: r.endDate)
                    recurrenceRule = .init(frequency: r.frequency, interval: Int(r.every))
                }

                let new = SchemaV2.Reminder(
                    id: old.id,
                    title: old.title,
                    icon: old.icon,
                    startDate: startDate,
                    dueDate: dueDate,
                    recurrenceRule: recurrenceRule,
                    ...
                )

                context.insert(new)
                context.delete(old)
            }

            try? context.save()

        }, didMigrate: { context in
            print("Migrated!", context.container)
        }
    )
}

Every time I would run the migration using that code, it would always fail when creating SchemaV2.Reminder with some weird error messages like:

Fatal error: Expected only Arrays for Relationships - RecurrenceRule

Fatal error: Unexpected type for CompositeAttribute: Optional<RecurrenceRule>

So I thought the problem was with the new property. It turns out that it was failing on all of the new properties, and if I changed the order it would crash on whichever one was first.

Solution

I found that you only have access to the "old" models in willMigrate, and you only have access to the "new" models in didMigrate. And you can't just move the migration code into the didMigrate block, because the breaking schema changes have to be fixed before didMigrate will run. So there is no way to do the type of migration that I was attempting. That means I have to leave the old database table around for this migration, so I have access to both models. I assume I can remove the table in the next schema version.

  1. Move the willMigrate code into the didMigrate block so that we have access to the new Reminder model.
  2. Rename the SchemaV2.Reminder model to something else i.e. SchemaV2.Reminder2
  3. Make sure both SchemaV1.Reminder and SchemaV2.Reminder2 are included in the SchemaV2.models array.
public typealias CurrentSchema = SchemaV2
public typealias Reminder = CurrentSchema.Reminder2
public enum SchemaV2: VersionedSchema {
    public static var versionIdentifier: Schema.Version = .init(2, 0, 0)

    public static var models: [any PersistentModel.Type] {
        [SchemaV1.Reminder.self, Reminder2.self]
    }

    @Model
    public final class Reminder2: Hashable, Identifiable {
        ...
    }
}
extension MigrationStage {
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: nil,
        didMigrate: { context in
            ...
        }
    )
}

Drawbacks

  1. Reminder can't be used as a model name, since that refers to the original table in the database. The typealias helps make that not as bad.
  2. The original Reminder table still exists in the database, it just won't be used. You can remove it, but it will take an additional migration stage.
  3. The migration code is not as straightforward as it should be.

Highlights

  • willMigrate is where logic goes if you can migrate your data without creating new models and the migration is pretty simple. Breaking model changes have to be fixed here.
  • didMigrate is where logic goes if you need access to the new model, but you can't access old models from here.
  • Since I needed access to the old model and the new one in order to do the migration I had to keep the old model around.

I hope this helps someone!

How does SwiftData schema migration work and what are the possible schema changing operations
 
 
Q