Introduction
After shipping an app with SwiftData, there comes a time when you need to add fields to your models. Migration in the Core Data era was quite complex, but SwiftData's VersionedSchema and SchemaMigrationPlan make it much cleaner.
This post covers the practical process of SwiftData migration, based on my experience extending the schema to support debit cards in a personal finance app.
The Starting Point: SchemaV1
The initial schema was defined using VersionedSchema.
struct iobookSchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Asset.self, AssetGroup.self, Transaction.self, ...]
}
static var versionIdentifier: Schema.Version = .init(1, 0, 0)
}
extension iobookSchemaV1 {
@Model
class Asset {
var isCreditCard: Bool?
var paymentAsset: Asset?
// ... other fields omitted
}
@Model
class Transaction {
var asset: Asset?
// ... other fields omitted
}
}
Instead of referencing models directly throughout the app, I use typealias. This becomes crucial during migration.
typealias Asset = iobookSchemaV1.Asset
typealias Transaction = iobookSchemaV1.Transaction
When Change Is Needed
To support debit cards, two new fields were required:
isDebitCard: Bool?onAsset— to identify debit card assetsdebitCard: Asset?onTransaction— to track which debit card was used for a transaction
Both are optional field additions, so lightweight migration is sufficient.
Step 1: Create SchemaV2
Copy the V1 file to create V2. Never modify V1. SwiftData compares V1 and V2 to determine the differences during migration.
struct iobookSchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Asset.self, AssetGroup.self, Transaction.self, ...]
}
static var versionIdentifier: Schema.Version = .init(2, 0, 0)
}
extension iobookSchemaV2 {
@Model
class Asset {
// ... all existing V1 fields retained ...
// Newly added
var isDebitCard: Bool?
@Relationship(inverse: \Transaction.debitCard)
var debitCardTransactions: [Transaction]?
}
@Model
class Transaction {
// ... all existing V1 fields retained ...
// Newly added
var debitCard: Asset?
}
}
Key points:
- All model classes from V1 must be redefined in V2 (even unchanged ones)
- New fields must have default values (lightweight migration requirement)
@Relationshipinverse must match exactly
Step 2: Write the Migration Plan
enum iobookMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[iobookSchemaV1.self, iobookSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: iobookSchemaV1.self,
toVersion: iobookSchemaV2.self
)
}
Lightweight migration supports only these changes:
- Adding optional fields (with defaults)
- Removing fields
- Adding optional relationships
- Adding/removing indexes
For field renames, type changes, or adding non-optional fields, you'll need MigrationStage.custom.
Step 3: Update typealias and ModelContainer
// Change V1 → V2
typealias Asset = iobookSchemaV2.Asset
typealias Transaction = iobookSchemaV2.Transaction
// Add migrationPlan to ModelContainer
var modelContainer: ModelContainer = {
let schema = Schema([Asset.self, ...])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(
for: schema,
migrationPlan: iobookMigrationPlan.self, // Add this line
configurations: [config]
)
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
Thanks to typealias, none of the app code that references Asset or Transaction needs to change. Just point the typealias to the V2 classes.
Step 4: Verification
- Test with existing data: Run the app on a simulator with V1 data → confirm no crashes
- Check new fields: Verify existing Asset's
isDebitCardis nil (or false) and Transaction'sdebitCardis nil - Test new features: Create a debit card and register transactions, verify data integrity
Common Pitfalls
1. Modifying the V1 file
V1 is a "historical snapshot." If you modify it, SwiftData will think the actual store schema doesn't match V1, and migration will fail.
2. Not defining all models in V2
Even unchanged models like AssetGroup or ExpenseCategory must be included in V2. Missing models from the VersionedSchema's models array will cause runtime errors.
3. Forgetting to include the stage in the stages array
Declaring migrateV1toV2 but not including it in the stages array means migration won't execute.
// Wrong
static var stages: [MigrationStage] { [] } // Empty!
// Correct
static var stages: [MigrationStage] { [migrateV1toV2] }
4. Mismatched @Relationship inverse
If the inverse doesn't match exactly when adding a new relationship, it will compile fine but can corrupt data at runtime.
Why I Recommend the typealias Pattern
Starting with the VersionedSchema + typealias pattern from day one pays off:
- Minimal app code changes during migration
- Single place to manage which schema version is active
- Same pattern scales to V3, V4, and beyond
It's similar to how Core Data managed versions in .xcdatamodeld files, but with code it's far more transparent.
Wrapping Up
SwiftData migration is decidedly simpler than Core Data. Use VersionedSchema for schema versioning, SchemaMigrationPlan for defining migration steps, and fall back to custom stages when lightweight won't cut it.
The essentials:
- Never touch V1
- Define all models in V2
- Keep it lightweight with optional fields + defaults
- Use typealias to minimize app code impact