SwiftData マイグレーション:lightweight か custom か、そして V2 が不要なケース
SwiftData のスキーママイグレーションは Core Data に比べて構造的に改善されていますが、チームが繰り返し陥る落とし穴が 1 つあります。それは、SwiftData がインラインのデフォルト値によって自動的に処理できる変更に対して、新しい VersionedSchema を宣言してしまうことです。その結果、コードは正しく見えてビルドも通るのに、実機で「Duplicate version checksums across stages detected」というクラッシュが起きます。このフレームワークの実際のマイグレーションモデルは、3 つの構成要素(VersionedSchema、MigrationStage、SchemaMigrationPlan)と 3 つのマイグレーションタイプ(自動 lightweight、宣言された lightweight、custom)から成り立っています1。ほとんどのスキーマ変更は自動です。一部は宣言された lightweight ステージを必要とします。そしてごく少数だけが、willMigrate と didMigrate クロージャを持つ custom ステージを必要とします。
本記事では、Apple のドキュメントに沿ってマイグレーションモデルをたどり、各マイグレーションタイプが扱うケースを明確にし、iOS 26 で新たに加わったクラス継承サポートについても取り上げます。考え方の枠組みは「自分が何を宣言し、何を SwiftData に任せるか」です。なぜなら、その判断こそがマイグレーションをクリーンに出荷できるか、それとも初回起動でクラッシュするかを決めるからです。
TL;DR
- SwiftData のマイグレーションは 3 つのプロトコルで構成されます。
VersionedSchema(あるバージョン時点のモデル型のスナップショット)、MigrationStage(.lightweightまたは.customのケースを持つ、単一の fromVersion から toVersion への遷移)、そしてSchemaMigrationPlan(ステージの順序付きリスト)です1。 - インラインのデフォルト値を持つ新しい
@Modelプロパティ(var foo: Bool = false)の追加には、新しいVersionedSchemaは不要です。SwiftData はこの追加を lightweight マイグレーションとして自動的に処理します。そのために V2 を宣言すると、「Duplicate version checksums across stages detected」というクラッシュを引き起こします。 - lightweight マイグレーションが扱うもの:エンティティ、属性、リレーションシップの追加・リネーム・削除、リレーションシップ型の変更、リネームを追跡する
@Attribute(originalName:)の宣言、削除ルールの指定。ほとんどのスキーマ変更はここに収まります。 - custom マイグレーション(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))はデータ変換を扱います。1 つのカラムを 2 つに分割する、派生フィールドを計算する、モデル間でデータを移動するといった処理です。willMigrateは古いコンテキストを持ち、didMigrateは新しいコンテキストを持ちます。 - iOS 26 は
@Model型にクラス継承を追加します2。継承を採用したスキーマは新しいバージョンに上がり、従来のフラットなモデルバージョンからの lightweight ステージを持ちます。
3 つの構成要素から成るモデル
SwiftData のマイグレーションは 3 つの構成要素から組み立てられます。
VersionedSchema
特定のスキーマバージョンにおけるモデル型のスナップショットです1。このプロトコルは次を要求します。
static var versionIdentifier: Schema.Version。セマンティックなバージョンの 3 つ組(Schema.Version(1, 0, 0))です。static var models: [any PersistentModel.Type]。このバージョンにおける@Model型の配列です。
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
enum の中に型をネストするこのパターンが慣例です。それぞれの VersionedSchema が自身のモデルクラスに名前空間を与えるため、同じモデル名を持つ複数のスキーマがマイグレーション中のコードベース内で共存できます。
MigrationStage
2 つの VersionedSchema 型の間の単一の遷移です3。2 つのケースがあります。
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type)。SwiftData がアプリ側のコードなしで処理する遷移を宣言します。引数は生のSchema.Version値ではなく、VersionedSchema型そのもの(例:SchemaV1.self)です。.custom(fromVersion:toVersion:willMigrate:didMigrate:)。データマイグレーションの前後で実行されるコードを伴う遷移を宣言します。バージョン引数の型は.lightweightと同じです。
SchemaMigrationPlan
任意の以前のバージョンから現在のバージョンへとスキーマを移行させる、ステージの順序付きリストです1。
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
ModelContainer は現在のスキーマとマイグレーションプランの両方を指定してセットアップします。
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData はコンテナ作成時に永続ストアの現在のスキーマバージョンを読み取り、そのバージョンから現在のバージョンに向かってプランのステージをたどり、各ステージを順番に適用します。
lightweight マイグレーションが自動的に扱うもの
ほとんどのスキーマ変更に custom ステージは不要です1。
- デフォルト値を持つ属性の追加。 既存の
@Modelへのvar foo: Bool = falseは自動です。 - 新しいエンティティ(モデルクラス)の追加。 新しい型はその
VersionedSchemaが現在のものになったときに現れ、既存のデータは保持されます。 - 属性やエンティティの削除。 SwiftData はカラムまたはテーブルを削除します。
- 属性やエンティティのリネーム。 データを保持するにはプロパティに
@Attribute(originalName: "oldName")を追加します。SwiftData が旧名を新名にマッピングします。 - リレーションシップ型の変更。 単数から複数、多対多などです。
- 削除ルールの指定。
@Relationship(deleteRule: .cascade)のような追加は lightweight です。
このリストにある変更については、モデル型がそれ以外で変わっていないのであれば、新しい VersionedSchema をまったく宣言しないのが正しいパターンです。SwiftData が既存のスキーマに対して lightweight マイグレーションを自動的に実行します。
落とし穴:フィールドの追加に V2 は不要
最もよくある SwiftData マイグレーションのミスはこうです。開発者がインラインのデフォルト値を持つ新しいプロパティ(var foo: Bool = false)を追加し、続いて SchemaV1 と同じモデル型を参照する SchemaV2 を宣言します。ビルドは通ります。しかし、既存の V1 データを持つ実機での初回起動で Duplicate version checksums across stages detected というクラッシュが起きます。SchemaV1 と SchemaV2 の両方が同じチェックサムに解決されるためです(モデル型は、SwiftData が差分として認識するような形では変わっていないのです)。
正しいパターンはこうです。既存の VersionedSchema はそのままにして、新しいプロパティをインラインのデフォルト値とともにモデルに追加し、SwiftData の自動 lightweight マイグレーションに任せます。MigrationPlan も MigrationStage も V2 も必要ありません。
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
var isFavorite: Bool = false という変更は、MigrationStage の宣言なしで出荷されます。migrationPlan: を渡さない ModelContainer のイニシャライザで動作します。
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
V2 スキーマが必要になるのは、変更が lightweight にできない場合だけです(データ変換、モデルの分割、custom ロジックを要する継承の再構成など)。そうしたケースでは V2 は実体を持ち、SchemaMigrationPlan が遷移を取り仕切ります。
custom マイグレーションが必要になるとき
custom マイグレーションがその複雑さに見合うのは、次の 3 つのケースです。
1. 1 つのフィールドを複数に分割する場合。 "Last, First" を保持する String フィールドを、firstName と lastName の 2 つのフィールドに分けます。このマイグレーションでは、古い値を読み取り、それをパースし、新しいフィールドへ書き込む必要があります。
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
didMigrate クロージャは新しいスキーマのコンテキストに対して実行されるため、新しいフィールドにアクセスできます。古い fullName は、新しいフィールドが埋まるまで削除を先送りする必要があるかもしれません。そのクリーンアップは後続の V2 から V3 へのステージで行います。
2. 派生フィールドを計算する場合。 既存データに依存する新しい @Attribute は、マイグレーション時にバックフィルする必要があります。
3. モデル間でデータを移動する場合。 Item のデータを Item と新しい Tag モデルに分割するような再編成では、古いデータからタグを割り当てる custom ロジックが必要です。
一般的なパターンはこうです。スキーマの形が変わるときは lightweight、データの形が変わるときは custom です。
willMigrate と didMigrate
custom ステージには 2 つのクロージャがあり、それぞれ異なるタイミングで呼び出されます4。
willMigrate は SwiftData がスキーママイグレーションを適用する前に実行されます。このクロージャが受け取るモデルコンテキストは古いスキーマのコンテキストです。スキーマが内部で変わる前に、データを取り込んだり、非正規化したり、補助的な状態を準備したりするために使います。
didMigrate はスキーママイグレーションの後に実行されます。モデルコンテキストは新しいスキーマのものです。新しいフィールドのバックフィル、派生データの計算、マイグレーションの仕上げに使います。
どちらのクロージャも必要なければ nil にできます。ほとんどの custom マイグレーションは didMigrate だけを使います。willMigrate は、スキーマ変更後にはアクセスできなくなる古いデータを読む必要があるときに役立ちます。
クロージャは ModelContext を受け取り、フェッチ、変更、保存ができます。クロージャは throwing で、エラーはマイグレーションの外へ伝播し、マイグレーションを中断させます。
iOS 26:@Model のクラス継承
iOS 26 は SwiftData モデルにクラス継承を導入します2。モデルは親子関係を持てるようになりました。
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
継承を採用したスキーマは、従来のフラットなモデルバージョンからの lightweight マイグレーションステージとともに、新しいバージョンに上がります。継承が既存のプロパティを保持していれば、この遷移は自動です。サブクラスの新しいフィールドは、標準的なインラインデフォルトのパターンに従います。
このパターンは、複数の @Model 型が特性を共有するケースに適しています。Car、Truck、Motorcycle を子に持つ Vehicle 親、あるいは CheckingAccount、SavingsAccount を子に持つ Account 親などです。共有プロパティは親に置き、固有のものは子に置きます。
マイグレーションのテスト
コンパイルが通るマイグレーションは、出荷できるマイグレーションではありません。リリース前に試す価値のある 3 つのテストパターンを挙げます。
1. 本番データベースのコピーを使ったラウンドトリップテスト。 直近の本番形状のデータベースを引き出す(あるいはテストで合成 V1 データを生成する)か、いずれかを用意し、V2 対応のコンテナで開いて、データが正しくマイグレートされることを検証します。このテストは、型チェッカーでは捕まえられない custom マイグレーションのバグを捕らえます。
2. 旧バージョンが依然として起動すること。 以前のアプリバージョンをビルドし、一度実行して V1 データを生成し、その後で新しいアプリバージョンをビルドして、クラッシュせずに起動することを検証します。このテストは「Duplicate version checksums」の落とし穴や、それに類する宣言ミスを捕らえます。
3. マイグレーション失敗からの復旧。 マイグレーションが throw したら何が起きるでしょうか。SwiftData の挙動はコンテナの設定に依存します。本番アプリでは、ハンドリングされていないマイグレーションエラーがユーザーデータを黙って削除してはいけません。失敗パスを明示的にテストし、アプリが何をするか(ロールバック、ユーザーへの確認、バックアップからの復旧)を決めておきます。
このクラスターの Single Source of Truth の記事 は、SwiftData ストアがプロセス間同期で置き換えられたときに何が起きるかという関連する問いを扱っています。マイグレーションは、そのパターンのローカルな進化版にあたります。
プロセスをまたいだマイグレーションの出荷と進捗の提示
ドキュメントでは前面に出されていませんが、SwiftData チームが WWDC 2026 で言及した運用上の詳細が 2 つあります5。アプリが widget や extension を持つときにマイグレーションがどこで実行されるか、そしてマイグレーション実行中に進捗 UI をどう駆動するか、です。
マイグレーションは 1 つのプロセスが所有します。 widget や extension はメインアプリと同じランタイムリソースを得られないため、マイグレーションを安全に実行できません。指針としては、SchemaMigrationPlan1 を widget と extension のターゲットから完全に外し、そこからは決してマイグレートしないことです。1 つのプロセス、通常はメインアプリを、データベースの所有者として選びます。widget がコンテナを開いたときに、ディスク上のストアがバージョン管理されていない(古い)スキーマであれば、その open はエラーになります。そのエラーをマイグレーションが必要だというシグナルとして扱います。メインアプリを開くようユーザーに促す UI を提示し、アプリにマイグレーションを実行させ、マイグレート済みのスキーマバージョンを共有の UserDefault に書き込ませます。widget は次回その値を読み、アプリがすでにマイグレートしたバージョンでコンテナを開きます。このパターンは書き手を 1 つに保ち、2 つのプロセスが同じファイルを進化させようと競合するのを避けます。
進捗は経過時間ではなくステージ数から算出します。 SwiftData は専用のマイグレーション進捗 API を公開していません5。進捗インジケータを駆動するには、プラン内の custom マイグレーションステージの総数を数え、ステージごとの didMigrate ハンドラ4 をオーバーライドして、各ステージが自分の位置を「M 件中 N 件目」として報告するようにします。この数は経過時間ではなく完了したステージを反映するため、バーは滑らかにではなく離散的なステップで進みます。これに対応する設計上の判断は、マイグレーション中にアプリが何を見せるかです。素っ気ないスピナーは停止しているように見え、ユーザーは離脱します。データが許す範囲でアプリを部分的に使える状態に保つか、少なくとも各ステージが何を追加しているか(そのマイグレーションが解放する新機能)を説明し、待ち時間が虚無ではなく何かに向かう進捗として読めるようにします。
よくある失敗パターン
SwiftData の失敗ログから 3 つのパターンを挙げます。
SwiftData が自動的に処理する変更に対して V2 を宣言する。 「Duplicate version checksums」クラッシュです。対処:インラインデフォルトのプロパティ追加に新しいスキーマを宣言しないこと。SwiftData に自動処理させます。
保存しない custom マイグレーションコード。 エンティティを変更するのに context.save() を呼ばない didMigrate クロージャは、一度実行して作業内容を捨て、起動のたびに再実行されるマイグレーションを生みます(マイグレーションが未完了に見えるためです)。対処:データを変更するすべてのクロージャは、return する前に必ず try context.save() を呼ぶこと。
@Attribute(originalName:) なしでプロパティをリネームする。 SwiftData は新しいプロパティを新規、古いものを削除済みとして扱い、古いプロパティの既存データは捨てられます。対処:@Attribute(originalName: "oldName") var newName: ... を宣言し、SwiftData がリネームを通じてデータをマッピングするようにします。
このパターンが iOS 26 以降のアプリに意味するもの
要点は 3 つです。
-
VersionedSchemaのはしごを使わないことをデフォルトにする。 インラインデフォルトでのプロパティ追加、使われていないフィールドの削除、@Attribute(originalName:)でのリネーム。これらはすべて lightweight で自動です。VersionedSchemaのはしごは、SwiftData が本当に自動処理できない変更(データ変換、custom ロジック、継承の再構成)のためのものです。 -
MigrationStage.customはデータ変換に使い、スキーマの形の変更には使わない。willMigrateとdidMigrateクロージャは、データを操作するコードのためのものであって、スキーマが変わったと宣言するためのものではありません。スキーマの形の変更は lightweight ステージを通します。 -
マイグレーションは合成テストデータだけでなく、本物の V1 データでテストする。 合成のラウンドトリップでは通るマイグレーションでも、エッジケースを含む本番形状のデータでは失敗することがあります(スキーマがカバーしていなかった null 許容フィールド、タイムアウトに達する大規模データセットなど)。テストのコストは小さく、初回起動でのマイグレーションクラッシュのコストは現実のものです。
Apple Ecosystem クラスターの全体像:型付き App Intents、MCP サーバー、ルーティングの問い、Foundation Models、ランタイム LLM とツーリング LLM の区別、3 つのサーフェス、single source of truth パターン、2 つの MCP サーバー、Apple 開発のための hooks、Live Activities、watchOS ランタイム、SwiftUI の内部構造、RealityKit の空間メンタルモデル、SwiftData スキーマの規律、Liquid Glass パターン、マルチプラットフォーム出荷、プラットフォームマトリクス、Vision フレームワーク、Symbol Effects、Core ML 推論、Writing Tools API、Swift Testing、Privacy Manifest、プラットフォーム機能としてのアクセシビリティ、SF Pro タイポグラフィ、visionOS の空間パターン、Speech フレームワーク、書くことを拒むもの。ハブは Apple Ecosystem シリーズ にあります。AI エージェントを伴う iOS のより広い文脈については、iOS Agent Development ガイド をご覧ください。
FAQ
SchemaMigrationPlan は常に必要ですか?
いいえ。単一のスキーマバージョンしか持たないアプリ(初回リリース、あるいは lightweight な変更しか加えてこなかったアプリ)には SchemaMigrationPlan は不要です。ModelContainer のイニシャライザはスキーマのモデルを直接受け取ります。migrationPlan: パラメータが必要になるのは、最初の custom マイグレーションステージを宣言したとき(あるいは開発者が明示的なバージョンのはしごを宣言したいと最初に思ったとき)です。
自分の変更が lightweight かどうか、どう見分けますか?
Apple の lightweight 対応リストはこうです1。エンティティ・属性・リレーションシップの追加、それらの削除、@Attribute(originalName:) でのリネーム、リレーションシップの多重度の変更、削除ルールの指定。変更がこれらのいずれかに当てはまり、モデルクラスの構造がそれ以外で変わっていなければ、マイグレーションは自動で、VersionedSchema のはしごは不要です。変更がデータ変換(計算、分割、データの移動)を要するなら、それは custom です。
willMigrate と didMigrate の両方を設定できますか?
はい。どちらのクロージャも個別には任意ですが、両方を指定できます。willMigrate は SwiftData がマイグレートする前に古いスキーマのコンテキストに対して実行され、didMigrate はその後に新しいスキーマのコンテキストに対して実行されます。2 つはそれぞれ準備と仕上げをカバーします。
マイグレーションがエラーを throw したら何が起きますか?
エラーは ModelContainer の初期化の外へ伝播します。コンテナは開けません。アプリの挙動は開発者がそのエラーをどう扱うかに依存します。復旧 UI を表示するアプリもあれば、バックアップからの復元を試みるアプリ、壊れたストアを削除してまっさらから始めるアプリもあります。SwiftData はマイグレーション失敗時にユーザーデータを黙って削除することはありません。失敗の扱いはアプリ側の責務です。
本番データに影響を与えずにマイグレーションをテストするには?
一時ファイルの URL を指す ModelContainer を作成するテストターゲットを用意し、V1 データを投入してから、マイグレーションプランを含む新しいコンテナで開きます。マイグレート後のデータが期待どおりかを検証します。このパターンはユニットテストでも統合テストでも機能します。最も現実的な結果を得るには、実際の本番形状のデータベースのコピーを使います。
iOS 26 のクラス継承は既存のスキーマで動作しますか?
はい、lightweight マイグレーションで動作します。継承を採用するアプリは新しいスキーマバージョン(例:V4)に上がり、MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self) を宣言します。フラットな親クラスのプロパティはそのまま残り、サブクラス固有のプロパティはインラインデフォルトとともに追加されます。SwiftData の lightweight マイグレーションがこの構造的変更を処理します。
参照
-
Apple Developer Documentation:
VersionedSchemaおよびSchemaMigrationPlanのプロトコルリファレンス。マイグレーションモデルです。スキーマ進化の全体的な流れについては、関連ガイド Adopting SwiftData for a Core Data app も参照してください。 ↩↩↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration(WWDC 2025 セッション 291)。iOS 26 における SwiftData クラス継承の導入です。 ↩↩
-
Apple Developer Documentation:
MigrationStage。.lightweight(fromVersion:toVersion:)と.custom(fromVersion:toVersion:willMigrate:didMigrate:)のケースを持ちます。 ↩ -
Apple Developer Documentation:
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)。このケースのシグネチャです。willMigrate が古いコンテキストに対して、didMigrate が新しいコンテキストに対して実行されるというセマンティクスは、iOS 26 の継承追加で参照したのと同じ WWDC 2025 セッション 291 SwiftData: Dive into inheritance and schema migration に記載されています。 ↩↩↩ -
WWDC 2026 SwiftData Group Lab(セッション 8017)。WWDC 2026 SwiftData Group Lab のローカルに文字起こしした録音からの言い換えです。Apple はラボの公式キャプションを公開していません。widget と extension のマイグレーションゲーティング(1 つのプロセスがマイグレーションを所有する、エラーパスがマイグレーションのシグナルである、マイグレート済みバージョンを UserDefault に保存する)と、ステージ数による進捗手法(専用の進捗 API が存在しないため、ステージごとの didMigrate ハンドラをオーバーライドして M 件中 N 件目を報告する)は、SwiftData エンジニアリングパネルによって説明されました。
SchemaMigrationPlanとMigrationStage.customのdidMigrateシンボルは 1 と 4 で引用した Apple Developer ドキュメントと照合済みです。専用の進捗 API が存在しないことは、ラボでのパネル自身の枠組みを反映しています。 ↩↩