One thing leads to another
A tale as old as time
Since the introduction of iPhone SDK 3.0 developers have been dealing with data migrations on mobile devices. As mobile apps grow, their data requirements change alongside them and being on top of migrations is a black art that even the most current LLM has difficulty with.
The reliance on magic
When SwiftData was introduced in 2023, it brought with it a new approach to migrations being SchemaMigrationPlan that allows for programatically migrating between versions. Those who have dealt in the dark arts of CoreData migrations know that there exists blog posts and also official apple documentation on the matter. The difference being that with SwiftData, the approach is a lot more approachable.
Where as CoreData relied on mapping files to handle migrations, in SwiftData there is a definite gotcha there where the often used examples say to just put the @Model macro everywhere with abandon in order to get the magic. This works great if you know that you’ll only ever do lightweight migrations, but if you need to change anything more substantially, this approach relies to much on magic.
Adoption of VersionedSchema is a way to avoid but if you’re app is already in the wild then you need to take a few extra steps to bring everything into line with versioned schemas.
Defining the schemas
The first step is to define all your existing SwiftData models in a VersionedSchema. This can look like the following. The important part to remember is that this first version should match your existing models exactly.
public struct Version1: VersionedSchema {
public static let models: [any PersistentModel.Type] = [
Version1.One.self,
Version1.Two.self,
Version1.Three.self,
]
public static let versionIdentifier = Schema.Version(1, 0, 0)
public init() {
}
}
By making use of the Version1 struct you can namespace your types. A public typealias will then allow for continuing to use the existing types without code change. This is also a good chance to define CurrentVersion as a type so that when you move between versions, the references in your code don’t require change.
public typealias CurrentVersion = Version1
public typealias One = CurrentVersion.One
public typealias Two = CurrentVersion.Two
public typealias Three = CurrentVersion.Three
When creating your model container, you can then make use of the new VersionedSchema definition.
let schema = Schema(versionedSchema: CurrentVersion.self)
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.your.app.data")
)
let container = try ModelContainer(
for: schema,
configurations: modelConfiguration
)
Not so fast
If you are going from a non versioned schema to a versioned schema, you will unfortunately hit an error when creating the model container "Cannot use staged migration with an unknown model version.". This error is unfortunately not well documented, but there is a common work around you can use. The trick is to explicitly load the Version1 schema first and then continue with any other migrations.
do
{
return try liveModelContainer()
} catch SwiftDataError.loadIssueModelContainer {
do {
let legacySchema = Schema(versionedSchema: Version1.self)
let legacyModelConfiguration = ModelConfiguration(
schema: legacySchema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none
)
let legacyContainer = try ModelContainer(
for: legacySchema,
configurations: [legacyModelConfiguration]
)
try legacyContainer.mainContext.save()
return try liveModelContainer()
} catch {
// Log and handle any other errors here
}
}
At this point, you should be able to successfully load your data as a versioned schema without any issues.
And on and on and on and on
As apps grow in size, you will want to define a SchemaMigrationPlan to allow you to constantly grow your apps data requirements.
As with anything there are a few caveats when using a schema migration plan. The majority of things will hopefully be covered by a .lightweight MigrationStage.
When you need to start modifying your data or changing it between app versions, this can get tricky and you will need to make use of a .custom migration stage.
public class AppMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
Version1.self,
Version2.self,
]
}
public static var stages: [MigrationStage] {
[
migrateV1toV2,
]
}
static let migrateV1toV2: MigrationStage = .custom(
fromVersion: Version1.self,
toVersion: Version2.self
) { context in
// Your Version1 models are available here
} didMigrate: { context in
// Your Version2 models are available here
}
}
The important thing to remember is that you can’t access anything from your old schema version inside of the didMigrate closure as the schema has already changed at that point. So if you are wanting to move data from var foo to a combination of var baz and var blorp then you will need to store this data as part of the willMigrate closure so that it is retained. You can then set it on the corresponding versioned model in didMigrate and save the changes.
There’s a lot of hand waving here, but that’s intentional. The specific requirements of migrations aren’t what’s important. The important thing is to get your app into a versioned schema and then start writing the migrations.