SwiftData Migrations β€” Lightweight and Complex

Updated Jun 01, 2026#swiftdata#swiftui#ios#storage

SwiftData handles local persistence with a Swift-native API. When you ship a new version of your app that changes your @Model schema β€” adding a property, renaming a field, or introducing a uniqueness constraint β€” SwiftData needs to migrate existing stores to match the new schema.

SwiftData supports two migration strategies:

  • Lightweight β€” automatic, for additive or renaming changes.
  • Complex (custom) β€” manual migration stages for transformations that lightweight cannot handle.

Lightweight Migrations

Lightweight migrations cover changes SwiftData can infer on its own. These include:

  • Adding, removing, or renaming properties or entities
  • Changing a relationship’s delete rule or cardinality
  • Adding .unique, .externalStorage, or .allowsCloudEncryption attribute options
  • Renaming properties via @Attribute(originalName:)

Removing a property or entity can be schema-compatible, but it permanently deletes that data from the current model. Use preserveValueOnDeletion if you need to retain deleted values for persistent history.

If your changes are additive, lightweight migration is automatic. Just add the property and rebuild:

@Model
final class Trip {
    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    var notes: String = "" // New property β€” lightweight handles this
}

No migration plan is needed. When ModelContainer opens the store and detects the schema changed, it applies lightweight migration automatically.

For renames, provide the original name so SwiftData maps old data to the new property:

@Model
final class Trip {
    var name: String
    @Attribute(originalName: "destination") var location: String
}

This renames destination β†’ location while preserving existing values.

Complex Migrations

When you need to transform data β€” merge entities, split fields, or deduplicate β€” you write a custom migration plan with versioned schemas.

1. Define Versioned Schemas

Each version of your model lives in a VersionedSchema enum:

import SwiftData

enum SchemaV1: 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
        var startDate: Date
        var endDate: Date
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var name: String
        var destination: String
        var startDate: Date
        var endDate: Date
    }
}

typealias Trip = SchemaV2.Trip

Every schema version must include all models used by that version, even if they did not change.

2. Create a Migration Plan

SchemaMigrationPlan lists the schemas and the stages between them:

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Deduplicate before the unique constraint is applied
            let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
            var seen = Set<String>()
            for trip in trips where !seen.insert(trip.name).inserted {
                context.delete(trip)
            }
            try context.save()
        },
        didMigrate: { context in
            // Post-migration cleanup β€” e.g. remove stale cached data
            try context.save()
        }
    )
}

willMigrate runs before the schema is upgraded. Use it to clean or transform data while the old schema is still active. didMigrate runs after the schema change β€” useful for rebuilding derived data or refreshing caches.

3. Wire It Into the App

Pass the migration plan to ModelContainer:

@main
struct TripsApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(
                for: Trip.self,
                migrationPlan: TripMigrationPlan.self
            )
        } catch {
            fatalError("Migration failed: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

For production, consider presenting a recovery UI instead of fatalError.

Handling Migration Errors

willMigrate and didMigrate closures are throws. If either throws, the migration fails and the container does not open. Always use try (not try?) so you surface failures:

willMigrate: { context in
    let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
    // ^ if this throws, migration stops and the caller can catch the error
}

At the app level, catch container initialization errors:

do {
    let container = try ModelContainer(
        for: Trip.self,
        migrationPlan: TripMigrationPlan.self
    )
    // use container
} catch {
    // Migration failed β€” handle gracefully (e.g. fall back to empty store, or alert)
}

Important: If a migration fails or the app crashes during the Core Data stack transition between stages, treat the store as needing validation before retrying. As a defensive practice, back up important stores before risky migrations, test migrations on copies of real data, and handle container initialization errors at the app level.

Testing Migrations

Test migrations on a copy of a real-world store:

  1. Archive a .sqlite store created by the old schema version.
  2. Write a unit test that opens it with the new schema and migration plan.
  3. Assert data integrity after migration β€” every field maps correctly, no records lost.
func testV1toV2Migration() throws {
    let tempDirectory = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString, isDirectory: true)
    try FileManager.default.createDirectory(
        at: tempDirectory,
        withIntermediateDirectories: true
    )
    defer { try? FileManager.default.removeItem(at: tempDirectory) }

    let storeURL = tempDirectory
        .appendingPathComponent("store_v1")
        .appendingPathExtension("sqlite")

    // Copy bundled store files to a writable location (migration mutates them).
    let bundledStore = Bundle.module.url(
        forResource: "store_v1",
        withExtension: "sqlite"
    )!

    for suffix in ["", "-wal", "-shm"] {
        let source = bundledStore.appendingPathExtensionSuffix(suffix)
        let destination = storeURL.appendingPathExtensionSuffix(suffix)
        if FileManager.default.fileExists(atPath: source.path) {
            try FileManager.default.copyItem(at: source, to: destination)
        }
    }

    let config = ModelConfiguration(url: storeURL)
    let container = try ModelContainer(
        for: Trip.self,
        migrationPlan: TripMigrationPlan.self,
        configurations: config
    )
    let fetcher = FetchDescriptor<SchemaV2.Trip>()
    let trips = try container.mainContext.fetch(fetcher)
    XCTAssertFalse(trips.isEmpty)
    XCTAssertEqual(Set(trips.map(\.name)).count, trips.count)
}

One helper keeps the SQLite sidecar paths readable:

private extension URL {
    func appendingPathExtensionSuffix(_ suffix: String) -> URL {
        URL(fileURLWithPath: path + suffix)
    }
}

Keep test stores under version control so schema changes are always validated before shipping.

Lightweight or Complex β€” How to Choose

Change Migration Type
Add a property with a default value Lightweight
Rename a property Lightweight (with originalName:)
Add .unique on an already-unique column Lightweight
Add .unique on a column with duplicates Complex (deduplicate first)
Merge two entities into one Complex
Split a property into multiple fields Complex
Transform stored values (e.g. string β†’ enum) Complex

If in doubt, start with a lightweight migration. If the framework throws at container creation, it will tell you the change needs a custom stage β€” you should still validate migrated data with fixture-based tests and keep backups of production stores before schema changes.