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 migrations cover changes SwiftData can infer on its own. These include:
.unique, .externalStorage, or .allowsCloudEncryption attribute options@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.
When you need to transform data β merge entities, split fields, or deduplicate β you write a custom migration plan with 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.TripEvery schema version must include all models used by that version, even if they did not change.
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.
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.
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.
Test migrations on a copy of a real-world store:
.sqlite store created by the old schema version.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.
| 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.