12.2 C
Canberra
Friday, January 23, 2026

A Deep Dive into SwiftData migrations – Donny Wals


SwiftData migrations are a kind of issues that really feel optionally available… proper till you ship an replace and actual customers improve with actual knowledge on disk.

On this put up we’ll dig into:

  • The right way to implement schema variations with VersionedSchema
  • When it’s best to introduce new schema variations
  • When SwiftData can migrate routinely and if you’ll have to write down guide migrations with SchemaMigrationPlan and MigrationStage
  • The right way to deal with additional advanced migrations the place you want “bridge” variations

By the top of this put up it’s best to have a fairly stable understanding of SwiftData’s migration guidelines, potentialities, and limitations. Extra importantly: you’ll know hold your migration work proportional. Not each change wants a customized migration stage, however some adjustments completely do.

Implementing easy variations with VersionedSchema

Each knowledge mannequin ought to have no less than one VersionedSchema. What I imply by that’s that even for those who haven’t launched any mannequin updates but, your preliminary mannequin ought to be shipped utilizing a VersionedSchema.

That offers you a steady place to begin. Introducing VersionedSchema after you’ve already shipped is feasible, however there’s some threat concerned with not getting issues proper from the beginning.

On this part, I’ll present you outline an preliminary schema, how one can reference “present” fashions cleanly, and when it’s best to introduce new variations.

Defining your preliminary mannequin schema

In case you’ve by no means labored with versioned SwiftData fashions earlier than, the nested sorts that you’re going to see in a second can look a little bit odd at first. The concept is straightforward although:

  • Every schema model defines its personal set of @Mannequin sorts, and people sorts are namespaced to that schema (for instance ExerciseSchemaV1.Train).
  • Your app code usually needs to work with “the present” fashions with out spelling SchemaV5.Train in every single place.
  • A typealias allows you to hold your name websites clear whereas nonetheless being express about which schema model you’re utilizing.

One very sensible consequence of that is that you simply’ll usually find yourself with two sorts of “fashions” in your codebase:

  • Versioned fashions: ExerciseSchemaV1.Train, ExerciseSchemaV2.Train, and so forth. These exist so SwiftData can cause about schema evolution.
  • Present fashions: typealias Train = ExerciseSchemaV2.Train. These exist so the remainder of your app stays readable and also you needn’t refactor half your code if you introduce a brand new schema model.

Each mannequin schema that you simply outline will conform to the VersionedSchema protocol and include the next two fields:

  • versionIdentifier a semantic versioning identifier in your schema
  • fashions a listing of mannequin objects which are a part of this schema

A minimal V1 → V2 instance

For instance a easy VersionedSchema definition, we’ll use a tiny Train mannequin as our V1.

In V2 we’ll add a notes area. This type of change is fairly frequent in my expertise and it is a good instance of a so-called light-weight migration as a result of current rows can merely have their notes set to nil.

import SwiftData

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

  @Mannequin
  closing class Train {
    var title: String

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

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

  @Mannequin
  closing class Train {
    var title: String
    var notes: String?

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

In the remainder of your app, you’ll normally need to work with the newest schema’s mannequin sorts:

typealias Train = ExerciseSchemaV2.Train

That manner you possibly can write Train(...) as an alternative of ExerciseSchemaV2.Train(...).

Understanding when to introduce new VersionedSchemas

Personally, I solely introduce a brand new model once I make mannequin adjustments in between App Retailer releases. For instance, I am going to ship my app v1.0 with mannequin v1.0. After I need to make any variety of mannequin adjustments in my app model 1.1, I’ll introduce a brand new mannequin model too. Normally I am going to title the mannequin model 2.0 since that simply is sensible to me. Even when I find yourself making a great deal of adjustments in separate steps, I hardly ever create multiple mannequin model for a single app replace. As we’ll see within the advanced migrations sections there is likely to be exceptions if I would like a multi-stage migration however these are very uncommon.

So, introduce a brand new VersionedSchema if you make mannequin adjustments after you’ve got already shipped a mannequin model.

One factor that you’ll be wanting to bear in mind is that customers can have completely different migration paths. Some customers will replace to each single mannequin you launch, others will skip variations.

SwiftData handles these migrations out of the field so you do not have to fret about them which is nice. It is nonetheless good to concentrate on this although. Your mannequin ought to have the ability to migrate from any previous model to any new model.

Usually, SwiftData will determine the migration path by itself, let’s examine how that works subsequent.

Automated migration guidelines

While you outline your whole versioned schemas appropriately, SwiftData can simply migrate your knowledge from one model to a different. Generally, you may need to assist SwiftData out by offering a migration plan. I usually solely do that for my customized migrations nevertheless it’s doable to optimize your migration paths by offering migration plans for light-weight migrations too.

What “computerized migration” means in SwiftData

SwiftData can infer sure schema adjustments and migrate your retailer with none customized logic. In a migration plan, that is represented as a light-weight stage.

One nuance that’s price calling out: SwiftData can carry out light-weight migrations with out you writing a SchemaMigrationPlan in any respect. However when you do undertake versioned schemas and also you need predictable, testable upgrades between shipped variations, explicitly defining levels is essentially the most easy method to make your intent unambiguous.

I like to recommend going for each approaches (with and with out plans) no less than as soon as so you possibly can expertise them and you may determine what works finest for you. When doubtful, it by no means hurts to construct migration plans for light-weight migrations even when it isn’t strictly wanted.

Let’s examine how you’ll outline a migration plan in your knowledge retailer, and the way you need to use your migration plan.

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

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

On this migration plan, we have outlined our mannequin variations, and we have created a light-weight migration stage to go from our v1 to our v2 fashions. Word that we technically did not must construct this migration plan as a result of we’re doing light-weight migrations solely, however for completeness sake you possibly can ensure you outline migration steps for each mannequin change.

While you create your container, you possibly can inform it to make use of your plan as follows:

typealias Train = ExerciseSchemaV2.Train

let container = strive ModelContainer(
  for: Train.self,
  migrationPlan: AppMigrationPlan.self
)

Understanding when a light-weight migration can be utilized

The next adjustments are light-weight adjustments and do not require any customized logic:

  • Add an optionally available property (like notes: String?)
  • Take away a property (knowledge is dropped)
  • Make a property optionally available (non-optional → optionally available)
  • Rename a property for those who map the unique saved title

These adjustments don’t require SwiftData to invent new values. It might both hold the previous worth, transfer it, or settle for a nil the place no worth existed earlier than.

Safely renaming values

While you rename a mannequin property, the shop nonetheless comprises the previous attribute title. Use @Attribute(originalName:) so SwiftData can convert from previous property names to new ones.

@Mannequin
closing class Train {
  @Attribute(originalName: "title")
  var title: String

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

When you shouldn’t depend on light-weight migration

Light-weight migrations break down when your new schema introduces a brand new requirement that previous knowledge cannot fulfill. Or in different phrases, if SwiftData cannot routinely decide transfer from the previous mannequin to the brand new one.

Some examples of mannequin adjustments that may require a heavy migration are:

  • Including non-optional properties with out a default worth
  • Any change that requires a change step:
    • parsing / composing values
    • merging or splitting entities
    • altering a price’s kind
    • knowledge cleanup (dedupe, normalizing strings, fixing invalid states)

In case you’re making a change that SwiftData cannot migrate by itself, you are in guide migration land and you will need to pay shut consideration to this part.

A fast be aware on “defaults”

You’ll generally see recommendation like “simply add a default worth and also you’re superb”. That may be true, however there’s a refined entice: a default worth in your Swift initializer doesn’t essentially imply current rows get a price throughout migration.

In case you’re introducing a required area, assume it’s worthwhile to explicitly backfill it until you’ve examined the migration from an actual on-disk retailer. That is the place guide migrations develop into necessary.

Performing guide migrations utilizing a migration plan

As you’ve got seen earlier than, a migration plan means that you can describe how one can migrate from one mannequin model to the following. Our instance from earlier than leveraged a light-weight migration. We’ll arrange a customized migration for this part.

We’ll stroll by means of a few situations with growing complexity so you possibly can ease into tougher migration paths with out being overwhelmed.

Assigning defaults for brand spanking new, non-optional properties

State of affairs: you add a brand new required area like createdAt: Date to an current mannequin. Present rows don’t have a price for it. Emigrate this, we have now two choices

  • Possibility A: make the property optionally available and settle for “unknown”. This may permit us to make use of a light-weight migration however we would have nil values for createdAt
  • Possibility B: write a guide migration and hold the property as non-optional

Possibility B is the cleaner choice because it permits us to have a extra strong knowledge mannequin. Right here’s what this seems to be like if you truly wire it up. First, outline schemas the place V2 introduces our createdAt property:

import SwiftData

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

  @Mannequin
  closing class Train {
    var title: String

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

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

  @Mannequin
  closing class Train {
    var title: String
    var createdAt: Date

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

Subsequent we are able to add a customized stage that units createdAt for current rows. We’ll speak about what the willMigrate and didMigrate closure are in a second; let us take a look at the migration logic first:

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

  static let v1ToV2 = MigrationStage.customized(
    fromVersion: ExerciseCreatedAtSchemaV1.self,
    toVersion: ExerciseCreatedAtSchemaV2.self,
    willMigrate: { _ in },
    didMigrate: { context in
      let workouts = strive context.fetch(FetchDescriptor())
      for train in workouts {
        train.createdAt = Date()
      }
      strive context.save()
    }
  )
}

With this alteration, we are able to assign a smart default to createdAt. As you noticed we have now two migration levels; willMigrate and didMigrate. Let’s examine what these are about subsequent.

Taking a more in-depth take a look at advanced migration levels

willMigrate

willMigrate is run earlier than your schema is utilized and ought to be used to scrub up your “previous” (current) knowledge if wanted. For instance, for those who’re introducing distinctive constraints you possibly can take away duplicates out of your unique retailer in willMigrate. Word that willMigrate solely has entry to your previous knowledge retailer (the “from” mannequin). So you possibly can’t assign any values to your new fashions on this step. You’ll be able to solely clear up previous knowledge right here.

didMigrate

After making use of your new schema, didMigrate is named. You’ll be able to assign your required values right here. At this level you solely have entry to your new mannequin variations.

I’ve discovered that I usually do most of my work in didMigrate, as a result of I will assign knowledge there; I do not usually have to arrange my previous knowledge for migration.

Establishing additional advanced migrations

Generally you may must do migrations that reshape your knowledge. A typical case is introducing a brand new mannequin the place one of many new mannequin’s fields consists from values that was once saved some place else.

To make this concrete, think about you began with a mannequin that shops “abstract” exercise knowledge in a single mannequin:

import SwiftData

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

  @Mannequin
  closing class WeightData {
    var weight: Float
    var reps: Int
    var units: Int

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

Now you need to introduce PerformedSet, and have WeightData include a listing of carried out units as an alternative. You may attempt to take away weight/reps/units from WeightData in the identical model the place you add PerformedSet, however that makes migration unnecessarily arduous: you continue to want the unique values to create your first PerformedSet.

The dependable method right here is similar bridge-version technique we used earlier:

  • V2 (bridge): hold the previous fields round underneath legacy names, and add the connection
  • V3 (cleanup): take away the legacy fields as soon as the brand new knowledge is populated

Right here’s what the bridge schema may appear to be. Discover how the legacy values are stored round with @Attribute(originalName:) so that they nonetheless learn from the identical saved columns:

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

  @Mannequin
  closing class WeightData {
    @Attribute(originalName: "weight")
    var legacyWeight: Float

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

    @Attribute(originalName: "units")
    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
    }
  }

  @Mannequin
  closing class PerformedSet {
    var weight: Float
    var reps: Int
    var units: Int

    var weightData: WeightData?

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

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

static let migrateV1toV2 = MigrationStage.customized(
  fromVersion: WeightSchemaV1.self,
  toVersion: WeightSchemaV2.self,
  willMigrate: nil,
  didMigrate: { context in
    let allWeightData = strive context.fetch(FetchDescriptor())

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

      weightData.performedSets.append(performedSet)
    }

    strive context.save()
  }
)

When you’ve shipped this and also you’re assured the information is within the new form, you possibly can introduce V3 to take away legacyWeight, legacyReps, and legacySets totally. As a result of the information now lives in PerformedSet, V2 → V3 is often a light-weight migration.

When you end up having to carry out a migration like this, it may be fairly scary and complicated so I extremely advocate correctly testing your app earlier than delivery. Attempt testing migrations from and to completely different mannequin variations to just remember to do not lose any knowledge.

Abstract

SwiftData migrations develop into loads much less demanding if you deal with schema variations as a launch artifact. Introduce a brand new VersionedSchema if you ship mannequin adjustments to customers, not for each little iteration you do throughout growth. That retains your migration story reasonable, testable, and manageable over time.

While you do ship a change, begin by asking whether or not SwiftData can moderately infer what to do. Light-weight migrations work nicely when no new necessities are launched: including optionally available fields, dropping fields, or renaming fields (so long as you map the unique saved title). The second your change requires SwiftData to invent or derive a price—like introducing a brand new non-optional property, altering sorts, or composing values—you’re in guide migration land, and a SchemaMigrationPlan with customized levels is the best device.

For the actually difficult instances, don’t pressure every thing into one heroic migration. Add a bridge model, populate the brand new knowledge form first, then clear up previous fields in a follow-up model. And no matter you do, check migrations the way in which customers expertise them: migrate a retailer created by an older construct with messy knowledge, not a pristine simulator database you possibly can delete at will.

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

[td_block_social_counter facebook="tagdiv" twitter="tagdivofficial" youtube="tagdiv" style="style8 td-social-boxed td-social-font-icons" tdc_css="eyJhbGwiOnsibWFyZ2luLWJvdHRvbSI6IjM4IiwiZGlzcGxheSI6IiJ9LCJwb3J0cmFpdCI6eyJtYXJnaW4tYm90dG9tIjoiMzAiLCJkaXNwbGF5IjoiIn0sInBvcnRyYWl0X21heF93aWR0aCI6MTAxOCwicG9ydHJhaXRfbWluX3dpZHRoIjo3Njh9" custom_title="Stay Connected" block_template_id="td_block_template_8" f_header_font_family="712" f_header_font_transform="uppercase" f_header_font_weight="500" f_header_font_size="17" border_color="#dd3333"]
- Advertisement -spot_img

Latest Articles