```html SwiftUI: How to Build SwiftData Migration (iOS 17+, 2026)

How to Build SwiftData Migration in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: SchemaMigrationPlan Updated: May 12, 2026
TL;DR

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

  1. VersionedSchema snapshots each model generation. Both TripSchemaV1 and TripSchemaV2 are self-contained enums with their own @Model class. The versionIdentifier is a semantic version that SwiftData uses to compare the stored schema against the current one.
  2. 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 to nil automatically. The stage is declared as a static let on the plan to ensure it is initialized only once.
  3. SchemaMigrationPlan sequences hops in order. The schemas array 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.
  4. ModelContainer applies the plan at init time. Passing migrationPlan: TripMigrationPlan.self to the container constructor tells SwiftData to check the store schema on every launch and migrate if necessary — no manual triggering is needed.
  5. The typealias Trip = TripSchemaV2.Trip pattern keeps app code clean. All views and queries reference the bare Trip name; 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

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.

```