SwiftDataマイグレーション:軽量と独自実装、そしてV2が不要なケース
SwiftDataのスキーママイグレーションは、Core Dataから構造的に改善されています。しかし、多くのチームが陥る罠が一つあります。それは、SwiftDataがインラインのデフォルト値で自動処理してくれる変更に対して、新しいVersionedSchemaを宣言してしまうことです。その結果、コードは正しく見えてビルドも問題なく通るのに、デバイス上で「Duplicate version checksums across stages detected」というクラッシュが発生します。フレームワークの実際のマイグレーションモデルは、3つのピース(VersionedSchema、MigrationStage、SchemaMigrationPlan)と3種類のマイグレーション(自動軽量、宣言型軽量、独自実装)で構成されています1。スキーマ変更のほとんどは自動です。一部は宣言型の軽量ステージが必要となります。さらに少数のケースでは、willMigrateとdidMigrateクロージャを伴う独自実装ステージが必要になります。
本記事では、Appleのドキュメントに沿ってマイグレーションモデルを解説し、各マイグレーションタイプが扱うケースを明らかにし、iOS 26の新しいクラス継承サポートも取り上げます。「自分で何を宣言すべきで、何をSwiftDataに任せるべきか」という観点で整理しています。この判断こそが、マイグレーションがクリーンに出荷されるか、初回起動でクラッシュするかを左右するからです。
TL;DR
- SwiftDataのマイグレーションは3つのプロトコルで構成されます。
VersionedSchema(特定バージョンにおけるモデル型のスナップショット)、MigrationStage(.lightweightまたは.customケースを持つfromVersionからtoVersionへの単一遷移)、SchemaMigrationPlan(ステージの順序付きリスト)です1。 - インラインのデフォルト値(
var foo: Bool = false)を持つ新しい@Modelプロパティの追加には、新しいVersionedSchemaは不要です。SwiftDataがこの追加を軽量マイグレーションとして自動的に処理します。これに対してV2を宣言してしまうと「Duplicate version checksums across stages detected」クラッシュが発生します。 - 軽量マイグレーションが扱うのは、エンティティ・属性・リレーションシップの追加・名称変更・削除、リレーションシップタイプの変更、
@Attribute(originalName:)による名称変更の追跡、削除ルールの指定です。スキーマ変更の大部分はここに収まります。 - 独自マイグレーション(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))はデータ変換を扱います。1つのカラムを2つに分割する、派生フィールドを計算する、モデル間でデータを移動する、といったケースです。willMigrateは古いコンテキストを、didMigrateは新しいコンテキストを持ちます。 - iOS 26で
@Model型にクラス継承が追加されました2。継承を採用するスキーマは、以前のフラットモデルバージョンからの軽量ステージで新バージョンに上がります。
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が処理する遷移を宣言します。パラメータはVersionedSchema型自体(例:SchemaV1.self)であり、生のSchema.Version値ではありません。.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はコンテナ生成時に永続ストアの現在のスキーマバージョンを読み取り、そこから現行バージョンまでプランのステージを順に辿り、各ステージを順序通り適用します。
軽量マイグレーションが自動処理する範囲
スキーマ変更の多くは、独自ステージを必要としません1。
- デフォルト値付きの属性追加。 既存の
@Modelへのvar foo: Bool = falseは自動です。 - 新しいエンティティ(モデルクラス)の追加。 新しい型は、その
VersionedSchemaが現行バージョンとなった時点で出現します。既存データは保持されます。 - 属性またはエンティティの削除。 SwiftDataがカラムまたはテーブルを削除します。
- 属性またはエンティティの名称変更。 プロパティに
@Attribute(originalName: "oldName")を付けるとデータが保持されます。SwiftDataが旧名から新名へとマッピングします。 - リレーションシップタイプの変更。 1対多、多対多などの変更です。
- 削除ルールの指定。
@Relationship(deleteRule: .cascade)などの追加は軽量です。
このリストにある変更については、モデル型がそれ以外で変わっていない限り、新しいVersionedSchemaを宣言しないのが正しいパターンです。SwiftDataが既存のスキーマに対して軽量マイグレーションを自動実行します。
罠:フィールド追加にV2は不要
SwiftDataで最も多いマイグレーションのミスです。開発者がインラインのデフォルト値(var foo: Bool = false)で新しいプロパティを追加し、その後SchemaV1と同じモデル型を参照するSchemaV2を宣言してしまう。ビルドはクリーンに通ります。ところが、既存のV1データを持つデバイスでの初回起動時に「Duplicate version checksums across stages detected」でクラッシュします。SchemaV1とSchemaV2が同じチェックサムに解決されるためです(モデル型がSwiftDataから見て差異のある変更をしていないからです)。
正しいパターンはこうです。既存のVersionedSchemaはそのままにして、モデルにインラインのデフォルト値で新プロパティを追加し、SwiftDataの自動軽量マイグレーションに任せます。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スキーマが必要なのは、変更が軽量ではあり得ない場合だけです(データ変換、モデルの分割、独自ロジックを必要とする継承の再構築など)。そうしたケースでは、V2は実体を持つものとなり、SchemaMigrationPlanが遷移を統括します。
独自マイグレーションが必要になるケース
独自マイグレーションが複雑性に見合う価値を発揮するのは、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モデルに分割するような再編では、古いデータからタグを割り当てる独自ロジックが必要です。
一般的なパターンとしては、スキーマの形が変わるときは軽量、データの形が変わるときは独自実装、と考えるとよいでしょう。
willMigrateとdidMigrateの違い
独自ステージには2つのクロージャがあり、それぞれ異なるタイミングで呼び出されます4。
willMigrateは、SwiftDataがスキーママイグレーションを適用する前に実行されます。クロージャが受け取るモデルコンテキストは、古いスキーマのコンテキストです。データのキャプチャ、非正規化、スキーマが変更される前の補助的な状態の準備などに使います。
didMigrateは、スキーママイグレーションの後に実行されます。モデルコンテキストは新しいスキーマのものです。新しいフィールドのバックフィル、派生データの計算、マイグレーションの最終化などに使います。
どちらのクロージャも不要であればnilにできます。多くの独自マイグレーションは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)
}
}
継承を採用するスキーマは、以前のフラットモデルバージョンからの軽量マイグレーションステージで新バージョンに上がります。継承が既存プロパティを保持していれば遷移は自動で、サブクラスの新フィールドは標準的なインラインデフォルトのパターンに従います。
このパターンが合うのは、複数の@Model型が共通の特徴を持つケースです。Car、Truck、Motorcycleの子を持つVehicle親、CheckingAccount、SavingsAccountの子を持つAccount親などです。共有プロパティは親に置き、固有のものは子に置きます。
マイグレーションのテスト
コンパイルが通るマイグレーションが、出荷できるマイグレーションとは限りません。リリース前に実施しておきたいテストパターンを3つ紹介します。
1. 本番データベースのコピーに対するラウンドトリップテスト。 直近の本番形状のデータベースを取得する(あるいはテストを通じて合成のV1データを生成する)、それをV2対応のコンテナで開き、データが正しくマイグレーションされることを検証します。型チェッカーが捕捉できない独自マイグレーションのバグを捕まえられます。
2. 旧バージョンが起動できることの確認。 以前のアプリバージョンをビルドし、一度実行してV1データを生成、そして新バージョンをビルドしてクラッシュなしで起動することを確認します。「Duplicate version checksums」の罠や類似の宣言ミスを捕捉できます。
3. マイグレーション失敗時のリカバリー。 マイグレーションがthrowしたら何が起きるでしょうか。SwiftDataの挙動はコンテナの設定に依存します。本番アプリにおいて、未処理のマイグレーションエラーがユーザーデータを黙って削除するようなことはあってはなりません。失敗パスを明示的にテストし、アプリの挙動(ロールバック、プロンプト、バックアップからの復旧)を決めましょう。
クラスタのSingle Source of Truth postでは、SwiftDataストアがプロセス間同期によって置き換えられたときに何が起きるか、という関連する問いを扱っています。マイグレーションは、そのパターンのローカル進化版にあたります。
よくある失敗パターン
SwiftDataの障害ログから見える3つのパターンです。
SwiftDataが自動で扱う変更にV2を宣言する。 「Duplicate version checksums」クラッシュです。対処:インラインデフォルトのプロパティ追加に対して新スキーマを宣言しないこと。SwiftDataに自動処理を任せます。
保存しない独自マイグレーションコード。 エンティティを変更するのにcontext.save()を呼ばないdidMigrateクロージャは、一度実行されては作業を捨て、起動のたびに再実行されるマイグレーションを生み出します(マイグレーションが未完了に見えるためです)。対処:データを変更するすべてのクロージャは、return前にtry context.save()を必ず実行します。
@Attribute(originalName:)なしでのプロパティ名変更。 SwiftDataは新プロパティを新規として、旧プロパティを削除として扱うため、旧プロパティの既存データは捨てられます。対処:@Attribute(originalName: "oldName") var newName: ...を宣言し、SwiftDataがリネームを通じてデータをマッピングするようにします。
このパターンがiOS 26+アプリにとって意味すること
要点は3つです。
-
VersionedSchemaの階段はデフォルトで作らない。 インラインデフォルトでのプロパティ追加、未使用フィールドの削除、@Attribute(originalName:)での名称変更。すべて軽量で自動です。VersionedSchemaの階段が必要になるのは、SwiftDataが本当に自動処理できない変更(データ変換、独自ロジック、継承の再構築)の場合だけです。 -
MigrationStage.customはデータ変換に使い、スキーマ形状の変更には使わない。willMigrateとdidMigrateクロージャは、データに対して動作するコードのためのものであって、スキーマが変更されたことを宣言するためのものではありません。スキーマ形状の変更は軽量ステージを通します。 -
合成テストデータだけでなく、実際のV1データでマイグレーションをテストする。 合成のラウンドトリップで通るマイグレーションが、エッジケースを含む本番形状のデータでは失敗することがあります(スキーマがカバーしていないnullableフィールド、タイムアウトに達する大規模データセットなど)。テストのコストは小さく、初回起動時のマイグレーションクラッシュのコストは現実のものです。
Apple Ecosystemクラスタの全体像は次の通りです。型付きApp Intents、MCP servers、ルーティングの問い、Foundation Models、ランタイムとツールLLMの区別、3つのサーフェス、single source of truthパターン、Two MCP Servers、Apple開発のためのフック、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 Seriesにあります。AIエージェントを伴うiOSの広い文脈については、iOS Agent Development guideをご覧ください。
FAQ
常にSchemaMigrationPlanが必要ですか?
いいえ。単一のスキーマバージョンしか持たないアプリ(初回リリース、または軽量変更のみ行ってきたアプリ)にはSchemaMigrationPlanは不要です。ModelContainerイニシャライザはスキーマのモデルを直接受け取れます。migrationPlan:パラメータが必要になるのは、独自マイグレーションステージを初めて宣言したとき(または明示的なバージョン階段を初めて宣言したいとき)です。
自分の変更が軽量かどうかをどう見分けますか?
Appleの軽量対応リスト1:エンティティ・属性・リレーションシップの追加、それらの削除、@Attribute(originalName:)による名称変更、リレーションシップのカーディナリティ変更、削除ルールの指定です。変更がこのリストに当てはまり、モデルクラスの構造がそれ以外で変わっていなければ、マイグレーションは自動で、VersionedSchemaの階段は不要です。データ変換(計算、分割、移動)を要する変更であれば独自実装となります。
willMigrateとdidMigrateの両方を設定できますか?
はい。両方のクロージャは個別にオプションですが、両方とも提供できます。willMigrateはSwiftDataがマイグレーションする前に旧スキーマのコンテキストに対して実行され、didMigrateは後で新スキーマのコンテキストに対して実行されます。2つで準備と最終化をそれぞれカバーします。
マイグレーションがエラーをthrowしたら何が起きますか?
エラーはModelContainerの初期化から伝播します。コンテナのオープンが失敗します。アプリの挙動は開発者がエラーをどう扱うかに依存します。リカバリーUIを表示するアプリ、バックアップから復旧を試みるアプリ、破損したストアを削除して新規開始するアプリなどがあります。SwiftDataはマイグレーション失敗時にユーザーデータを黙って削除しません。失敗の対処はアプリ側の責務となります。
本番データに影響を与えずにマイグレーションをテストするには?
一時ファイルURLを指すModelContainerを作成するテストターゲットを構築し、V1データを投入して、マイグレーションプランを含む新コンテナで開きます。マイグレーション後のデータが期待通りであることを検証します。このパターンはユニットテストでも統合テストでも機能します。最も現実的な結果を得るには、実際の本番形状のデータベースのコピーを使うのがよいでしょう。
iOS 26のクラス継承は既存スキーマで動きますか?
はい、軽量マイグレーションで動きます。継承を採用するアプリは新しいスキーマバージョン(例:V4)に上がり、MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)を宣言します。フラットな親クラスのプロパティはそのまま残り、サブクラス固有のプロパティはインラインデフォルトで追加されます。SwiftDataの軽量マイグレーションが構造変更を処理します。
参考文献
-
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で文書化されています。 ↩