Data Persistence with SwiftData

Mar 19 2025 · Swift 5.10, iOS 17, ipadOS 17, macOS 15, visionOS 1.2, Xcode 15

Lesson 05: SwiftData, Migrations & Working with Core Data

SwiftData Migrations Demo

Episode complete

Play next episode

Next
Transcript

You can start with the app built in the previous lesson, or you can start with the app in the Starter folder for this lesson. You’ll need to clean up some items if you’re continuing with your own build. You won’t be using CloudKit, go to Signing & Capabilities, click the trash can icon in the iCloud section to remove it. Next, click the trash can icon in the Background Modes. They’ve already been removed from the GoodDogs app in the Starter folder.

The mock data in the DogModel extension has been simplified for this lesson. The image data has been set to nil, and parks and breed inserts are slightly different. The creation of records has been covered in previous lessons. You’ll be focusing on learning migration stages and migration plans. Update your own DogModel’s extension to match.

let labrador = BreedModel(name: "Labrador Retriever")
let golden = BreedModel(name: "Golden Retriever")
let bouvier = BreedModel(name: "Bouvier")
let mixed = BreedModel(name: "Mixed")

let riverdale = ParkModel(name: "Riverdale Park")
let withrow = ParkModel(name: "Withrow Park")
let greenwood = ParkModel(name: "Greewood Park")
let hideaway = ParkModel(name: "Hideaway Park")
let kewBeach = ParkModel(name: "Kew Beach Off Leash Dog Park")
let allan = ParkModel(name: "Allan Gardens")

let macDog = DogModel(
  name: "Mac",
  age: 11,
  weight: 90,
  color: "Yellow",
  image: nil
)
let sorcha = DogModel(
  name: "Sorcha",
  age: 1,
  weight: 40,
  color: "Yellow",
  image: nil
)
let violet = DogModel(
  name: "Violet",
  age: 4,
  weight: 85,
  color: "Gray",
  image: nil
)
let kirby = DogModel(
  name: "Kirby",
  age: 11,
  weight: 95,
  color: "Fox Red",
  image: nil
)
let priscilla = DogModel(
  name: "Priscilla",
  age: 17,
  weight: 65,
  color: "White",
  image: nil
)

container.mainContext.insert(macDog)
macDog.breed = labrador
macDog.parks = [riverdale, withrow, kewBeach]
container.mainContext.insert(sorcha)
sorcha.breed = golden
sorcha.parks = [greenwood, withrow]
container.mainContext.insert(violet)
violet.breed = bouvier
violet.parks = [riverdale, withrow, hideaway]
container.mainContext.insert(kirby)
kirby.breed = labrador
kirby.parks = [allan, greenwood, kewBeach]
container.mainContext.insert(priscilla)
priscilla.breed = mixed

Setting Up Version 1.0.0

The first thing to do is to create the initial version of the VersionedSchema. Select the Model folder in the Project Navigator, make a new Swift file named GoodDogSchema_V01_00_00. At the top of the file, import SwiftData and add a MARK for version 1.0.0.

// MARK: - Version 1.0.0

That will make it easier to find the code that follows.

Add an enum named GoodDogSchema_V01_00_00 of type VersionedSchema. Add a static var versionIdentifier inside the enum and assign it Schema.Version(1, 0, 0). Follow that with a static var models and add an array with all of your three models:

enum GoodDogSchema_V01_00_00: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)

  static var models: [any PersistentModel.Type] {
    [DogModel.self,
    BreedModel.self,
    ParkModel.self]
  }

  // ... you'll paste the DogModel here.
}

// paste the DogModel extension here

Switch over to the DogModel.swift, select all of the model code. Cut and paste it inside the GoodDogSchema_V01_00_00 enum. Ignore the warnings for now, you’ll fix those soon.

@Model
class DogModel {
  var name: String = ""
  var age: Int?
  var weight: Int?
  var color: String?
  var breed: BreedModel?
  @Attribute(.externalStorage) var image: Data?
  var parks: [ParkModel]?

  init(
    name: String,
    age: Int = 0,
    weight: Int = 0,
    color: String? = nil,
    breed: BreedModel? = nil,
    image: Data? = nil,
    parks: [ParkModel]? = nil
  ) {
    self.name = name
    self.age = age
    self.weight = weight
    self.color = color
    self.breed = breed
    self.image = image
    self.parks = parks
  }
}

Next, cut the DogModel extension with the mock preview data objects. Paste the extension after the enum at the end of the GoodDogSchema_V01_00_00 file.

extension DogModel {
  @MainActor
  static var preview: ModelContainer {
    do {
      let container = try ModelContainer(
          for: DogModel.self,
          configurations: ModelConfiguration(
            isStoredInMemoryOnly: true)
        )

      let labrador = BreedModel(name: "Labrador Retriever")
      let golden = BreedModel(name: "Golden Retriever")
      let bouvier = BreedModel(name: "Bouvier")
      let mixed = BreedModel(name: "Mixed")

      let riverdale = ParkModel(name: "Riverdale Park")
      let withrow = ParkModel(name: "Withrow Park")
      let greenwood = ParkModel(name: "Greenwood Park")
      let hideaway = ParkModel(name: "Hideaway Park")
      let kewBeach = ParkModel(name: "Kew Beach Off Leash Dog Park")
      let allan = ParkModel(name: "Allan Gardens")

  let macDog = DogModel(
    name: "Mac",
    age: 11,
    weight: 90,
    color: "Yellow",
    image: nil
  )
  let sorcha = DogModel(
    name: "Sorcha",
    age: 1,
    weight: 40,
    color: "Yellow",
    image: nil
  )
  let violet = DogModel(
    name: "Violet",
    age: 4,
    weight: 85,
    color: "Gray",
    image: nil
  )
  let kirby = DogModel(
    name: "Kirby",
    age: 11,
    weight: 95,
    color: "Fox Red",
    image: nil
  )
  let priscilla = DogModel(
    name: "Priscilla",
    age: 17,
    weight: 65,
    color: "White",
    image: nil
  )

      container.mainContext.insert(macDog)
      macDog.breed = labrador
      macDog.parks = [riverdale, withrow, kewBeach]
      container.mainContext.insert(sorcha)
      sorcha.breed = golden
      sorcha.parks = [greenwood, withrow]
      container.mainContext.insert(violet)
      violet.breed = bouvier
      violet.parks = [riverdale, withrow, hideaway]
      container.mainContext.insert(kirby)
      kirby.breed = labrador
      kirby.parks = [allan, greenwood, kewBeach]
      container.mainContext.insert(priscilla)
      priscilla.breed = mixed

      return container
    } catch {
      print("Fatal Error: Could not create preview modelContainer.")
      // Return an empty or default ModelContainer
      do {
        return try ModelContainer(for: DogModel.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true))
      } catch {
        fatalError("Failed to create fallback ModelContainer.")
      }
    }
  }
}

Rename the extension GoodDogSchema_V01_00_00.DogModel. Add a MARK here as well:

// MARK: - Version 1.0.0 extension
extension GoodDogSchema_V01_00_00.DogModel {
  // ... preview code is here.
}

TypeAlias to the Rescue

Now you’ll fix some errors. Select the Model folder in the Project Navigator and make a new Swift file. Name the file GoodDogMigrationPlan. At the top of the file, import SwiftData. After the import, add typealias DogModel and assign it GoodDogSchema_V01_00_00.DogModel. Add another MARK. This is to solve some of the compiler errors.

// MARK: - MODEL TYPE ALIASES
typealias DogModel = GoodDogSchema_V01_00_00.DogModel

This TypeAlias will solve the errors that say Cannot find 'DogModel' in scope.

Head over to the BreedModel and ParkModel files. Cut the model code and paste it into the GoodDogSchema_V01_00_00 enum:

// ... DogModel

@Model
class BreedModel {
  var name: String = "Unknown Breed"
  var dogs: [DogModel]?

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

@Model
class ParkModel {
  var name: String = ""
  var dogs: [DogModel]?

  init(name: String, dogs: [DogModel]? = nil) {
    self.name = name
    self.dogs = dogs
  }
}

Add two more TypeAliases for each of the BreedModel and ParkModel so the compiler can resolve those models.

typealias BreedModel = GoodDogSchema_V01_00_00.BreedModel
typealias ParkModel = GoodDogSchema_V01_00_00.ParkModel

All of the errors about missing models should be gone. Press Command-B and check for errors. At this point, you’ve created the initial VersionedSchema. The app still works because of the DogModel TypeAlias. To be sure you’ll use the new schema in the app, switch to the GoodDogApp.swift and change the schema from DogModel.self to GoodDogSchema_V01_00_00.DogModel.self, where the schema is defined in the container variable:

let schema = Schema(GoodDogSchema_V01_00_00.DogModel.self)

Test the Canvas previews. You can also build and run in the Simulator. Now, you’ll set up the next version.

Completing the Migration Plan

You’ll now update the app by adding a city to the ParkModel. This is going to be a lightweight migration. Some people, however, may choose to skip this update in production. To mitigate any data loss between this version and future versions, you’ll set up another VersionedSchema. Select the version 1.0.0 GoodDogSchema_V01_00_00 file in the Project Navigator. From the File menu, choose Duplicate. Name the new file GoodDogSchema_V01_01_00.swift.

Go through the file and change the versions from version 1.0.0 to version 1.1.0.

// MARK: - Version 1.1.0
enum GoodDogSchema_V01_01_00: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 1, 0)

  // ...
}

Also, increment the versions in the extension for the mock data preview.

// MARK: - Version 1.1.0 extension
extension GoodDogSchema_V01_01_00.DogModel {
  @MainActor
  // ... the preview data

}

It’s time to set up the migration plan’s schemas and stages. Select the GoodDogMigrationPlan.swift and add an enum named GoodDogMigrationPlan. Set its type to SchemaMigrationPlan.

enum GoodDogMigrationPlan: SchemaMigrationPlan {

}

Tap the Fixit that offers to add the required stubs.

static var schemas: [any VersionedSchema.Type]

static var stages: [MigrationStage]

Add curly braces to add values. In the schemas, add the two versioned schemas you’ve made in an array.

static var schemas: [any VersionedSchema.Type] {
  [
    GoodDogSchema_V01_00_00.self,
    GoodDogSchema_V01_01_00.self
  ]
}

Between the new variables, add a let constant called migration_V1_0_0_to_V1_1_0 of type MigrationStage.lightweight(fromVersion:toVersion:). In the fromVersion set the value to GoodDogSchema_V01_00_00.self. Set the toVersion value to GoodDogSchema_V01_01_00.self.

static let migration_V1_0_0_to_V1_1_0 = MigrationStage.lightweight(
  fromVersion: GoodDogSchema_V01_00_00.self,
  toVersion: GoodDogSchema_V01_01_00.self)

Now, in the stages: [MigrationStage], add curly braces and enter the stage you just made into an array. Add the square brackets because, as you add future migration stages, they’ll get added to the stages array.

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

The completed SchemaMigrationPlan should look like this:

enum GoodDogMigrationPlan: SchemaMigrationPlan {

  static var schemas: [any VersionedSchema.Type] {
    [
      GoodDogSchema_V01_00_00.self,
      GoodDogSchema_V01_01_00.self
    ]
  }

  static let migration_V1_0_0_to_V1_1_0 = MigrationStage.lightweight(
    fromVersion: GoodDogSchema_V01_00_00.self,
    toVersion: GoodDogSchema_V01_01_00.self)

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

Adding City to Parks

Inside the GoodDogSchema_V01_01_00, update the ParkModel with an Optional String named city.

@Model
class ParkModel {
  // ...
  var city: String?

  init(name: String,
       dogs: [DogModel]? = nil,
       city: String?) {
    // ...
    self.city = city
  }
  // ...
}

Scroll down to the extension with mock parks and add some cities.

let riverdale = ParkModel(
  name: "Riverdale Park",
  city: "Mississauga"
)
let withrow = ParkModel(
  name: "Withrow Park",
  city: "Toronto"
)
let greenwood = ParkModel(
  name: "Greenwood Park",
  city: "Burlington"
)
let hideaway = ParkModel(
  name: "Hideaway Park",
  city: "Hamilton"
)
let kewBeach = ParkModel(
  name: "Kew Beach Off Leash Dog Park",
  city: "Toronto"
)
let allan = ParkModel(
  name: "Allan Gardens",
  city: "Toronto"
)

You’ll get some errors at this point that say Extra argument 'city' in call. This is because the mock data from version 1.0.0 is still being used. You’ll switch the version in the app soon, but for now, head over to the GoodDogSchema_V01_00_00.swift file and comment out the preview data in the extension.

// comment out the data in  GoodDogSchema_V01_00_00

//  let labrador = BreedModel(name: "Labrador Retriever")
//  let golden = BreedModel(name: "Golden Retriever")
//  let bouvier = BreedModel(name: "Bouvier")
//  let mixed = BreedModel(name: "Mixed")

// ...

//      kirby.breed = labrador
//      kirby.parks = [allan, greenwood, kewBeach]
//      container.mainContext.insert(priscilla)
//      priscilla.breed = mixed

Update the TypeAlias settings at the top of the GoodDogMigrationPlan to use the new schema and mock data.

typealias DogModel = GoodDogSchema_V01_01_00.DogModel
typealias BreedModel = GoodDogSchema_V01_01_00.BreedModel
typealias ParkModel = GoodDogSchema_V01_01_00.ParkModel

Updating the Park Views

Open the NewParkView in the Project Navigator. Add a state variable city with an empty string at the top of the NewParkView struct.

@State private var city = ""

Below the Name LabeledContent, add one for the city name.

LabeledContent {
  TextField("City", text: $city)
} label: {
  Text("Name")
    .foregroundStyle(.secondary)
}

Update the button action to insert the city name.

let newPark = ParkModel(name: name, city: city)

Open the ParkStackView in the Project Navigator. Command-click the park.name and embed it in a VStack. Add a Text(park.city ?? "n/a") view, and set the font to caption2. The finished VStack will look like the following, followed by the modifiers that were on the park.name.:

VStack {
  Text(park.name)
    .font(.caption)
  Text(park.city ?? "n/a")
    .font(.caption2)
}

At the bottom of the ParkStackView, update the Preview with some city names as well.

let riverdale = ParkModel(
  name: "Riverdale Park",
  city: "Toronto"
)
let withrow = ParkModel(
  name: "Withrow Park",
  city: "Toronto"
)
let greenwood = ParkModel(
  name: "Greenwood Park",
  city: "Toronto"
)

Using the Migration

Now that you’ve updated the model and views, it’s time to use the migration in the app. Switch to the GoodDogApp.swift and change schema from GoodDogSchema_V01_00_00.DogModel.self to GoodDogSchema_V01_01_00.DogModel.self, where the schema is defined in the container variable:

let schema = Schema([GoodDogSchema_V01_01_00.DogModel.self])

Update the container to use the migrationPlan with the value of your GoodDogMigrationPlan.self.

let container = try ModelContainer(
  for: schema,
  migrationPlan: GoodDogMigrationPlan.self,
  configurations: config
)

Use Command-B to update the modelContainer and set up the updated modelContext. Try out the Canvas previews or build and run to build to the Simulator. You can also debug the Simulator’s object stores and see the ZCITY value in the ParkModel. You’ve successfully set up a lightweight migration. Enter some cities and parks in the Simulator for the next section.

Custom Migration

Now that you’ve managed to add the city field, it’s time to reduce any redundant and duplicated entries. Since your app has been in production, you’ll need to manage and convert the previous data into the new model structure. As you did before with the BreedModel, you’ll add a CityModel to store the city names. This is going to be a major change and will require a custom migration. You’ll set up the data clean-up logic.

Select the version 1.1.0 GoodDogSchema_V01_01_00 file in the Project Navigator. From the File menu choose Duplicate. Name the file GoodDogSchema_V02_00_00.swift because this is a major change.

Go through the file and change the versions from version 1.1.0 to version 2.0.0.

// MARK: - Version 2.0.0
enum GoodDogSchema_V02_00_00: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)

  // ...
}

Also, increment the versions in the extension.

// MARK: - Version 2.0.0 extension
extension GoodDogSchema_V02_00_00.DogModel {
  @MainActor
  // ... the preview data

}

Update the TypeAlias settings at the top of the GoodDogMigrationPlan to use the new mock data.

typealias DogModel = GoodDogSchema_V02_00_00.DogModel
typealias BreedModel = GoodDogSchema_V02_00_00.BreedModel
typealias ParkModel = GoodDogSchema_V02_00_00.ParkModel

Add the new 2.0.0 schema to the schemas array.

[
  GoodDogSchema_V01_00_00.self,
  GoodDogSchema_V01_01_00.self,
  GoodDogSchema_V02_00_00.self
]

Add the new migration stage constant as a custom migration.

static let migrationV1_1_0toV2_0_0 = MigrationStage.custom (
  fromVersion: GoodDogSchema_V01_01_00.self,
  toVersion: GoodDogSchema_V02_00_00.self
) { context in
    // willMigrate: before migration
  } didMigrate: { context in
    // didMigrate: after migration
}

Open the GoodDogSchema_V02_00_00.swift file, and add the CityModel with a string for name and park as Optional ParkModel.

@Model
class CityModel {
  var name: String = ""
  var park: ParkModel?

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

Change the ParkModel to use the CityModel instead of a string.

// ... ParkModel
var city: CityModel?

init(
  name: String,
  dogs: [DogModel]? = nil,
  city: CityModel?
) {

// ...
}

Update the model array by adding the CityModel.self.

static var models: [any PersistentModel.Type] {
  [
    DogModel.self,
    BreedModel.self,
    ParkModel.self,
    CityModel.self
  ]
}

In the GoodDogMigrationPlan, add a TypeAlias for the CityModel.

typealias CityModel = GoodDogSchema_V02_00_00.CityModel

Go back to the GoodDogSchema_V02_00_00.swift, and add some cities to the mock data.

let toronto = CityModel(name: "Toronto")
let hamilton = CityModel(name: "Hamilton")
let ottawa = CityModel(name: "Ottawa")
let mississauga = CityModel(name: "Mississauga")

Update the park variables with cities.

let riverdale = ParkModel(
  name: "Riverdale Park",
  city: nil
)
let withrow = ParkModel(
  name: "Withrow Park",
  city: mississauga
)
let greenwood = ParkModel(
  name: "Greewood Park",
  city: hamilton
)
let hideaway = ParkModel(
  name: "Hideaway Park",
  city: toronto
)
let kewBeach = ParkModel(
  name: "Kew Beach Off Leash Dog Park",
  city: toronto
)
let allan = ParkModel(
  name: "Allan Gardens",
  city: nil
)

Open the ParkStackView and update city.name to park.city?.name.

Text(park.city?.name ?? "n/a")

Also, update the Preview’s parks.

let riverdale = ParkModel(
  name: "Riverdale Park",
  city: CityModel(name:"Toronto")
)
let withrow = ParkModel(
  name: "Withrow Park",
  city: CityModel(name:"Toronto")
)
let greenwood = ParkModel(
  name: "Greenwood Park",
  city: CityModel(name:"Toronto")
)

Open NewParkView and update the button action.

let newPark = ParkModel(
  name: name,
  city: CityModel(
    name: city
  )
)

Comment out the mock data variables in the GoodDogSchema_V01_01_00 since they’re not compatible with the 2.0.0 version.

//      let labrador = BreedModel(name: "Labrador Retriever")
//      let golden = BreedModel(name: "Golden Retriever")
//      let bouvier = BreedModel(name: "Bouvier")
//      let mixed = BreedModel(name: "Mixed")
//
// ...
//
//      container.mainContext.insert(violet)
//      violet.breed = bouvier
//      violet.parks = [riverdale, withrow, hideaway]
//      container.mainContext.insert(kirby)
//      kirby.breed = labrador
//      kirby.parks = [allan, greenwood, kewBeach]
//      container.mainContext.insert(priscilla)
//      priscilla.breed = mixed

Now, you’ll add the code to migrate the CityModel data. Open GoodDogMigrationPlan, and update the unlabeled willMigrate closure in the migrationV1_1_0toV2_0_0 custom migration stage.

Add a dictionary to store the parks and the related cities.

// Park to city mapping for migrationV1_1_0toV2_0_0
  static var parkToCityDictionary: [String: String] = [:]

Add a guard to fetch the parks from the object store. Then, add the park and city key-value pairs to the dictionary.

// willMigrate: before migration
guard let parks = try? context.fetch(
  FetchDescriptor<GoodDogSchema_V01_01_00.ParkModel>()
) else {
  return
}
// save the mapping
parkToCityDictionary = parks.reduce(into: [:], { dictionary, ParkModel in
  dictionary[ParkModel.name] = ParkModel.city
})

In the didMigrate: closure, add the following. It will add the cities to a Set for uniqueness and then insert each into the CityModel. Following that, you’re checking the parks and cities values and updating the ParkModel with the related city object.

// after migration
let uniqueCities = Set(parkToCityDictionary.values)
// add cities to ParkModel
for city in uniqueCities {
  context.insert(GoodDogSchema_V02_00_00.CityModel(name: city))
}
try? context.save()

guard let parks = try? context.fetch(
  FetchDescriptor<GoodDogSchema_V02_00_00.ParkModel>()
) else {
  return
}
guard let cities = try? context.fetch(
  FetchDescriptor<GoodDogSchema_V02_00_00.CityModel>()
) else {
  return
}
// match park to city
for parkToCity in parkToCityDictionary {
  guard let parkModel = parks.first(where: {
    $0.name == parkToCity.key
  }) else {
    return
  }
  guard let cityModel = cities.first(where: {
    $0.name == parkToCity.value
  }) else {
    return
  }
  parkModel.city = cityModel
  try? context.save()
}

Add the 2.0.0 migration stage to the stages array.

static var stages: [MigrationStage] {
  [
    migration_V1_0_0_to_V1_1_0,
    migration_V1_1_0_to_V2_0_0
  ]
}

Finally, go to the GoodDogApp file and update the schema.

let schema = Schema([GoodDogSchema_V02_00_00.DogModel.self])

Use Command-B to update the modelContainer and set up the updated modelContext. Try out the Canvas previews or build and run to build to the Simulator. You can also debug the Simulator’s object stores and see the values in the ParkModel and CityModel. You have just completed a custom migration. Your app is now ready to handle future updates to your schema with the use of SwiftData’s migrations. This concludes your work with with the GoodDog app, great job!

That ends the demo on Swift Data Migrations. In the next video, you’ll learn how to convert an existing Swift Data app using Core Data to one that uses SwiftData for data persistence.

See forum comments
Cinema mode Download course materials from Github
Previous: Migrations & Working with Core Data Instruction Next: From Core Data to SwiftData Demo