Skip to main content

SwiftData Schema Migrations: Solving 'Persistent Store Migration Failed' Crashes

 You are likely reading this because your iOS app is crashing on launch. You modified a @Model class—perhaps you renamed a property, changed a data type, or added a non-optional field—and now Xcode is throwing a fatalError inside the SwiftData container initialization.

Common error signatures include:

  • "The model used to open the store is incompatible with the one used to create the store"
  • NSMigrationMissingSourceModelError
  • Cannot use staged migration

This crash occurs because the shape of your data in code (the Model) no longer matches the shape of the data stored on the device (the SQLite schema). SwiftData attempts "lightweight" migrations automatically, but when changes are ambiguous or complex, it fails to prevent data corruption.

This guide provides a production-grade implementation of VersionedSchema and SchemaMigrationPlan to resolve these crashes and handle data evolution safely.

The Root Cause: Why SwiftData Crashes

Under the hood, SwiftData is a wrapper around Core Data. When your app launches, it inspects the persistent store file (usually a .sqlite file) and compares its metadata hash against the current @Model definitions in your codebase.

If the hashes differ, SwiftData looks for a path to bridge the gap.

  1. Lightweight Migration: If you only added an optional property, SwiftData can usually infer the change and update the SQL table automatically.
  2. Staged Migration: If you renamed a variable (e.g., name to fullName), SwiftData sees this as "Delete column name, Create column fullName." It refuses to run this automatically because it would result in data loss.

To fix this, you must explicitly define the history of your schema versions and the logic required to transform data from Version A to Version B.

The Fix: Implementing VersionedSchema

To solve the crash, we must stop defining our models as loose classes and start organizing them into VersionedSchema enums.

Scenario

We have a User model. In Version 1, it had a single name string. In Version 2, we want to split this into firstName and lastName.

Step 1: Define Schema Version 1

First, wrap your original (pre-crash) model in a schema enum. This tells SwiftData what the data looks like currently on the disk.

import SwiftData
import Foundation

enum UserSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version = .init(1, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [User.self]
    }
    
    @Model
    final class User {
        var name: String
        var email: String
        
        init(name: String, email: String) {
            self.name = name
            self.email = email
        }
    }
}

Step 2: Define Schema Version 2

Now, define the new structure in a separate schema enum. Note that we rename the class to User inside the enum, but SwiftData handles the mapping based on the entity name.

enum UserSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version = .init(2, 0, 0)
    
    static var models: [any PersistentModel.Type] {
        [User.self]
    }
    
    @Model
    final class User {
        // We are replacing 'name' with these two fields
        var firstName: String
        var lastName: String
        
        @Attribute(.unique) 
        var email: String // Unchanged
        
        init(firstName: String, lastName: String, email: String) {
            self.firstName = firstName
            self.lastName = lastName
            self.email = email
        }
    }
}

Step 3: Create the Migration Plan

This is the critical step. We create a SchemaMigrationPlan that instructs SwiftData on how to convert UserSchemaV1.User into UserSchemaV2.User.

Since we are splitting a string (name -> first + last), we need a Custom Migration Stage.

enum UserMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [UserSchemaV1.self, UserSchemaV2.self]
    }
    
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }
    
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: UserSchemaV1.self,
        toVersion: UserSchemaV2.self,
        willMigrate: { context in
            // Pre-migration logic (usually empty for this type of change)
        },
        didMigrate: { context in
            // 1. Fetch all users using the V2 definition 
            // (SwiftData has already updated the table structure by now, but fields are empty)
            let users = try context.fetch(FetchDescriptor<UserSchemaV2.User>())
            
            // 2. Iterate and transform data
            for user in users {
                // In V1, this data was in the 'name' column. 
                // However, during a custom migration, we need to access the raw values 
                // or rely on temporary properties if SwiftData preserved them.
                // For a split like this, we often rely on the fact that the 'name' column
                // might still exist in the underlying row if not explicitly dropped yet,
                // OR simpler: we default values if strictly adding.
                
                // CRITICAL NOTE: In a pure rename/split scenario, accessing the old 
                // 'name' value via the V2 model is impossible directly.
                // A common strategy is to use 'willMigrate' with the V1 model 
                // to buffer data, but context saves are tricky between stages.
                
                // For this example, let's assume a simpler migration where we provide defaults
                // or specific logic. 
                
                user.firstName = "Unknown" 
                user.lastName = "User"
                
                // If you need to preserve data from a deleted column during migration,
                // the safest approach in SwiftData is often keeping the old property 
                // in V2 as a temporary optional, copying data, then removing it in V3.
            }
            
            try context.save()
        }
    )
}

Refining the Custom Stage Logic: Directly accessing deleted properties in didMigrate is difficult because the context is already using the V2 model. If preserving exact string data during a destructive split is required, the most robust pattern is a three-step lightweight migration:

  1. Add firstName/lastName (optional) to V1.
  2. Run a script on launch to populate new fields from name.
  3. Create V2 that deletes name.

However, for standard renaming or lightweight changes, use MigrationStage.lightweight.

// Example of a lightweight stage if we were just adding a field
static let migrateV1toV2Lightweight = MigrationStage.lightweight(
    fromVersion: UserSchemaV1.self,
    toVersion: UserSchemaV2.self
)

Step 4: Configure the Model Container

Finally, update your App entry point. You must type-alias the User class to the current version so your SwiftUI views use the correct definition, and inject the migration plan.

import SwiftUI
import SwiftData

// Typealias the "Active" User model to the latest version
typealias User = UserSchemaV2.User

@main
struct MyApp: App {
    let container: ModelContainer
    
    init() {
        do {
            // Initialize with the Migration Plan
            container = try ModelContainer(
                for: User.self, 
                migrationPlan: UserMigrationPlan.self
            )
        } catch {
            fatalError("Failed to initialize model container: \(error)")
        }
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

Deep Dive: How It Works

When the app initializes the ModelContainer with a migrationPlan:

  1. Version Detection: SwiftData checks the .sqlite store metadata to find the current schema version.
  2. Pathfinding: It checks your UserMigrationPlan.stages to find a route from the store's version to the UserSchemaV2 version.
  3. Execution:
    • It creates a temporary ModelContext.
    • It executes the willMigrate closure (if custom).
    • It performs the SQL schema updates (altering tables).
    • It executes the didMigrate closure.
  4. Finalization: It marks the store as compatible with V2.

This process ensures that your app never attempts to load V1 data into a V2 class structure without an intermediate translation layer.

Common Pitfalls and Edge Cases

1. The "Delete and Reinstall" Trap

During development, it is tempting to just delete the app from the simulator to reset the database. While this works for prototyping, never rely on this strategy. If you ship an app update that changes the model without a migration plan, existing users will crash immediately upon update, losing all their data.

2. Renaming Attributes

If you simply rename var address to var homeAddress, SwiftData assumes address was deleted and homeAddress is new (data loss).

To fix this, use @Attribute(originalName:) in your V2 model. This allows a lightweight migration without a custom plan.

@Model
final class User {
    @Attribute(originalName: "address")
    var homeAddress: String
    // ... other properties
}

3. Large Datasets

Custom migration stages run synchronously on the main thread during app launch in the initialization phase shown above. If you have 100,000 records, complex logic in didMigrate will cause the app to hang or be killed by the iOS watchdog.

For massive datasets, prefer:

  1. Lightweight migration to add the new fields (nullable).
  2. Background processing (using a background ModelContext) to populate the new fields gradually after app launch.
  3. Future migration to remove the old fields.

Conclusion

The "Persistent Store Migration Failed" error is a safeguard, not a bug. It protects your user's data from becoming corrupted by code changes. By implementing VersionedSchema and SchemaMigrationPlan, you transform this crash into a controlled, safe data evolution process.

Always start your SwiftData projects with VersionedSchema if you anticipate any future changes to your data models. It involves slightly more boilerplate upfront but saves significant engineering hours in crash fixes later.