SwiftData Migrations: Lightweight vs Custom, And When You Don't Need a V2
SwiftData’s schema-migration story is a structural improvement over Core Data’s, with one trap teams keep falling into: declaring a new VersionedSchema for changes that SwiftData would handle automatically through inline defaults. The result is a “Duplicate version checksums across stages detected” crash on device, even though the code looked right and built clean. The framework’s actual migration model uses three pieces (VersionedSchema, MigrationStage, SchemaMigrationPlan) and three migration types (automatic lightweight, declared lightweight, custom)1. Most schema changes are automatic. Some need a declared lightweight stage. A small minority need a custom stage with willMigrate and didMigrate closures.
The post walks the migration model against Apple’s documentation, names the cases each migration type handles, and covers iOS 26’s new class inheritance support. The frame is “what do I declare versus what does SwiftData handle for me,” because that decision determines whether the migration ships cleanly or crashes on first launch.
TL;DR
- SwiftData migrations compose three protocols:
VersionedSchema(a snapshot of model types at a version),MigrationStage(a single fromVersion-to-toVersion transition with.lightweightor.customcases), andSchemaMigrationPlan(ordered list of stages)1. - Adding a new
@Modelproperty with an inline default (var foo: Bool = false) does not require a newVersionedSchema. SwiftData handles the addition automatically as a lightweight migration. Declaring a V2 for it produces “Duplicate version checksums across stages detected” crashes. - Lightweight migrations handle: adding/renaming/deleting entities, attributes, relationships; changing relationship types; declaring
@Attribute(originalName:)to track renames; specifying delete rules. Most schema changes fit here. - Custom migrations (
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) handle data transformations: splitting one column into two, computing derived fields, moving data between models.willMigratehas the old context;didMigratehas the new context. - iOS 26 adds class inheritance for
@Modeltypes2. Schemas adopting inheritance bump to a new version with a lightweight stage from the previous flat-model version.
The Three-Piece Model
A SwiftData migration is composed from three pieces.
VersionedSchema
A snapshot of model types at a specific schema version1. The protocol requires:
static var versionIdentifier: Schema.Version. A semantic version triple (Schema.Version(1, 0, 0)).static var models: [any PersistentModel.Type]. The array of@Modeltypes in this version.
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
The enum-with-nested-types pattern is the convention. Each VersionedSchema namespaces its model classes so multiple schemas with the same model name can coexist in the codebase during a migration.
MigrationStage
A single transition between two VersionedSchema types3. Two cases:
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Declare a transition that SwiftData handles without app code. The parameters are theVersionedSchematypes themselves (e.g.SchemaV1.self), not rawSchema.Versionvalues..custom(fromVersion:toVersion:willMigrate:didMigrate:). Declare a transition with code that runs before and/or after the data migration. Same parameter types as.lightweightfor the version arguments.
SchemaMigrationPlan
The ordered list of stages that takes the schema from any prior version to the current one1.
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
The ModelContainer is set up with both the current schema and the migration plan:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData reads the persistent store’s current schema version on container creation, walks the plan’s stages from that version forward to the current one, and applies each stage in order.
What Lightweight Migrations Handle Automatically
Most schema changes do not require a custom stage1:
- Adding an attribute with a default value.
var foo: Bool = falseon an existing@Modelis automatic. - Adding a new entity (model class). New types appear when their
VersionedSchemais the current one; existing data is preserved. - Removing an attribute or entity. SwiftData drops the column or table.
- Renaming an attribute or entity. Add
@Attribute(originalName: "oldName")to the property to preserve data; SwiftData maps old to new. - Changing a relationship type. Single-to-many, many-to-many, etc.
- Specifying delete rules.
@Relationship(deleteRule: .cascade)and similar additions are lightweight.
For changes in this list, the right pattern is to not declare a new VersionedSchema at all if the model types are otherwise unchanged. SwiftData performs the lightweight migration automatically against the existing schema.
The Trap: Adding A Field Does Not Require V2
The most common SwiftData migration mistake: a developer adds a new property with an inline default (var foo: Bool = false), then declares a SchemaV2 referencing the same model types as SchemaV1. The build is clean. The first launch on a device with existing V1 data crashes with Duplicate version checksums across stages detected because both SchemaV1 and SchemaV2 resolve to the same checksum (the model types didn’t change in a way SwiftData notices as different).
The correct pattern: leave the existing VersionedSchema alone, add the new property to the model with an inline default, and let SwiftData’s automatic lightweight migration handle it. No MigrationPlan, no MigrationStage, no V2 needed.
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
The var isFavorite: Bool = false change ships without any MigrationStage declaration. The ModelContainer initializer that doesn’t pass migrationPlan: works:
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
The V2 schema is only required when a change cannot be lightweight (a data transformation, a model split, an inheritance restructure that requires custom logic). In those cases, V2 is real and a SchemaMigrationPlan orchestrates the transition.
When Custom Migrations Are Required
Custom migrations earn their complexity in three cases:
1. Splitting one field into multiple. A String field that holds "Last, First" becomes two fields, firstName and lastName. The migration needs to read the old value, parse it, and write the new fields.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
The didMigrate closure runs against the new schema’s context, so the new fields are accessible. The old fullName may need to be deferred for removal until after the new fields are populated; the cleanup is a follow-up V2-to-V3 stage.
2. Computing derived fields. A new @Attribute that depends on existing data needs to be backfilled at migration time.
3. Moving data between models. A reorganization where data from Item is split between Item and a new Tag model requires custom logic to assign tags from the old data.
The general pattern: lightweight when the schema shape changes; custom when the data shape changes.
willMigrate vs didMigrate
Custom stages have two closures, called at different points4:
willMigrate runs before SwiftData applies the schema migration. The model context the closure receives is the old schema’s context. Use this to capture data, denormalize it, or prepare auxiliary state before the schema changes underneath.
didMigrate runs after the schema migration. The model context is the new schema’s. Use this for backfilling new fields, computing derived data, or finalizing the migration.
Either closure can be nil if not needed. Most custom migrations use didMigrate only; willMigrate is useful when the migration needs to read old data that won’t be accessible after the schema changes.
The closure receives a ModelContext and can fetch, modify, and save. The closure is throwing; errors propagate out of the migration and abort it.
iOS 26: Class Inheritance for @Model
iOS 26 introduces class inheritance for SwiftData models2. Models can now have parent-child relationships:
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
Schemas adopting inheritance bump to a new version with a lightweight migration stage from the previous flat-model version. The transition is automatic if the inheritance preserves the existing properties; new fields on the subclass follow the standard inline-default pattern.
The pattern fits cases where multiple @Model types share characteristics: a Vehicle parent with Car, Truck, Motorcycle children; an Account parent with CheckingAccount, SavingsAccount children. The shared properties live on the parent; the specifics live on the children.
Testing Migrations
A migration that compiles is not a migration that ships. Three testing patterns worth running before release:
1. Round-trip test on a copy of the production database. Pull a recent production-shape database (or generate synthetic V1 data through tests), open it with the V2-aware container, and verify the data migrates correctly. The test catches custom-migration bugs that the type-checker can’t.
2. Old-version still launches. Build the previous app version, run it once to produce V1 data, then build the new app version and verify it launches without crashing. The test catches the “Duplicate version checksums” trap and similar declaration mistakes.
3. Failed-migration recovery. What happens if the migration throws? SwiftData’s behavior depends on the container’s configuration; for production apps, an unhandled migration error should not silently delete user data. Test the failure path explicitly and decide what the app does (rollback, prompt, recover from backup).
The cluster’s Single Source of Truth post covers the related question of what happens when a SwiftData store is replaced through cross-process sync. Migrations are the local-evolution analog of that pattern.
Common Failure Modes
Three patterns from the SwiftData failure logs:
Declaring V2 for a change SwiftData would handle automatically. The “Duplicate version checksums” crash. Fix: don’t declare a new schema for inline-default property additions; let SwiftData handle them automatically.
Custom migration code that doesn’t save. A didMigrate closure that modifies entities but doesn’t call context.save() produces a migration that runs once, drops its work, and re-runs every launch (because the migration appears unfinished). Fix: every closure that modifies data must try context.save() before returning.
Renaming a property without @Attribute(originalName:). SwiftData treats the new property as new and the old as deleted; existing data on the old property is dropped. Fix: declare @Attribute(originalName: "oldName") var newName: ... so SwiftData maps the data through the rename.
What This Pattern Means For iOS 26+ Apps
Three takeaways.
-
Default to no
VersionedSchemaladder. Adding properties with inline defaults, deleting unused fields, renaming with@Attribute(originalName:). All lightweight and automatic. TheVersionedSchemaladder is for changes that SwiftData genuinely cannot handle automatically (data transformations, custom logic, inheritance restructures). -
Use
MigrationStage.customfor data transformations, not for schema-shape changes. ThewillMigrateanddidMigrateclosures are for code that operates on data, not for declaring that the schema has changed. Schema-shape changes flow through lightweight stages. -
Test migrations with real V1 data, not just synthetic test data. Migrations that pass on synthetic round-trips can still fail on production-shape data with edge cases (nullable fields the schema didn’t cover, large datasets that hit timeout, etc.). The cost of testing is small; the cost of a migration crash on first launch is real.
The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; SF Pro typography; visionOS spatial patterns; Speech framework; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
Do I always need a SchemaMigrationPlan?
No. Apps with a single schema version (the initial release, or apps that have only ever made lightweight changes) don’t need a SchemaMigrationPlan. The ModelContainer initializer accepts the schema’s models directly. The migrationPlan: parameter becomes necessary the first time a custom migration stage is declared (or the first time the developer wants to declare an explicit version ladder).
How do I know if my change is lightweight?
Apple’s lightweight-eligible list1: adding entities/attributes/relationships, removing them, renaming with @Attribute(originalName:), changing relationship cardinality, specifying delete rules. If the change fits one of these and the model class structure is otherwise unchanged, the migration is automatic and no VersionedSchema ladder is required. If the change requires data transformation (compute, split, move data), it is custom.
Can willMigrate and didMigrate both be set?
Yes. Both closures are optional individually but can both be provided. willMigrate runs against the old schema’s context before SwiftData migrates; didMigrate runs against the new schema’s context after. The two cover preparation and finalization respectively.
What happens if a migration throws an error?
The error propagates out of the ModelContainer initialization. The container fails to open. The app’s behavior depends on how the developer handles the error: some apps display a recovery UI, some attempt to restore from a backup, some delete the corrupt store and start fresh. SwiftData does not silently delete user data on migration failure; the failure is the app’s to handle.
How do I test a migration without affecting production data?
Build a test target that creates a ModelContainer pointed at a temporary file URL, populates it with V1 data, then opens it with the new container that includes the migration plan. Verify the migrated data matches expectations. The pattern works in both unit and integration tests; for the most realistic results, use a copy of an actual production-shape database.
Does iOS 26’s class inheritance work with existing schemas?
Yes, with a lightweight migration. Apps that adopt inheritance bump to a new schema version (e.g., V4) and declare a MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). The flat parent-class properties remain, and the subclass-specific properties are added with inline defaults. SwiftData’s lightweight migration handles the structural change.
References
-
Apple Developer Documentation:
VersionedSchemaandSchemaMigrationPlanprotocol references. The migration model. See also the related guide Adopting SwiftData for a Core Data app for the full schema-evolution narrative. ↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 session 291). The introduction of SwiftData class inheritance in iOS 26. ↩↩
-
Apple Developer Documentation:
MigrationStagewith the.lightweight(fromVersion:toVersion:)and.custom(fromVersion:toVersion:willMigrate:didMigrate:)cases. ↩ -
Apple Developer Documentation:
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)for the case signature. The willMigrate-runs-against-old-context and didMigrate-runs-against-new-context semantics are documented in WWDC 2025 session 291 SwiftData: Dive into inheritance and schema migration, the same session referenced for the iOS 26 inheritance addition. ↩