cr8rcho
Tutorial

A Practical Guide to SwiftData VersionedSchema and Lightweight Migration

Feb 14
7 min

Introduction

After shipping an app with SwiftData, there comes a time when you need to add fields to your models. Migration in the Core Data era was quite complex, but SwiftData's VersionedSchema and SchemaMigrationPlan make it much cleaner.

This post covers the practical process of SwiftData migration, based on my experience extending the schema to support debit cards in a personal finance app.

The Starting Point: SchemaV1

The initial schema was defined using VersionedSchema.

struct iobookSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Asset.self, AssetGroup.self, Transaction.self, ...]
    }
    static var versionIdentifier: Schema.Version = .init(1, 0, 0)
}

extension iobookSchemaV1 {
    @Model
    class Asset {
        var isCreditCard: Bool?
        var paymentAsset: Asset?
        // ... other fields omitted
    }

    @Model
    class Transaction {
        var asset: Asset?
        // ... other fields omitted
    }
}

Instead of referencing models directly throughout the app, I use typealias. This becomes crucial during migration.

typealias Asset = iobookSchemaV1.Asset
typealias Transaction = iobookSchemaV1.Transaction

When Change Is Needed

To support debit cards, two new fields were required:

  • isDebitCard: Bool? on Asset — to identify debit card assets
  • debitCard: Asset? on Transaction — to track which debit card was used for a transaction

Both are optional field additions, so lightweight migration is sufficient.

Step 1: Create SchemaV2

Copy the V1 file to create V2. Never modify V1. SwiftData compares V1 and V2 to determine the differences during migration.

struct iobookSchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Asset.self, AssetGroup.self, Transaction.self, ...]
    }
    static var versionIdentifier: Schema.Version = .init(2, 0, 0)
}

extension iobookSchemaV2 {
    @Model
    class Asset {
        // ... all existing V1 fields retained ...

        // Newly added
        var isDebitCard: Bool?

        @Relationship(inverse: \Transaction.debitCard)
        var debitCardTransactions: [Transaction]?
    }

    @Model
    class Transaction {
        // ... all existing V1 fields retained ...

        // Newly added
        var debitCard: Asset?
    }
}

Key points:

  • All model classes from V1 must be redefined in V2 (even unchanged ones)
  • New fields must have default values (lightweight migration requirement)
  • @Relationship inverse must match exactly

Step 2: Write the Migration Plan

enum iobookMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [iobookSchemaV1.self, iobookSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: iobookSchemaV1.self,
        toVersion: iobookSchemaV2.self
    )
}

Lightweight migration supports only these changes:

  • Adding optional fields (with defaults)
  • Removing fields
  • Adding optional relationships
  • Adding/removing indexes

For field renames, type changes, or adding non-optional fields, you'll need MigrationStage.custom.

Step 3: Update typealias and ModelContainer

// Change V1 → V2
typealias Asset = iobookSchemaV2.Asset
typealias Transaction = iobookSchemaV2.Transaction

// Add migrationPlan to ModelContainer
var modelContainer: ModelContainer = {
    let schema = Schema([Asset.self, ...])
    let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

    do {
        return try ModelContainer(
            for: schema,
            migrationPlan: iobookMigrationPlan.self,  // Add this line
            configurations: [config]
        )
    } catch {
        fatalError("Could not create ModelContainer: \(error)")
    }
}()

Thanks to typealias, none of the app code that references Asset or Transaction needs to change. Just point the typealias to the V2 classes.

Step 4: Verification

  1. Test with existing data: Run the app on a simulator with V1 data → confirm no crashes
  2. Check new fields: Verify existing Asset's isDebitCard is nil (or false) and Transaction's debitCard is nil
  3. Test new features: Create a debit card and register transactions, verify data integrity

Common Pitfalls

1. Modifying the V1 file

V1 is a "historical snapshot." If you modify it, SwiftData will think the actual store schema doesn't match V1, and migration will fail.

2. Not defining all models in V2

Even unchanged models like AssetGroup or ExpenseCategory must be included in V2. Missing models from the VersionedSchema's models array will cause runtime errors.

3. Forgetting to include the stage in the stages array

Declaring migrateV1toV2 but not including it in the stages array means migration won't execute.

// Wrong
static var stages: [MigrationStage] { [] }  // Empty!

// Correct
static var stages: [MigrationStage] { [migrateV1toV2] }

4. Mismatched @Relationship inverse

If the inverse doesn't match exactly when adding a new relationship, it will compile fine but can corrupt data at runtime.

Why I Recommend the typealias Pattern

Starting with the VersionedSchema + typealias pattern from day one pays off:

  • Minimal app code changes during migration
  • Single place to manage which schema version is active
  • Same pattern scales to V3, V4, and beyond

It's similar to how Core Data managed versions in .xcdatamodeld files, but with code it's far more transparent.

Wrapping Up

SwiftData migration is decidedly simpler than Core Data. Use VersionedSchema for schema versioning, SchemaMigrationPlan for defining migration steps, and fall back to custom stages when lightweight won't cut it.

The essentials:

  • Never touch V1
  • Define all models in V2
  • Keep it lightweight with optional fields + defaults
  • Use typealias to minimize app code impact