How to Build SwiftData Migration in SwiftUI
Conform each schema version to VersionedSchema, then define a SchemaMigrationPlan that lists the ordered stages between versions. Pass the plan to ModelContainer and SwiftData migrates the on-disk store automatically at launch.
// 1. Define your migration plan
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[TripSchemaV1.self, TripSchemaV2.self]
}
static var stages: [MigrationStage] { [migrateV1toV2] }
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: TripSchemaV1.self,
toVersion: TripSchemaV2.self
)
}
// 2. Wire up the container
let container = try ModelContainer(
for: Trip.self,
migrationPlan: TripMigrationPlan.self
)
Full implementation
The pattern has three layers: versioned schema enums that snapshot each iteration of your model, a migration plan enum that sequences the hops between versions, and a ModelContainer that applies the plan. Below is a realistic two-version evolution: V1 stores a trip with just a name and destination, while V2 adds an optional startDate property — a perfect candidate for a lightweight (automatic) migration stage. The @main app entry point shows exactly how to bootstrap the container and inject it into the SwiftUI environment.
import SwiftUI
import SwiftData
// MARK: - Schema V1
enum TripSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Trip.self] }
@Model final class Trip {
var name: String
var destination: String
init(name: String, destination: String) {
self.name = name
self.destination = destination
}
}
}
// MARK: - Schema V2 (adds startDate)
enum TripSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Trip.self] }
@Model final class Trip {
var name: String
var destination: String
var startDate: Date? // New optional property — safe for lightweight
init(name: String, destination: String, startDate: Date? = nil) {
self.name = name
self.destination = destination
self.startDate = startDate
}
}
}
// MARK: - Migration Plan
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[TripSchemaV1.self, TripSchemaV2.self]
}
static var stages: [MigrationStage] { [migrateV1toV2] }
// Lightweight: SwiftData handles column addition automatically
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: TripSchemaV1.self,
toVersion: TripSchemaV2.self
)
}
// MARK: - Current model alias (always points to latest schema)
typealias Trip = TripSchemaV2.Trip
// MARK: - App entry point
@main
struct TripsApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(
for: Trip.self,
migrationPlan: TripMigrationPlan.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: false)
)
} catch {
fatalError("Failed to initialise ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
TripListView()
}
.modelContainer(container)
}
}
// MARK: - Sample view
struct TripListView: View {
@Query(sort: \Trip.name) private var trips: [Trip]
@Environment(\.modelContext) private var context
var body: some View {
NavigationStack {
List(trips) { trip in
VStack(alignment: .leading, spacing: 2) {
Text(trip.name).font(.headline)
Text(trip.destination).font(.subheadline).foregroundStyle(.secondary)
if let date = trip.startDate {
Text(date.formatted(date: .abbreviated, time: .omitted))
.font(.caption).foregroundStyle(.tertiary)
}
}
}
.navigationTitle("Trips")
.toolbar {
Button("Add Sample") { addSample() }
}
}
}
private func addSample() {
let trip = Trip(name: "Tokyo Run", destination: "Tokyo, Japan", startDate: .now)
context.insert(trip)
}
}
#Preview {
TripListView()
.modelContainer(for: Trip.self, inMemory: true)
}
How it works
-
VersionedSchema snapshots each model generation. Both
TripSchemaV1andTripSchemaV2are self-contained enums with their own@Modelclass. TheversionIdentifieris a semantic version that SwiftData uses to compare the stored schema against the current one. -
MigrationStage.lightweight handles additive, non-destructive changes. Adding an optional property (
startDate: Date?) requires no custom logic — SwiftData adds the column and sets existing rows tonilautomatically. The stage is declared as astatic leton the plan to ensure it is initialized only once. -
SchemaMigrationPlan sequences hops in order. The
schemasarray must list versions in ascending order; SwiftData walks the chain from the stored version to the current one, executing only the stages that are needed. If a user skips V2 and installs V3 directly, the intermediate stages still run in order. -
ModelContainer applies the plan at init time. Passing
migrationPlan: TripMigrationPlan.selfto the container constructor tells SwiftData to check the store schema on every launch and migrate if necessary — no manual triggering is needed. -
The
typealias Trip = TripSchemaV2.Trippattern keeps app code clean. All views and queries reference the bareTripname; bumping to V3 later means updating only the typealias and the migration plan, leaving view code untouched.
Variants
Custom migration stage (data transformation)
When a change cannot be expressed as a pure column addition — for example, splitting a fullName: String property into firstName and lastName — use MigrationStage.custom with willMigrate and didMigrate closures to transform existing records.
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: TripSchemaV2.self,
toVersion: TripSchemaV3.self,
willMigrate: nil,
didMigrate: { context in
// After migration, back-fill the new `formattedTitle` property
let trips = try context.fetch(FetchDescriptor<TripSchemaV3.Trip>())
for trip in trips {
trip.formattedTitle = "\(trip.name) → \(trip.destination)"
}
try context.save()
}
)
In-memory container for Previews and tests
For SwiftUI Previews and unit tests, you never want to touch the real on-disk store. Use ModelConfiguration(isStoredInMemoryOnly: true) — and omit migrationPlan, since an ephemeral store always starts fresh. The #Preview at the bottom of the full implementation uses the convenience .modelContainer(for:inMemory:) modifier that does exactly this.
Common pitfalls
- SwiftData migrations are iOS 17+ only. The entire
SchemaMigrationPlanAPI does not exist on iOS 16 or earlier. If your deployment target is below iOS 17, you must maintain a parallel Core Data stack for those users — there is no bridging path. - Lightweight migration requires optional or default-valued new properties. If you add a non-optional property without a default value and attempt a lightweight stage, SwiftData will throw a migration error at runtime. Always make new properties optional or provide a sensible default (e.g.,
= "",= .now) when usingMigrationStage.lightweight. - Never skip a version in the
schemasarray. All historical schema versions must remain in the array even after you ship V3 or V4. Removing an intermediate version breaks the migration chain for users who haven't launched the app since that version was current, causing an unrecoverable store error. - Custom stage closures run on a background context. Do not update
@Publishedproperties or SwiftUI state from insidewillMigrate/didMigrate. All work must be confined to the providedModelContextargument. - Test migrations against real stores before shipping. Create a device backup containing the old schema store, then install the new build. Simulator testing alone is insufficient because migration timing differs on real hardware and the SQLite WAL mode behaves differently.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement SwiftData migration in SwiftUI for iOS 17+. Use SchemaMigrationPlan, VersionedSchema, and MigrationStage. Define TripSchemaV1 (name, destination) and TripSchemaV2 (adds startDate: Date?). Use a lightweight migration stage between versions. Set up ModelContainer with the migration plan in the @main App struct. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data using an in-memory container.
In the Soarias Build phase, paste this prompt into the active session after your data model screens are locked — Claude Code will scaffold the versioned schemas and wire the container without touching your existing view layer.
Related
FAQ
Does SwiftData migration work on iOS 16?
No. SchemaMigrationPlan, VersionedSchema, and MigrationStage are all iOS 17+ APIs. If you must support iOS 16, you need to maintain a Core Data stack with NSMigratePersistentStoresAutomaticallyOption for those users, and gate the SwiftData path behind an if #available(iOS 17, *) check. In practice, most teams shipping new apps in 2026 can safely set iOS 17 as their minimum deployment target.
When should I use a custom stage instead of a lightweight stage?
Use MigrationStage.lightweight whenever the change is purely additive: adding optional properties, adding new models, or adding relationships where the new end is optional. Switch to MigrationStage.custom when you need to transform data — renaming a property, splitting a field into multiple columns, changing a property type (e.g., String to Int), or back-filling computed values based on existing data. Custom stages let you run arbitrary Swift code inside willMigrate and didMigrate closures using a provided ModelContext.
What is the UIKit / Core Data equivalent?
In Core Data, schema migration uses NSMappingModel and NSMigrationManager. Lightweight migration maps to setting NSMigratePersistentStoresAutomaticallyOption: true and NSInferMappingModelAutomaticallyOption: true on the persistent store coordinator options. Custom migrations required hand-crafted .xcmappingmodel files or a subclassed NSEntityMigrationPolicy. SwiftData's SchemaMigrationPlan is significantly less verbose and fully type-safe — the main trade-off is that it requires iOS 17+ and doesn't yet support all the edge cases that Core Data has covered over 20 years.
Last reviewed: 2026-05-12 by the Soarias team.