A Deep Dive into SwiftData migrations

Published on: January 19, 2026

SwiftData migrations are one of those things that feel optional… right until you ship an update and real users upgrade with real data on disk.

In this post we’ll dig into:

  • How to implement schema versions with VersionedSchema
  • When you should introduce new schema versions
  • When SwiftData can migrate automatically and when you’ll have to write manual migrations with SchemaMigrationPlan and MigrationStage
  • How to handle extra complex migrations where you need “bridge” versions

By the end of this post you should have a pretty solid understanding of SwiftData’s migration rules, possibilities, and limitations. More importantly: you’ll know how to keep your migration work proportional. Not every change needs a custom migration stage, but some changes absolutely do.

Implementing simple versions with VersionedSchema

Every data model should have at least one VersionedSchema. What I mean by that is that even if you haven’t introduced any model updates yet, your initial model should be shipped using a VersionedSchema.

That gives you a stable starting point. Introducing VersionedSchema after you’ve already shipped is possible, but there's some risk involved with not getting things right from the start.

In this section, I’ll show you how to define an initial schema, how you can reference “current” models cleanly, and when you should introduce new versions.

Defining your initial model schema

If you’ve never worked with versioned SwiftData models before, the nested types that you'll see in a moment can look a little odd at first. The idea is simple though:

  • Each schema version defines its own set of @Model types, and those types are namespaced to that schema (for example ExerciseSchemaV1.Exercise).
  • Your app code typically wants to work with “the current” models without spelling SchemaV5.Exercise everywhere.
  • A typealias lets you keep your call sites clean while still being explicit about which schema version you’re using.

One very practical consequence of this is that you’ll often end up with two kinds of “models” in your codebase:

  • Versioned models: ExerciseSchemaV1.Exercise, ExerciseSchemaV2.Exercise, etc. These exist so SwiftData can reason about schema evolution.
  • Current models: typealias Exercise = ExerciseSchemaV2.Exercise. These exist so the rest of your app stays readable and you don't need to refactor half your code when you introduce a new schema version.

Every model schema that you define will conform to the VersionedSchema protocol and contain the following two fields:

  • versionIdentifier a semantic versioning identifier for your schema
  • models a list of model objects that are part of this schema

A minimal V1 → V2 example

To illustrate a simple VersionedSchema definition, we'll use a tiny Exercise model as our V1.

In V2 we’ll add a notes field. This kind of change is pretty common in my experience and it's a good example of a so-called lightweight migration because existing rows can simply have their notes set to nil.

import SwiftData

enum ExerciseSchemaV1: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String

    init(name: String) {
      self.name = name
    }
  }
}

enum ExerciseSchemaV2: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String
    var notes: String?

    init(name: String, notes: String? = nil) {
      self.name = name
      self.notes = notes
    }
  }
}

In the rest of your app, you’ll usually want to work with the latest schema’s model types:

typealias Exercise = ExerciseSchemaV2.Exercise

That way you can write Exercise(...) instead of ExerciseSchemaV2.Exercise(...).

Knowing when to introduce new VersionedSchemas

Personally, I only introduce a new version when I make model changes in between App Store releases. For example, I'll ship my app v1.0 with model v1.0. When I want to make any number of model changes in my app version 1.1, I will introduce a new model version too. Usually I'll name the model version 2.0 since that just makes sense to me. Even if I end up making loads of changes in separate steps, I rarely create more than one model version for a single app update. As we'll see in the complex migrations sections there might be exceptions if I need a multi-stage migration but those are very rare.

So, introduce a new VersionedSchema when you make model changes after you've already shipped a model version.

One thing that you'll want to keep in mind is that users can have different migration paths. Some users will update to every single model you release, others will skip versions.

SwiftData handles these migrations out of the box so you don't have to worry about them which is great. It's still good to be aware of this though. Your model should be able to migrate from any old version to any new version.

Often, SwiftData will figure out the migration path on its own, let's see how that works next.

Automatic migration rules

When you define all of your versioned schemas correctly, SwiftData can easily migrate your data from one version to another. Sometimes, you might want to help SwiftData out by providing a migration plan. I typically only do this for my custom migrations but it's possible to optimize your migration paths by providing migration plans for lightweight migrations too.

What “automatic migration” means in SwiftData

SwiftData can infer certain schema changes and migrate your store without any custom logic. In a migration plan, this is represented as a lightweight stage.

One nuance that’s worth calling out: SwiftData can perform lightweight migrations without you writing a SchemaMigrationPlan at all. But once you do adopt versioned schemas and you want predictable, testable upgrades between shipped versions, explicitly defining stages is the most straightforward way to make your intent unambiguous.

I recommend going for both approaches (with and without plans) at least once so you can experience them and you can decide what works best for you. When in doubt, it never hurts to build migration plans for lightweight migrations even if it's not strictly needed.

Let's see how you would define a migration plan for your data store, and how you can use your migration plan.

enum AppMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] = [ExerciseSchemaV1.self, ExerciseSchemaV2.self]
  static var stages: [MigrationStage] = [v1ToV2]

  static let v1ToV2 = MigrationStage.lightweight(
    fromVersion: ExerciseSchemaV1.self,
    toVersion: ExerciseSchemaV2.self
  )
}

In this migration plan, we've defined our model versions, and we've created a lightweight migration stage to go from our v1 to our v2 models. Note that we technically didn't have to build this migration plan because we're doing lightweight migrations only, but for completeness sake you can make sure you define migration steps for every model change.

When you create your container, you can tell it to use your plan as follows:

typealias Exercise = ExerciseSchemaV2.Exercise

let container = try ModelContainer(
  for: Exercise.self,
  migrationPlan: AppMigrationPlan.self
)

Knowing when a lightweight migration can be used

The following changes are lightweight changes and don't require any custom logic:

  • Add an optional property (like notes: String?)
  • Remove a property (data is dropped)
  • Make a property optional (non-optional → optional)
  • Rename a property if you map the original stored name

These changes don’t require SwiftData to invent new values. It can either keep the old value, move it, or accept a nil where no value existed before.

Safely renaming values

When you rename a model property, the store still contains the old attribute name. Use @Attribute(originalName:) so SwiftData can convert from old property names to new ones.

@Model
final class Exercise {
  @Attribute(originalName: "name")
  var title: String

  init(title: String) {
    self.title = title
  }
}

When you should not rely on lightweight migration

Lightweight migrations break down when your new schema introduces a new requirement that old data can't satisfy. Or in other words, if SwiftData can't automatically determine how to move from the old model to the new one.

Some examples of model changes that will require a heavy migration are:

  • Adding non-optional properties without a default value
  • Any change that requires a transformation step:
    • parsing / composing values
    • merging or splitting entities
    • changing a value's type
    • data cleanup (dedupe, normalizing strings, fixing invalid states)

If you're making a change that SwiftData can't migrate on its own, you're in manual migration land and you'll want to pay close attention to this section.

A quick note on “defaults”

You’ll sometimes see advice like “just add a default value and you’re fine”. That can be true, but there’s a subtle trap: a default value in your Swift initializer does not necessarily mean existing rows get a value during migration.

If you’re introducing a required field, assume you need to explicitly backfill it unless you’ve tested the migration from a real on-disk store. That's where manual migrations become important.

Performing manual migrations using a migration plan

As you've seen before, a migration plan allows you to describe how you can migrate from one model version to the next. Our example from before leveraged a lightweight migration. We're going to set up a custom migration for this section.

We'll walk through a couple of scenarios with increasing complexity so you can ease into harder migration paths without being overwhelmed.

Assigning defaults for new, non-optional properties

Scenario: you add a new required field like createdAt: Date to an existing model. Existing rows don’t have a value for it. To migrate this, we have two options

  • Option A: make the property optional and accept “unknown”. This would allow us to use a lightweight migration but we might have nil values for createdAt
  • Option B: write a manual migration and keep the property as non-optional

Option B is the cleaner option since it allows us to have a more robust data model. Here’s what this looks like when you actually wire it up. First, define schemas where V2 introduces our createdAt property:

import SwiftData

enum ExerciseCreatedAtSchemaV1: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String

    init(name: String) {
      self.name = name
    }
  }
}

enum ExerciseCreatedAtSchemaV2: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String
    var createdAt: Date

    init(name: String, createdAt: Date = .now) {
      self.name = name
      self.createdAt = createdAt
    }
  }
}

Next we can add a custom stage that sets createdAt for existing rows. We'll talk about what the willMigrate and didMigrate closure are in a moment; let's look at the migration logic first:

enum AppMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] = [ExerciseCreatedAtSchemaV1.self, ExerciseCreatedAtSchemaV2.self]
  static var stages: [MigrationStage] = [v1ToV2]

  static let v1ToV2 = MigrationStage.custom(
    fromVersion: ExerciseCreatedAtSchemaV1.self,
    toVersion: ExerciseCreatedAtSchemaV2.self,
    willMigrate: { _ in },
    didMigrate: { context in
      let exercises = try context.fetch(FetchDescriptor<ExerciseCreatedAtSchemaV2.Exercise>())
      for exercise in exercises {
        exercise.createdAt = Date()
      }
      try context.save()
    }
  )
}

With this change, we can assign a sensible default to createdAt. As you saw we have two migration stages; willMigrate and didMigrate. Let's see what those are about next.

Taking a closer look at complex migration stages

willMigrate

willMigrate is run before your schema is applied and should be used to clean up your "old" (existing) data if needed. For example, if you're introducing unique constraints you can remove duplicates from your original store in willMigrate. Note that willMigrate only has access to your old data store (the "from" model). So you can't assign any values to your new models in this step. You can only clean up old data here.

didMigrate

After applying your new schema, didMigrate is called. You can assign your required values here. At this point you only have access to your new model versions.

I’ve found that I typically do most of my work in didMigrate, because I'm able to assign data there; I don't often have to prepare my old data for migration.

Setting up extra complex migrations

Sometimes you'll have to do migrations that reshape your data. A common case is introducing a new model where one of the new model’s fields is composed from values that used to be stored somewhere else.

To make this concrete, imagine you started with a model that stores “summary” workout data in a single model:

import SwiftData

enum WeightSchemaV1: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)
  static var models: [any PersistentModel.Type] = [WeightData.self]

  @Model
  final class WeightData {
    var weight: Float
    var reps: Int
    var sets: Int

    init(weight: Float, reps: Int, sets: Int) {
      self.weight = weight
      self.reps = reps
      self.sets = sets
    }
  }
}

Now you want to introduce PerformedSet, and have WeightData contain a list of performed sets instead. You could try to remove weight/reps/sets from WeightData in the same version where you add PerformedSet, but that makes migration unnecessarily hard: you still need the original values to create your first PerformedSet.

The reliable approach here is the same bridge-version strategy we used earlier:

  • V2 (bridge): keep the old fields around under legacy names, and add the relationship
  • V3 (cleanup): remove the legacy fields once the new data is populated

Here’s what the bridge schema could look like. Notice how the legacy values are kept around with @Attribute(originalName:) so they still read from the same stored columns:

enum WeightSchemaV2: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)
  static var models: [any PersistentModel.Type] = [WeightData.self, PerformedSet.self]

  @Model
  final class WeightData {
    @Attribute(originalName: "weight")
    var legacyWeight: Float

    @Attribute(originalName: "reps")
    var legacyReps: Int

    @Attribute(originalName: "sets")
    var legacySets: Int

    @Relationship(inverse: \WeightSchemaV2.PerformedSet.weightData)
    var performedSets: [PerformedSet] = []

    init(legacyWeight: Float, legacyReps: Int, legacySets: Int) {
      self.legacyWeight = legacyWeight
      self.legacyReps = legacyReps
      self.legacySets = legacySets
    }
  }

  @Model
  final class PerformedSet {
    var weight: Float
    var reps: Int
    var sets: Int

    var weightData: WeightData?

    init(weight: Float, reps: Int, sets: Int, weightData: WeightData? = nil) {
      self.weight = weight
      self.reps = reps
      self.sets = sets
      self.weightData = weightData
    }
  }
}

Now you can migrate by fetching WeightSchemaV2.WeightData in didMigrate and inserting a PerformedSet for each migrated WeightData:

static let migrateV1toV2 = MigrationStage.custom(
  fromVersion: WeightSchemaV1.self,
  toVersion: WeightSchemaV2.self,
  willMigrate: nil,
  didMigrate: { context in
    let allWeightData = try context.fetch(FetchDescriptor<WeightSchemaV2.WeightData>())

    for weightData in allWeightData {
      let performedSet = WeightSchemaV2.PerformedSet(
        weight: weightData.legacyWeight,
        reps: weightData.legacyReps,
        sets: weightData.legacySets,
        weightData: weightData
      )

      weightData.performedSets.append(performedSet)
    }

    try context.save()
  }
)

Once you’ve shipped this and you’re confident the data is in the new shape, you can introduce V3 to remove legacyWeight, legacyReps, and legacySets entirely. Because the data now lives in PerformedSet, V2 → V3 is typically a lightweight migration.

When you find yourself having to perform a migration like this, it can be quite scary and complex so I highly recommend properly testing your app before shipping. Try testing migrations from and to different model versions to make sure that you don't lose any data.

Summary

SwiftData migrations become a lot less stressful when you treat schema versions as a release artifact. Introduce a new VersionedSchema when you ship model changes to users, not for every little iteration you do during development. That keeps your migration story realistic, testable, and manageable over time.

When you do ship a change, start by asking whether SwiftData can reasonably infer what to do. Lightweight migrations work well when no new requirements are introduced: adding optional fields, dropping fields, or renaming fields (as long as you map the original stored name). The moment your change requires SwiftData to invent or derive a value—like introducing a new non-optional property, changing types, or composing values—you’re in manual migration land, and a SchemaMigrationPlan with custom stages is the right tool.

For the truly tricky cases, don’t force everything into one heroic migration. Add a bridge version, populate the new data shape first, then clean up old fields in a follow-up version. And whatever you do, test migrations the way users experience them: migrate a store created by an older build with messy data, not a pristine simulator database you can delete at will.

Categories

SwiftData

Expand your learning with my books

Practical Combine header image

Learn everything you need to know about Combine and how you can use it in your projects with Practical Combine. It contains:

  • Thirteen chapters worth of content.
  • Playgrounds and sample projects that use the code shown in the chapters.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more