SwiftData Schema Migrations: Versioning Your Schema Without Losing User Data


Version 1.0 of your Pixar film tracker stores PixarFilm with a releaseDate: Date. Version 2.0 needs to split that into releaseYear: Int and releaseCountry: String. Without a migration plan, every user who updates will either have their data silently deleted and recreated from scratch — or, more visibly, the app will crash on launch with a SwiftData.SchemaError.

This guide covers SwiftData’s full migration system: VersionedSchema, SchemaMigrationPlan, lightweight vs. custom migration stages, multi-step migrations, and a testing strategy that catches regressions before they reach production. We won’t cover CoreData-to-SwiftData migration paths — that’s a separate topic with its own surface area.

This post assumes you’re familiar with SwiftData relationships and @Relationship. If you haven’t read that yet, start there.

Note: All SwiftData migration APIs require iOS 17+. Annotate your entry points accordingly.

Contents

The Problem: Changing a Model Without a Plan

Suppose your v1 app ships with this model:

@available(iOS 17, *)
@Model
final class PixarFilm {
    var title: String
    var releaseDate: Date  // ← v1 schema

    init(title: String, releaseDate: Date) {
        self.title = title
        self.releaseDate = releaseDate
    }
}

In v2, product decides the app needs releaseYear: Int and releaseCountry: String separately, and releaseDate should be removed. A developer who doesn’t know about migrations might simply update the model:

@available(iOS 17, *)
@Model
final class PixarFilm {
    var title: String
    var releaseYear: Int      // ← changed
    var releaseCountry: String // ← new

    init(title: String, releaseYear: Int, releaseCountry: String) {
        self.title = title
        self.releaseYear = releaseYear
        self.releaseCountry = releaseCountry
    }
}

When this app launches on a device with existing v1 data, SwiftData compares the on-disk schema to the current model. It detects a mismatch and has no instructions for how to transform the old data. Depending on the ModelConfiguration options and the iOS version, one of two things happens:

  1. SwiftData throws a SwiftData.SchemaError and the ModelContainer fails to initialize, crashing the app on launch.
  2. SwiftData silently destroys and recreates the persistent store, wiping all user data.

Option 2 is particularly dangerous during development — the silent data loss is easy to miss in testing if you’re always starting from a clean install.

Warning: Never rename a @Model class without a migration. SwiftData uses the class name as the entity name in the persistent store. Renaming PixarFilm to Film without an originalName annotation or a migration stage will cause SwiftData to treat the new class as a completely different entity — leading to data loss or a schema conflict.

Apple Docs: ModelContainer — SwiftData

Versioned Schemas

The solution is to never mutate your live model class directly. Instead, wrap each historical version of your model in a VersionedSchema enum:

import SwiftData

@available(iOS 17, *)
enum PixarSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [PixarFilm.self]
    }

    @Model
    final class PixarFilm {
        var title: String
        var releaseDate: Date

        init(title: String, releaseDate: Date) {
            self.title = title
            self.releaseDate = releaseDate
        }
    }
}
@available(iOS 17, *)
enum PixarSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [PixarFilm.self]
    }

    @Model
    final class PixarFilm {
        var title: String
        var releaseYear: Int       // ← changed
        var releaseCountry: String // ← new

        init(title: String, releaseYear: Int, releaseCountry: String) {
            self.title = title
            self.releaseYear = releaseYear
            self.releaseCountry = releaseCountry
        }
    }
}

Each VersionedSchema enum acts as a namespace that freezes a snapshot of your model at a specific schema version. The model class inside PixarSchemaV2 is what the rest of your app uses — you reference it as PixarSchemaV2.PixarFilm in migrations and as PixarFilm in your application code (via a typealias, covered shortly).

The models array lists every @Model type that belongs to this schema version. If you have PixarFilm, PixarStudio, and PixarCharacter, all three go in models — even if only PixarFilm changed between versions.

Apple Docs: VersionedSchema — SwiftData

Keeping Application Code Clean

Referencing PixarSchemaV2.PixarFilm throughout your app is noisy. Use a typealias in a non-versioned file to point your app code at the current version:

// CurrentSchema.swift — update this file on each version bump

import SwiftData

@available(iOS 17, *)
typealias PixarFilm = PixarSchemaV2.PixarFilm

@available(iOS 17, *)
typealias CurrentSchema = PixarSchemaV2

Your views, view models, and repositories all use PixarFilm directly. When you add V3, you update CurrentSchema.swift — one file, one change.

Lightweight Migrations

Not all schema changes require data transformation. Adding an optional property, adding a new entity, or adding an index are non-destructive changes. SwiftData can perform these automatically via MigrationStage.lightweight:

@available(iOS 17, *)
enum PixarSchemaV3: VersionedSchema {
    static var versionIdentifier = Schema.Version(3, 0, 0)

    static var models: [any PersistentModel.Type] {
        [PixarFilm.self]
    }

    @Model
    final class PixarFilm {
        var title: String
        var releaseYear: Int
        var releaseCountry: String
        var posterURL: URL?         // ← new optional property (lightweight-safe)
        var isAnimated: Bool = true // ← new property with default (lightweight-safe)

        init(
            title: String,
            releaseYear: Int,
            releaseCountry: String,
            posterURL: URL? = nil
        ) {
            self.title = title
            self.releaseYear = releaseYear
            self.releaseCountry = releaseCountry
            self.posterURL = posterURL
        }
    }
}

Lightweight-safe changes include:

  • Adding a new optional property
  • Adding a new non-optional property with a default value
  • Adding a new @Model entity
  • Adding or removing an index (@Attribute(.unique) counts)
  • Adding a new relationship with .nullify delete rule

Non-lightweight changes — anything requiring data transformation — need a custom stage.

Tip: When in doubt about whether a change qualifies as lightweight, test it with an in-memory store (see the Advanced Usage section). If the ModelContainer initializes without throwing, it’s lightweight.

Custom Migration Stages

For transformative changes like the releaseDatereleaseYear + releaseCountry split, use MigrationStage.custom:

@available(iOS 17, *)
enum PixarMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [PixarSchemaV1.self, PixarSchemaV2.self, PixarSchemaV3.self]
    }

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

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: PixarSchemaV1.self,
        toVersion: PixarSchemaV2.self,
        willMigrate: { context in
            // Called before the schema change is applied.
            // The context still operates against the V1 schema here.
            // Use this hook to read or archive data you might lose.
            // For this migration we don't need it — data survives in V2 properties.
        },
        didMigrate: { context in
            // Called after the schema change. The context now operates
            // against the V2 schema — PixarFilm has releaseYear and releaseCountry.
            let films = try context.fetch(
                FetchDescriptor<PixarSchemaV2.PixarFilm>()
            )

            let calendar = Calendar.current
            for film in films {
                // releaseDate was removed — V2 films have releaseYear = 0 by default.
                // We can't recover the original Date here (it was on V1 objects),
                // but we can set a sensible default. See the willMigrate hook
                // pattern below for preserving data across the boundary.
                film.releaseYear = 1995   // default placeholder
                film.releaseCountry = "USA"
            }

            try context.save()
        }
    )

    static let migrateV2toV3 = MigrationStage.lightweight(
        fromVersion: PixarSchemaV2.self,
        toVersion: PixarSchemaV3.self
    )
}

Preserving Data Across the Schema Boundary

The challenge in the V1→V2 migration above is that releaseDate (a V1 property) is gone by the time didMigrate runs. The willMigrate hook is the escape hatch — use it to persist derived values somewhere accessible to didMigrate:

@available(iOS 17, *)
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: PixarSchemaV1.self,
    toVersion: PixarSchemaV2.self,
    willMigrate: { context in
        // The V1 schema is still active. Read and cache the data
        // we need to carry forward.
        let v1Films = try context.fetch(
            FetchDescriptor<PixarSchemaV1.PixarFilm>()
        )

        // Write year+title pairs to UserDefaults as a temporary bridge.
        // Keep this data small — UserDefaults isn't a database.
        var bridge: [String: Int] = [:]
        let calendar = Calendar.current
        for film in v1Films {
            let year = calendar.component(.year, from: film.releaseDate)
            bridge[film.title] = year
        }

        UserDefaults.standard.set(bridge, forKey: "migrationBridge_v1v2")
    },
    didMigrate: { context in
        let bridge = UserDefaults.standard.dictionary(
            forKey: "migrationBridge_v1v2"
        ) as? [String: Int] ?? [:]

        let v2Films = try context.fetch(
            FetchDescriptor<PixarSchemaV2.PixarFilm>()
        )

        for film in v2Films {
            film.releaseYear = bridge[film.title] ?? 0
            film.releaseCountry = "USA"
        }

        try context.save()

        // Clean up the temporary bridge data
        UserDefaults.standard.removeObject(forKey: "migrationBridge_v1v2")
    }
)

This willMigrateUserDefaultsdidMigrate handoff is the standard pattern when you need to carry data across a destructive schema change. Keep the bridged data small and always clean it up after didMigrate succeeds.

Apple Docs: SchemaMigrationPlan — SwiftData | MigrationStage — SwiftData

Configuring ModelContainer with a Migration Plan

Wire the migration plan into your ModelContainer at app startup:

import SwiftData
import SwiftUI

@available(iOS 17, *)
@main
struct PixarTrackerApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(
                for: PixarSchemaV3.PixarFilm.self,  // ← current schema models
                migrationPlan: PixarMigrationPlan.self,
                configurations: ModelConfiguration(isStoredInMemoryOnly: false)
            )
        } catch {
            // Migration failed — handle gracefully (see Advanced Usage)
            fatalError("Failed to initialize ModelContainer: \(error)")
        }
    }

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

SwiftData reads the on-disk schema version, finds the matching entry in PixarMigrationPlan.schemas, and executes all stages in order from the detected version to the current version. A user upgrading from V1 directly to V3 will run both migrateV1toV2 and migrateV2toV3 in sequence.

Warning: The schemas array in your SchemaMigrationPlan must be ordered from oldest to newest. Misordering this array causes SwiftData to apply migrations out of sequence, producing corrupted data or a SchemaError.

Advanced Usage

Multi-Step Migrations: V1 → V2 → V3

The migration plan’s schemas array implicitly defines the upgrade path. SwiftData chains stages automatically — a device on V1 upgrading to a V3 app runs migrateV1toV2 then migrateV2toV3. You don’t need to write a direct V1→V3 path. This means you can build your migration logic incrementally as you ship versions, and long-term users get the same correctness guarantees as recent upgraders.

The practical implication: never remove historical VersionedSchema enums. They’re the migration foundation. PixarSchemaV1 must remain in your codebase for as long as any user might be upgrading from V1 — which, in practice, means forever unless you ship a minimum version cut.

Testing Migrations with In-Memory Stores

SwiftData migrations can be unit-tested without touching the device’s persistent store. The key is seeding a V1 in-memory store, then reinitializing the container with the migration plan against the same URL:

import Testing
import SwiftData

@available(iOS 17, *)
struct MigrationTests {
    @Test
    func testV1ToV2MigrationPreservesReleaseYear() throws {
        // 1. Create a temporary on-disk URL for the test store
        let testURL = URL.temporaryDirectory.appending(
            path: "test-migration-\(UUID()).sqlite"
        )

        // 2. Seed V1 data
        let v1Config = ModelConfiguration(url: testURL)
        let v1Container = try ModelContainer(
            for: PixarSchemaV1.PixarFilm.self,
            configurations: v1Config
        )
        let v1Context = ModelContext(v1Container)

        // Release date for Toy Story (November 22, 1995)
        var components = DateComponents()
        components.year = 1995
        components.month = 11
        components.day = 22
        let toyStoryDate = Calendar.current.date(from: components)!

        v1Context.insert(
            PixarSchemaV1.PixarFilm(title: "Toy Story", releaseDate: toyStoryDate)
        )
        try v1Context.save()

        // 3. Re-open the same store URL with the migration plan
        let migratedConfig = ModelConfiguration(url: testURL)
        let migratedContainer = try ModelContainer(
            for: PixarSchemaV2.PixarFilm.self,
            migrationPlan: PixarMigrationPlan.self,
            configurations: migratedConfig
        )
        let migratedContext = ModelContext(migratedContainer)

        // 4. Verify the data was transformed correctly
        let films = try migratedContext.fetch(
            FetchDescriptor<PixarSchemaV2.PixarFilm>()
        )
        #expect(films.count == 1)
        #expect(films[0].title == "Toy Story")
        #expect(films[0].releaseYear == 1995)

        // 5. Clean up
        try FileManager.default.removeItem(at: testURL)
    }
}

This test pattern is the most reliable way to catch migration regressions. Run it in CI. A failing migration test before a release is a recoverable situation; a failing migration on a user’s device is a support ticket and a bad review.

What Happens When a Migration Fails

If ModelContainer throws during migration, you have a few options:

  1. Fail loudly in debug, gracefully in release. In debug builds, fatalError immediately to surface the issue. In release builds, present a recovery UI that offers to reset the store.
  2. Back up before migrating. Copy the SQLite file to a backup location before initializing the container. If migration fails, restore from backup.
  3. Offer a “Reset App Data” option in Settings. Proactively giving users this escape valve, combined with iCloud backup reminders, is better than silently crashing.
@available(iOS 17, *)
func makeContainer() -> ModelContainer {
    do {
        return try ModelContainer(
            for: CurrentSchema.PixarFilm.self,
            migrationPlan: PixarMigrationPlan.self
        )
    } catch {
        // In release: log the error, present recovery UI
        // For now, reset the store as a last resort
        guard let storeURL = defaultStoreURL() else {
            fatalError("No store URL and migration failed: \(error)")
        }
        try? FileManager.default.removeItem(at: storeURL)
        return try! ModelContainer(for: CurrentSchema.PixarFilm.self)
    }
}

This is not the only approach, but it illustrates the principle: always have a recovery path, and always log migration failures to your crash reporter.

CloudKit Sync and Migrations

If your app uses ModelConfiguration with cloudKitDatabase: .automatic, migrations have additional constraints:

  • CloudKit requires all properties to be optional (or have defaults). Custom migration stages that introduce non-optional properties without defaults will cause CloudKit sync to fail.
  • Lightweight migrations sync cleanly across devices. Custom migrations that modify existing records will conflict with in-flight CloudKit changes from other devices.
  • Apple recommends pausing CloudKit sync during complex migrations. In practice, this means adding a migration-in-progress flag and deferring CloudKit operations until didMigrate completes.

Apple Docs: Syncing model data across a person’s devices — SwiftData

Performance Considerations

Migration performance scales with data volume. A MigrationStage.custom that fetches and modifies every row in the store runs in O(n) time. For an app with 10,000 PixarFilm records, this might take several hundred milliseconds — long enough to be noticeable at app launch.

A few strategies to keep migration latency acceptable:

Batch saves. Instead of calling context.save() after every modification, batch saves every 100–500 objects. Each save() is a SQLite transaction commit — batching reduces I/O.

// ❌ Save per object — O(n) transactions
for film in films {
    film.releaseYear = bridge[film.title] ?? 0
    try context.save()
}

// ✅ Batch save — one transaction per 500 records
for (index, film) in films.enumerated() {
    film.releaseYear = bridge[film.title] ?? 0
    if index % 500 == 0 {
        try context.save()
    }
}
try context.save() // Final flush

Paginated fetching. For very large stores, fetching all records at once in didMigrate can exhaust memory. Use FetchDescriptor with fetchLimit and fetchOffset to process records in pages.

Background migration. SwiftData runs migrations synchronously on the thread that initializes ModelContainer. For migrations expected to exceed 500ms, consider showing a migration progress screen and initializing the container on a background thread.

When to Use (and When Not To)

ScenarioRecommendation
Adding an optional property or a new modelMigrationStage.lightweight — no custom logic needed
Removing a propertyMigrationStage.lightweight if the data isn’t needed; custom if you need to archive it
Renaming a propertySet @Attribute(originalName: "oldName") on the new property — qualifies as lightweight
Renaming a @Model classRequires originalName on the class-level @Model or a full custom migration
Splitting one property into multiple (e.g., releaseDatereleaseYear + releaseCountry)MigrationStage.custom with willMigrate to preserve the original data
Changing a non-optional to optional or vice versaCustom migration to handle existing nil/non-nil cases explicitly
No existing users on the previous version (first public release)Skip versioning — just modify the model freely and bump the schema version

Tip: The @Attribute(originalName:) annotation is a lightweight alternative to full migrations for property renames. Use it before reaching for MigrationStage.custom.

Summary

  • SwiftData uses class names and property names as the schema identity. Any change that alters those names — without a migration — will cause a SchemaError or silent data loss.
  • Wrap every historical model version in a VersionedSchema enum. Never modify a versioned schema after it ships.
  • MigrationStage.lightweight handles additive, non-destructive changes with no custom logic.
  • MigrationStage.custom provides willMigrate and didMigrate hooks for transformative changes. Use willMigrate to archive data before the schema change, didMigrate to apply it.
  • Test migrations in CI using temporary on-disk stores seeded with historical schema data. A migration test failure before release is always recoverable; a failure on a user’s device often is not.
  • Always have a fallback path when ModelContainer initialization fails — log the error, offer a reset, and preserve user trust.

With reliable migrations in place, the natural next question is whether SwiftData is the right persistence layer for your use case at all. SwiftData vs Core Data makes a direct comparison across performance, flexibility, and migration ergonomics for teams deciding which to adopt or migrate to.