Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Test SwiftData Schema Migrations on a Remote Mac

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.

How SwiftData Versioned Schemas Work

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:

StageUse 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).

Defining Two Schema Versions

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()
        }
    )
}

The XCTest Migration Template: Seed, Migrate, Assert

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.

Lightweight vs Custom: Choosing the Right Stage

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.

Common Migration Failures to Catch in CI

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.

Which Figment Plan to Pick for This Work

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:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms