
A shipped SwiftData app has exactly one chance to migrate each user's store correctly. Run the migration tests on a remote Mac before you submit to App Store Connect, and you catch entity-count drops, missing relationships, and silent type coercions in CI rather than in a one-star review.
This guide gives you the full XCTest workflow: define two VersionedSchema versions, write a MigrationStage.custom with willMigrate and didMigrate closures, seed a v1 store in test setup, migrate to v2, and assert data integrity. SwiftData is available on macOS 14+ and iOS 17+. For ARM-only simulator slices (newest iOS SDKs) and GPU work, request Figment's M-series Mac from $95/month; the exact chip, RAM, and disk are confirmed in the quote. For Swift package builds, xcodebuild test against supported Intel simulator runtimes, and self-hosted CI agents, the $29/month virtual Intel macOS plan is usually enough. Start your configuration request.
SwiftData uses VersionedSchema conformances to pin your model at a point in time. Each released version becomes a named enum that lists the @Model classes active in that release and declares a static var versionIdentifier: Schema.Version.
A SchemaMigrationPlan orders those versions into a sequence of MigrationStage values via static var schemas and static var stages. SwiftData reads the existing store's version, finds the gap to the current schema, and walks each stage in order. When you initialize ModelContainer with that migrationPlan, the walk runs automatically before your app — or your test — performs any fetch.
The MigrationStage enum defines two cases:
| Stage | Use when |
|---|---|
lightweight(fromVersion:toVersion:) | Adding optional attributes, renaming with @Attribute(.originalName), changing a delete rule |
custom(fromVersion:toVersion:willMigrate:didMigrate:) | Making a property unique, transforming values, splitting or merging entities |
A lightweight stage requires no code — SwiftData infers the mapping. A custom stage gives you two optional (ModelContext) throws -> Void closures: willMigrate runs before the new schema is applied (old models are reachable), and didMigrate runs after (only new models are reachable).
Put both versions in one file so the migration plan can reference them side by side. Note that AppSchemaV1.Item and AppSchemaV2.Item are distinct namespaced types — SwiftData's recommended pattern for coexisting schema versions.
import SwiftData
enum AppSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Item.self] }
@Model final class Item {
var title: String
var createdAt: Date
init(title: String, createdAt: Date = .now) {
self.title = title
self.createdAt = createdAt
}
}
}
enum AppSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Item.self] }
@Model final class Item {
var title: String
var createdAt: Date
var priority: Int // new required attribute
var tags: [String] // new attribute
init(title: String, createdAt: Date = .now, priority: Int = 0, tags: [String] = []) {
self.title = title
self.createdAt = createdAt
self.priority = priority
self.tags = tags
}
}
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [AppSchemaV1.self, AppSchemaV2.self] }
static var stages: [MigrationStage] { [v1ToV2] }
static let v1ToV2 = MigrationStage.custom(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self,
willMigrate: { context in
let oldItems = try context.fetch(FetchDescriptor<AppSchemaV1.Item>())
_ = oldItems // inspect or transform here
try context.save()
},
didMigrate: { context in
let items = try context.fetch(FetchDescriptor<AppSchemaV2.Item>())
for item in items {
item.priority = 0
item.tags = []
}
try context.save()
}
)
}
This is a reviewed template, not output from a measured run. Adapt the @Model types, module name, and scheme to your own project, verify initializer labels against the SwiftData reference for your Xcode version, and compile it locally before wiring it into CI.
import XCTest
import SwiftData
@testable import MyApp
final class SwiftDataMigrationTests: XCTestCase {
private var storeURL: URL!
override func setUpWithError() throws {
let dir = FileManager.default.temporaryDirectory
storeURL = dir.appendingPathComponent("migration_test_\(UUID().uuidString).store")
let v1Schema = Schema([AppSchemaV1.Item.self])
let v1Config = ModelConfiguration(schema: v1Schema, url: storeURL)
let v1Container = try ModelContainer(for: v1Schema, configurations: [v1Config])
let ctx = ModelContext(v1Container)
for i in 1...10 {
ctx.insert(AppSchemaV1.Item(title: "Item \(i)", createdAt: .now))
}
try ctx.save()
}
override func tearDownWithError() throws {
guard let url = storeURL else { return }
try? FileManager.default.removeItem(at: url)
storeURL = nil
}
private func openV2() throws -> ModelContext {
let container = try ModelContainer(
for: AppSchemaV2.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(url: storeURL)
)
return ModelContext(container)
}
func testMigrationPreservesItemCount() throws {
let ctx = try openV2()
let items = try ctx.fetch(FetchDescriptor<AppSchemaV2.Item>())
XCTAssertEqual(items.count, 10, "Migration must preserve all 10 items")
}
func testMigrationSetsPriorityDefault() throws {
let ctx = try openV2()
let items = try ctx.fetch(FetchDescriptor<AppSchemaV2.Item>())
XCTAssertTrue(
items.allSatisfy { $0.priority == 0 },
"Migrated items should have priority 0 until the user updates them"
)
}
func testMigrationPreservesTitlesAndDates() throws {
let ctx = try openV2()
var descriptor = FetchDescriptor<AppSchemaV2.Item>(
sortBy: [SortDescriptor(\AppSchemaV2.Item.createdAt)]
)
descriptor.fetchLimit = 10
let items = try ctx.fetch(descriptor)
XCTAssertEqual(items.count, 10)
XCTAssertNotNil(items.first?.createdAt)
XCTAssertTrue(items.allSatisfy { $0.title.hasPrefix("Item ") })
}
}
Run the suite against a simulator that matches the Xcode installed on your remote Mac. Replace MyApp.xcodeproj, MyApp, and the test target name with your own project's values, and pick a simulator name that xcrun simctl list devices actually returns:
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:MyAppTests/SwiftDataMigrationTests \
-resultBundlePath ./MigrationTestResults.xcresult
xcodebuild test returns non-zero on any test failure, so your CI job fails the pipeline — a failed build is never a passing migration result.
Before writing any willMigrate code, check whether the change qualifies for a lightweight stage.
Lightweight fits adding a new optional attribute (SwiftData sets it to nil), renaming a property with @Attribute(.originalName("oldName")), or changing a relationship's delete rule.
Custom is required for making an attribute unique, splitting one attribute into two (fullName → firstName + lastName), changing a property's type (e.g., String to Date), or populating a new required attribute from a computed value.
The order of stages in SchemaMigrationPlan.stages matters: SwiftData walks them sequentially. If you release v1 → v2 as lightweight and v2 → v3 as custom, both must appear in stages in that order.
Renamed attribute drops data. Renaming without @Attribute(.originalName(...)) is treated as delete-plus-add, dropping the original values. Add originalName before shipping or write a custom stage that copies the data.
Dropped relationship leaves orphans. Removing a relationship without a delete rule can leave related records behind. Set .cascade or .nullify on the parent side in the schema version before the one that removes the relationship.
Type change without a transform throws. Changing var score: String to var score: Double without a custom willMigrate closure produces a runtime migration error. Transform the string values to doubles inside willMigrate before the new schema applies.
Missing save in willMigrate. The closure receives a ModelContext; call try context.save() before it returns or SwiftData discards your changes.
Write one XCTest for each failure mode above against a realistic seeded store. Those four tests pay for themselves the first time a reviewer renames a field in a hotfix branch.
If your target simulator runtime still supports Intel in your Xcode version, the $29/month virtual Intel macOS plan runs xcodebuild test for the migration suite, builds Swift packages, and hosts a CI agent that calls out to your service. It is a macOS environment running under Linux/KVM with browser-console access to the desktop; outbound network from the VM is available, inbound SSH is not. There is no GPU or audio, and ARM-only simulator slices will not execute.
For the newest iOS SDK simulator runtimes, Xcode 27, visionOS, or any ARM-only execution, request an M-series Mac from $95/month. If you also want CI setup, backups, monitoring, and 30 minutes of monthly support bundled on one M-series Mac, the Managed Build Mac is $249/month. Physical M-series availability is request-based right now, so include your preferred Xcode version, macOS version, and timing in the quote.
Apply at Figment Mac Hosting or start a configuration request.
Related reading: