SwiftData, Apple's modern persistence framework, has a clean migration story when you set it up right and a confusing one when you don't. This guide is the right way, plus the gotchas I've hit shipping persistent SwiftData apps (Outpost.Co, Upsee, Test112).
- iOS 17+ (SwiftData minimum) or iOS 18+ for the better migration ergonomics.
- A SwiftData model you've already shipped to users — and you need to change it.
- Comfort with Swift's
typealias, generics, and@Model.
Step 1The "from day one" pattern
If you're starting fresh: don't ship a raw @Model as your schema. Wrap it in a VersionedSchema from v1. Future-you will thank you.
enum MySchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Trip.self, PackingItem.self] } @Model final class Trip { var name: String var startDate: Date init(name: String, startDate: Date) { self.name = name self.startDate = startDate } } }
Then add a type alias at the top of the app target so your views and view models reference Trip, not MySchemaV1.Trip:
typealias Trip = MySchemaV1.Trip typealias PackingItem = MySchemaV1.PackingItem
Step 2Set up the container with a MigrationPlan
enum MyMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [MySchemaV1.self] // add V2 here later } static var stages: [MigrationStage] { [] // no migrations yet } } @main struct OutpostApp: App { var body: some Scene { WindowGroup { RootView() } .modelContainer( for: [Trip.self, PackingItem.self], migrationPlan: MyMigrationPlan.self ) } }
Step 3Now add v2 — the lightweight case
You need to add a nullable property to Trip. SwiftData can do this as a "lightweight" migration — no data transformation needed.
enum MySchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [Trip.self, PackingItem.self] } @Model final class Trip { var name: String var startDate: Date var notes: String? // NEW — nullable, lightweight init(name: String, startDate: Date, notes: String? = nil) { self.name = name; self.startDate = startDate; self.notes = notes } } }
Update the typealias to point at V2: typealias Trip = MySchemaV2.Trip. Add V2 to the migration plan:
enum MyMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [MySchemaV1.self, MySchemaV2.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: MySchemaV1.self, toVersion: MySchemaV2.self)] } }
Step 4v3 — the custom (data-transforming) case
You need to split name into title and subtitle. SwiftData can't infer that — you write a custom stage.
.custom( fromVersion: MySchemaV2.self, toVersion: MySchemaV3.self, willMigrate: { context in let trips = try context.fetch(FetchDescriptor<MySchemaV2.Trip>()) for trip in trips { let parts = trip.name.split(separator: ":", maxSplits: 1) trip.name = String(parts[0]) // stash subtitle in a transient — picked up by didMigrate } try context.save() }, didMigrate: { context in // now operating on V3 models — set subtitle if you stashed it } )
SwiftData identifies schema versions by a hash of the model, not just the version number you declare. If you change V2 in-place (say, you added notes but haven't shipped V2 yet, then you also rename a different property), the hash changes — but the version number doesn't. SwiftData thinks the store is at a version it has never seen and may discard the existing data.
The rule: once a VersionedSchema is shipped to users, don't touch it again. Any change becomes a new Vn+1. This is the single most common cause of "users updated the app and lost all their data."
Step 5Verify before shipping
The only way to verify a migration works on real production data is to actually run it on real production data. Steps:
- Install the previous version on a device or simulator. Use the app, generate some data.
- Replace the binary with the new version (TestFlight or a local archive). Don't delete the app first — that wipes the persistent store and skips the migration.
- Launch. The migration runs on first launch. Verify nothing crashed and the data is intact.
- Check the SwiftData store file at
~/Library/Developer/CoreSimulator/Devices/<UDID>/data/Containers/Data/Application/<APP-UDID>/Library/Application Support/default.storefor size/sanity.
For data you really can't lose, copy the store file to a backup location before kicking off the migration. If it goes wrong, you can roll back. The Outpost.Co migrate-to-V6 happened with backup-before-migrate as a one-time guard.