Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

SwiftData Schema Migrations on macOS: Versioned Models and Recovery

Evolve SwiftData stores safely with immutable versioned schemas, ordered migration stages, custom transforms, rollback planning, and fixture tests.

SwiftData’s model declarations are also a persistence contract. Renaming a property, changing a relationship, or removing an enum case can alter how stored data maps to the application’s types. A migration that works on a developer’s fresh store does not prove that a person who skipped two releases can open a long-lived store without data loss.

SwiftData provides VersionedSchema to describe snapshots of a model schema and SchemaMigrationPlan to describe the versions and migration stages the container can traverse. The current models are not a substitute for historical schema definitions. Preserve each released schema that users may still have, define supported transitions explicitly, and test old stores as fixtures.

Freeze each released schema

Put versioned model types in namespaces that keep their identities distinct. Each schema supplies a version identifier and a model list. Treat a released version as immutable: do not edit its fields later and assume it still describes old on-disk data. A new release adds a new schema version and makes the current-model alias point to that version.

import SwiftData

enum LibrarySchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Book.self]
    }

    @Model
    final class Book {
        var title: String
        init(title: String) { self.title = title }
    }
}

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

    @Model
    final class Book {
        var title: String
        var normalizedTitle: String?
        init(title: String, normalizedTitle: String? = nil) {
            self.title = title
            self.normalizedTitle = normalizedTitle
        }
    }
}

This example is a schema illustration. The new field is optional so an existing row does not need an invented nonoptional value during the lightweight transition. If the product requires every old row to have a computed normalized value, use a custom migration and define that normalization explicitly instead of relying on a model initializer to run for persisted records. Real model versions need all relationships, uniqueness rules, indexes, attributes, and defaults that existed in the corresponding released application. Do not make two version declarations accidentally refer to the same current model class if their persisted layouts differ.

Declare the full path, not just the endpoints

A SchemaMigrationPlan lists the versioned schemas the app supports and an ordered set of stages. A stage describes a transition between two schema versions. A lightweight stage asks SwiftData to perform a migration supported by its automatic migration capabilities. A custom stage provides optional willMigrate and didMigrate closures when the transition needs application work.

Every store the application intends to open must map to a known schema version and a supported migration path. Do not assume that having a V1-to-V2 stage automatically handles an unknown legacy model, a store written by another app version, or a V1-to-V3 jump with missing intervening migration definitions. The migration plan is executable compatibility policy.

import SwiftData

enum LibraryMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [LibrarySchemaV1.self, LibrarySchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [
            .lightweight(
                fromVersion: LibrarySchemaV1.self,
                toVersion: LibrarySchemaV2.self
            )
        ]
    }
}

This migration-plan fragment uses the version types from the preceding example in the same Swift module. The example transition adds an optional field and is intended to illustrate a lightweight-compatible shape; do not label another transition lightweight merely because the source code compiles. Validate it against the actual schema change and a stored fixture. For a custom stage, the callbacks receive a ModelContext and can throw; handle failure as a store-open failure that needs a user-safe recovery path, not as a reason to delete the store.

Design custom transformation stages around data invariants

Use a custom migration when data must be transformed, deduplicated, normalized, or moved between representations in a way the automatic migration cannot express. Define what must be true before and after the transition. For example, if V2 adds a normalized search field, decide the canonical Unicode normalization, locale behavior, empty-input behavior, and whether the new value is derived at read time or stored permanently.

The willMigrate closure is associated with the source schema, while didMigrate runs after the destination schema has been applied. Keep each closure limited to the models and context valid at that phase. A new destination-only property is not a safe place to store work before the destination model exists. Apple documents the stage API and context parameters; verify exact model-access patterns against its current SwiftData migration guidance and compile the stage with the SDK used to ship the app.

A migration must be restartable according to the framework’s store transaction behavior and the app’s error model. Do not make external network calls, send email, or perform irreversible side effects in a migration callback. If an external system needs to learn about transformed data, write a durable local intent in the supported migration path and process it after the container opens. The store migration and remote side effect are not one atomic transaction.

For large stores, profile migration time and memory using representative data. A transformation that fetches every row into one array may exhaust memory. Batch safely where the API and migration stage allow it, keep temporary data minimal, and fail loudly on an invariant violation rather than silently dropping records. Do not suppress errors with optional-try patterns in data-moving code.

Create the model container with the intended plan

Construct the app’s ModelContainer from the versioned schema and plan rather than relying on an implicit current-model inference after versions exist. If the app has multiple configurations, identify which schema and store each configuration owns. App Group, in-memory, CloudKit, and local configurations can have different operational behavior; test each configuration the product actually ships.

Container initialization can throw when a store cannot be opened or migrated. Surface that failure through a recovery flow that preserves the original store until the user or support process chooses what to do. A destructive reset may be an explicit final option, but never silently create an empty store and present it as if the user’s data had migrated successfully.

Build a migration fixture matrix

Keep copies of stores produced by each released schema and test upgrades from every supported starting version. A fixture should contain ordinary rows, optional fields, relationships, deleted references, empty collections, duplicate edge cases, and values outside the happy path. Test launch from the oldest supported release to the latest release, not only one-version increments, and keep an explicit policy if intermediate stages are required.

For every fixture, assert record counts, stable identifiers, relationships, required transformed values, and representative queries after migration. Also test a failed migration using a disposable copy and prove that the original fixture remains recoverable. A successful ModelContainer initializer alone does not establish semantic correctness: the store can open while application-level data has been mapped incorrectly.

If the app supports multiple processes or extensions that open the same store, coordinate rollout and migration ownership. Ensure that older processes are not left writing a schema that the upgraded process has transformed. Test app-group containers, extension startup, and the actual deployment upgrade sequence. For iCloud-backed configurations, validate schema and migration constraints for the sync configuration separately; do not assume local-only behavior automatically applies to CloudKit synchronization.

Compatibility and recovery decisions

Retaining old schemas is a product decision with a support horizon. Removing a schema type that is still present on users’ devices can leave the app unable to recognize their store. Document which historical versions remain readable, whether downgrades are supported, and how restored backups from older app versions are handled.

A migration error should include a stable diagnostic identifier and a redacted error summary. Avoid logging model contents or file paths that reveal user data. Provide an export or support bundle path if appropriate, but do not rewrite the original database while trying to diagnose it. Preserve the original store, use a copy for repair experiments, and make backup or rollback behavior explicit before a production migration ships.

Release acceptance checks

For each migration release, run clean-install tests, every supported old-store fixture, and a process interruption test using a disposable copy. Verify schema version, row count, relationship integrity, key queries, and migration duration. Test both successful open and safe presentation of an error when the store is intentionally unreadable. Re-run the fixtures when changing the model declarations, migration plan, SwiftData SDK, or store configuration.

SwiftData can perform supported schema transitions and gives applications hooks for custom stages. The app still owns its compatibility history, data invariants, release path, and recovery experience. A migration is production-ready only when old persisted data is tested as carefully as new model code.

Related:

Sources:

Comments