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
- Versioned Schemas
- Lightweight Migrations
- Custom Migration Stages
- Configuring
ModelContainerwith a Migration Plan - Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
- SwiftData throws a
SwiftData.SchemaErrorand theModelContainerfails to initialize, crashing the app on launch. - 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
@Modelclass without a migration. SwiftData uses the class name as the entity name in the persistent store. RenamingPixarFilmtoFilmwithout anoriginalNameannotation 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
@Modelentity - Adding or removing an index (
@Attribute(.unique)counts) - Adding a new relationship with
.nullifydelete 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
ModelContainerinitializes without throwing, it’s lightweight.
Custom Migration Stages
For transformative changes like the releaseDate → releaseYear + 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 willMigrate → UserDefaults → didMigrate 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
schemasarray in yourSchemaMigrationPlanmust be ordered from oldest to newest. Misordering this array causes SwiftData to apply migrations out of sequence, producing corrupted data or aSchemaError.
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:
- Fail loudly in debug, gracefully in release. In debug builds,
fatalErrorimmediately to surface the issue. In release builds, present a recovery UI that offers to reset the store. - Back up before migrating. Copy the SQLite file to a backup location before initializing the container. If migration fails, restore from backup.
- 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
didMigratecompletes.
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)
| Scenario | Recommendation |
|---|---|
| Adding an optional property or a new model | MigrationStage.lightweight — no custom logic needed |
| Removing a property | MigrationStage.lightweight if the data isn’t needed; custom if you need to archive it |
| Renaming a property | Set @Attribute(originalName: "oldName") on the new property — qualifies as lightweight |
Renaming a @Model class | Requires originalName on the class-level @Model or a full custom migration |
Splitting one property into multiple (e.g., releaseDate → releaseYear + releaseCountry) | MigrationStage.custom with willMigrate to preserve the original data |
| Changing a non-optional to optional or vice versa | Custom 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 forMigrationStage.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
SchemaErroror silent data loss. - Wrap every historical model version in a
VersionedSchemaenum. Never modify a versioned schema after it ships. MigrationStage.lightweighthandles additive, non-destructive changes with no custom logic.MigrationStage.customprovideswillMigrateanddidMigratehooks for transformative changes. UsewillMigrateto archive data before the schema change,didMigrateto 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
ModelContainerinitialization 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.