Weather.Co is now free for every pilot — always free for CFIs and flight schools.
Lab · Guides · SwiftData migrations
Lab guide · SwiftData

SwiftData migrations.

VersionedSchema, MigrationPlan, and the one-line mistake that quietly nukes existing user data on upgrade. The migration patterns I've shipped in three apps and would now use from day one.

SwiftData, Apple's modern persistence framework, has a clean migration story when you set it up right and a confusing one when you don't. This guide is the right way, plus the gotchas I've hit shipping persistent SwiftData apps (Outpost.Co, Upsee, Test112).

Before you start
  • iOS 17+ (SwiftData minimum) or iOS 18+ for the better migration ergonomics.
  • A SwiftData model you've already shipped to users — and you need to change it.
  • Comfort with Swift's typealias, generics, and @Model.

Step 1The "from day one" pattern

If you're starting fresh: don't ship a raw @Model as your schema. Wrap it in a VersionedSchema from v1. Future-you will thank you.

enum MySchemaV1: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)
  static var models: [any PersistentModel.Type] {
    [Trip.self, PackingItem.self]
  }

  @Model
  final class Trip {
    var name: String
    var startDate: Date
    init(name: String, startDate: Date) {
      self.name = name
      self.startDate = startDate
    }
  }
}

Then add a type alias at the top of the app target so your views and view models reference Trip, not MySchemaV1.Trip:

typealias Trip = MySchemaV1.Trip
typealias PackingItem = MySchemaV1.PackingItem

Step 2Set up the container with a MigrationPlan

enum MyMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] {
    [MySchemaV1.self]   // add V2 here later
  }
  static var stages: [MigrationStage] {
    []                       // no migrations yet
  }
}

@main
struct OutpostApp: App {
  var body: some Scene {
    WindowGroup { RootView() }
      .modelContainer(
        for: [Trip.self, PackingItem.self],
        migrationPlan: MyMigrationPlan.self
      )
  }
}

Step 3Now add v2 — the lightweight case

You need to add a nullable property to Trip. SwiftData can do this as a "lightweight" migration — no data transformation needed.

enum MySchemaV2: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)
  static var models: [any PersistentModel.Type] {
    [Trip.self, PackingItem.self]
  }

  @Model
  final class Trip {
    var name: String
    var startDate: Date
    var notes: String?       // NEW — nullable, lightweight
    init(name: String, startDate: Date, notes: String? = nil) {
      self.name = name; self.startDate = startDate; self.notes = notes
    }
  }
}

Update the typealias to point at V2: typealias Trip = MySchemaV2.Trip. Add V2 to the migration plan:

enum MyMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] {
    [MySchemaV1.self, MySchemaV2.self]
  }
  static var stages: [MigrationStage] {
    [.lightweight(fromVersion: MySchemaV1.self, toVersion: MySchemaV2.self)]
  }
}

Step 4v3 — the custom (data-transforming) case

You need to split name into title and subtitle. SwiftData can't infer that — you write a custom stage.

.custom(
  fromVersion: MySchemaV2.self,
  toVersion: MySchemaV3.self,
  willMigrate: { context in
    let trips = try context.fetch(FetchDescriptor<MySchemaV2.Trip>())
    for trip in trips {
      let parts = trip.name.split(separator: ":", maxSplits: 1)
      trip.name = String(parts[0])
      // stash subtitle in a transient — picked up by didMigrate
    }
    try context.save()
  },
  didMigrate: { context in
    // now operating on V3 models — set subtitle if you stashed it
  }
)
The colliding-hash gotcha

SwiftData identifies schema versions by a hash of the model, not just the version number you declare. If you change V2 in-place (say, you added notes but haven't shipped V2 yet, then you also rename a different property), the hash changes — but the version number doesn't. SwiftData thinks the store is at a version it has never seen and may discard the existing data.

The rule: once a VersionedSchema is shipped to users, don't touch it again. Any change becomes a new Vn+1. This is the single most common cause of "users updated the app and lost all their data."

Step 5Verify before shipping

The only way to verify a migration works on real production data is to actually run it on real production data. Steps:

Tip — ship a backup before migrating

For data you really can't lose, copy the store file to a backup location before kicking off the migration. If it goes wrong, you can roll back. The Outpost.Co migrate-to-V6 happened with backup-before-migrate as a one-time guard.


The summary: ship VersionedSchema from v1, never mutate a shipped schema in place, prefer lightweight migrations when you can, write custom stages when you can't, and always test the migration on real prior-version data before shipping. SwiftData's migration story is good — but only when you build the version scaffolding from day one.

Sources: Apple Developer — SwiftData, VersionedSchema, SchemaMigrationPlan; ~/.claude/projects/-Users-ethankuhlman/memory/project_outpost.md migration notes. Tested on iOS 17–18, Outpost.Co CloudKit schema V1–V6.