← すべての記事

SwiftDataの本当のコストはスキーマ規律にある

Get BananasのShoppingItemは、SwiftDataのスキーマ規律がなぜ重要かを示す代表的な例です。元のスキーマにはlastModifiedタイムスタンプが含まれておらず、後から追加する際には特定のマイグレーション形式が必要でした。なぜなら既存のデータがすでにディスク上に存在しており、このフィールドは最初に非オプショナルとして追加した際にマイグレーションクラッシュを引き起こしたため、それを修正するために特別にオプショナルとされたからです。1

SwiftDataのAPIはマクロ2つで成り立っています。クラスに@Modelを付けると永続化型になります。プロパティに@Attribute(.unique)を付けると一意性制約が与えられます。フレームワークは、Core Dataのスタック管理、値変換のお作法、NSManagedObjectContextのボイラープレートを隠蔽してくれます。フレームワークが隠してくれないものは、スキーマのマイグレーションです。マイグレーションを命令的ではなく宣言的に書けるようにしてくれるだけです。マイグレーションに注意を払わないことのコストは、通常のアップデートでユーザーのデータを消し飛ばすバグとなって現れます。

主張はこうです。SwiftDataは始めるのは安いが、雑にマイグレーションすると高くつく。規律とは、命名・オプショナル性・VersionedSchemaを初日から取り入れることであり、後で気づいた日からではありません。

TL;DR

  • @Modelマクロはクラスを永続化可能なSwiftData型に変えます。フレームワークはコンパイル時にプロパティ宣言からスキーマを生成します。
  • 新しいオプショナルプロパティの追加はノーオペマイグレーションです。SwiftDataの軽量マイグレーションが処理してくれます。既存スキーマに非オプショナルプロパティを追加するには、VersionedSchemaに加えて、既存行に対して新フィールドをどのように埋めるかをフレームワークに指示するMigrationPlanが必要となります。
  • 初日からVersionedSchemaを採用しないコストは、v2で重要なスキーマ変更を行ったときにユーザーのデータベースを失うリスクとなります。軽量マイグレーション経路は保守的で、マイグレーションを推測できない場合は処理を中止するためです。
  • @Attribute(.unique)は自然キー(自分で生成したUUID、インポートした外部ID)に適したツールです。@Relationshipは親子参照に適したツールです。どちらもマクロであり、内部で適切なCore Dataの配管を生成してくれます。2

@Modelが実際に行うこと

SwiftData型とは、@Modelマクロを適用したSwiftクラスのことです。Get BananasのShoppingItemはその代表的な形です。

import Foundation
import SwiftData

@Model
final class ShoppingItem {
    @Attribute(.unique) var id: UUID
    var name: String
    var amount: String
    var section: String
    var isChecked: Bool
    var isOptional: Bool
    var sortOrder: Int
    var lastModified: Date?

    init(id: UUID = UUID(), name: String, amount: String, section: String,
         isOptional: Bool = false, sortOrder: Int = 0) {
        self.id = id
        self.name = name
        self.amount = amount
        self.section = section
        self.isChecked = false
        self.isOptional = isOptional
        self.sortOrder = sortOrder
        self.lastModified = Date()
    }
}

この形について、APIが隠している3つのディテールがあります。

@Modelは、永続ストアのスキーマ宣言を別途必要としません。 SwiftDataはコンパイル時にクラス定義を読み取り、スキーマを合成します。クラスのプロパティはモデルの属性となり、Swiftの型はカラムの型となります。維持すべき.xcdatamodeldファイルはありません(とはいえCore Dataの基盤であるNSManagedObjectModelは依然として存在し、ランタイム時にスキーマを支えています)。2

@Attribute(.unique)は単一カラムへの制約であり、PRIMARY KEY宣言ではありません。 SwiftDataの永続的アイデンティティはPersistentIdentifierであり、行ごとに自動生成されます。@Attribute(.unique)宣言は「このカラムは値ごとに最大1行までしか格納しない」とフレームワークに伝えるものです。すでに存在する.unique値を持つモデルを挿入すると、SwiftDataはアップサートを実行します。既存の行は拒否されるのではなく更新されるのです。プロダクトコードにおいてこのセマンティクスは重要です。.uniqueは、UI レベルで重複の送信を防ぐバリデーションではなく、静かにマージしてしまう「最大1個」のストレージ保証なのです。上記のid: UUIDパターンは、プロセス間同期に推奨される形(プロセス内のPersistentIdentifierが消失しても残る安定した識別子が欲しい場合)であり、アップサートの挙動は同じUUIDが2つの同期パスから到着したときにまさに望む振る舞いとなります。

@Modelクラスは値型ではなく参照型です。 ShoppingItemインスタンスのプロパティを変更するとSwiftDataの変更追跡がトリガーされ、フレームワークがその変更を登録し、次のコンテキスト保存時に永続化します。@Queryを介したSwiftUI統合により、一致するプレディケートを観測しているビューが再描画されます。このパターンは@ObservableWhat SwiftUI Is Made Ofで扱っています)に似ており、その上に永続化が重ねられています。

オプショナルなフィールドは安価なマイグレーション

ShoppingItemlastModified: Date?フィールドはオプショナルであり、このオプショナル性が肝心です。このフィールドはv1リリース後にクロスデバイス同期と競合解決のサポートのために追加されたもので、ユーザー端末上の既存行にはlastModifiedの値がありませんでした。デフォルト値のないオプショナルフィールドであれば、SwiftDataの軽量マイグレーションがマイグレーションコードを書かずに追加を処理してくれます。既存行はnilを、新規行はinitが設定する値を取得することになります。3

軽量マイグレーション経路はフレームワークの礼儀正しい経路です。SwiftDataは新スキーマと永続ストアを検査し、最小の互換変更を推測して適用します。マイグレーションは自動的に行われ、ユーザーには何も見えず、アプリは既存データのまま通常通り起動します。軽量経路がきれいに処理できるケースは次のとおりです。

  • オプショナルプロパティの追加
  • プロパティの削除(データは破棄され、既存の読み取りはそのカラムを見られなくなる)
  • フレームワークがヒントによって対応関係を把握できる属性のリネーム(@Attribute(originalName: ...)を使用)
  • フレームワークが対応関係を把握できる@Modelクラスのリネーム(@Model.originalNameまたはヒントを使用)

軽量経路が処理を中止するケースは次のとおりです。

  • 既存スキーマへの、デフォルト値を持たない非オプショナルプロパティの追加(既存行には埋めるべき値がない)
  • プロパティ型の変更(例: IntString
  • モデルを2つに分割する、または2つを1つにマージする
  • マイグレーションにカスタムロジックが必要なもの

軽量経路が処理を中止した場合、安全な振る舞いはマイグレーションを失敗させることです。安全でない振る舞いはデータベースを破棄してやり直すことであり、フレームワークは保守的なので暗黙にそれをすることは拒否します。ユーザーは起動時にマイグレーションエラーでアプリがクラッシュするのを目にし、開発者はスキーマ不一致を指すスタックトレースを見ます。データを失う人はいませんが、信頼を失う人は皆出るというわけです。

初日からVersionedSchemaを省略するコストは、v2 → v3の境界で現れます。軽量経路が扱える範囲を超えるスキーマ変更を伴う3つ目の機能を追加したときです。

VersionedSchemaとMigrationPlan: 初日からの規律

VersionedSchemaはモデルスキーマの特定バージョンを宣言します。MigrationPlanはあるバージョンから次のバージョンへのマイグレーション方法を宣言します。4 その形はこうです。

import SwiftData

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

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

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        SchemaV1.self,
        SchemaV2.self,
    ]

    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
    ]
}

モデルクラス自体はバージョン付きスキーマの名前空間内に移動します。

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

ModelContainerはマイグレーションプランとともに構築されます。

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

マイグレーションプランは、スキーマがどのように進化するかの型付けされたグラフをフレームワークに与えます。v2リリース版のアプリがv1のデータベースに対して起動すると、フレームワークはマイグレーションプランをたどり、名前付きステージを適用し、データベースをv2まで持ち上げます。v3をリリースする際には、SchemaV3.selfschemasに追加し、v2とv3の間に新しいMigrationStageを追加するだけです。

規律とは、バージョンが1つしかなくてもv1からVersionedSchemaをリリースすることです。そのコストは、ファイル1つとenum宣言1つが追加で増えるだけです。そうしないコストは、v2の最初の重要なスキーマ変更時にv1を遡及的にVersionedSchemaでラップする必要が生じることです。これは可能ではあるものの、フレームワークが既存データをSchemaV1として識別できるように正確なv1の形に合わせる注意が要ります。v2を担当する未来のあなたが税金を払うことになります。今のあなたが一度払って忘れることもできるのです。

カスタムMigrationStageによる難しいケースへの対応

軽量マイグレーションは大半の追加的変更をカバーします。型変更、分割、マージ、条件付きの値設定にはMigrationStage.customが必要です。

static var stages: [MigrationStage] = [
    MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Read v1 rows; stage any derived state to a transient store
            // (UserDefaults / temp file) since the v1 and v2 contexts do
            // not share state, and didMigrate cannot read v1.
            let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
            stageDerivedState(from: v1Items)
        },
        didMigrate: { context in
            // Populate v2-only fields on existing rows
            let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
            for item in v2Items where item.lastModified == nil {
                item.lastModified = Date()
            }
            try context.save()
        }
    )
]

2つのクロージャは、フレームワークが構造的マイグレーションを適用する前と後にそれぞれ発火します。willMigrateはv1スキーマに対して実行され、didMigrateはv2スキーマに対して実行されます。クロージャの本体は通常のSwiftDataコード(フェッチディスクリプタ、モデルコンテキストの保存、稼働中のアプリで使われるのと同じAPI)であり、マイグレーション中の一時的なコンテキストに対して動作します。

本番で生き残るパターンは、willMigrateを空のままにして、すべての値設定ロジックをdidMigrateに置くことです。willMigrateの中でv1データを読むことは許されていますが、フレームワークの観点ではv2スキーマがまだ存在しないため、計算結果はすべてdidMigrateクロージャが読める一時的なストアに退避させる必要があります。よりシンプルなルールはこうです。構造的マイグレーションはフレームワークの仕事、既存行のv2専用フィールドへの値設定はdidMigrateの仕事です。

@Attribute@Relationshipがその名にふさわしい場面

@Modelクラスにおけるスキーマ装飾の大半は、2つのマクロが担います。

@Attributeは単一プロパティに制約またはヒントを装飾します。

  • @Attribute(.unique)ShoppingItem.idのように一意性を強制します
  • @Attribute(.externalStorage)は大きなDataブロブをデータベース外に格納します(画像データ、オーディオバッファなど)
  • @Attribute(originalName: "old_field_name")はマイグレーション時にプロパティをリネーム済みカラムにマッチさせます
  • @Attribute(.transformable(by: ...))は非Codable型にValueTransformerを適用します

正しい規律はこうです。本当に一意であるべきフィールド(生成したUUID、外部ID)には.uniqueを使い、数KBを超えるブロブには.externalStorageを使い、プロパティのv2でのリネームによってv1データが失われそうな場合にはoriginalNameを使うことです。

@Relationshipは別の@Modelクラスやその集合を指すプロパティを装飾します。

@Model
final class List {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
    var items: [ShoppingItem] = []
}

@Model
final class ShoppingItem {
    var name: String
    var list: List?
}

deleteRule: .cascadeは、親のListを削除するとすべての子ShoppingItem行も削除されることを意味します。inverse:パラメータは、子のどのプロパティが親を指し戻しているかをフレームワークに伝えるもので、フレームワークはそれを予測可能な双方向メンテナンスに使います。SwiftDataは逆参照を自動推測できる場合もあり、明示的に単方向としたい関係にはinverse: nilもサポートされていますが、推測が曖昧になり得るときは常にinverse:を宣言するのが安全なデフォルトです。5

正しい規律はこうです。関係を宣言するときは明示的なdeleteRuleを付けること(デフォルトは.nullifyですが、これが望ましいことはほぼありません)、そして双方向の関係であれば常にinverse:を宣言すること(フレームワークの推測に頼らないこと)です。暗黙のデフォルトはたいてい間違っており、明示形式はパラメータが1つ増えるだけで、永遠に保存されるバグを防げます。

自分なら違うやり方で構築する点

クラスター内のアプリが採用している、または採用していればよかったと思う3つのパターンです。

v1からVersionedSchemaをリリースする。 リリースされる@Modelクラスはすべて初日からVersionedSchema内で生きるべきです。コストはスキーマバージョンごとにラップするenumが1つ増えるだけ。利点は、v2の最初の重要な変更が、2日がかりの遡及リファクタリングではなく、MigrationPlan.schemasへの1行追加で済むことです。

すべてのタイムスタンプをオプショナルにする。 クロスデバイス同期や競合解決のために存在するlastModifiedcreatedAtupdatedAtのようなフィールドは、v1のプロダクトがそれらを必要としないなら、v1ではオプショナルにすべきです。オプショナル性は、(必要となる)v2へのマイグレーションを安価に保ちます。didMigrate中に既存行を埋めるのはループ1つで済みます。一方でv1から非オプショナルにすることは、ユーザーデータのバックフィルを壊しかねない制約となります。

PersistentIdentifierではなくUUIDを自然キーとして使う。 SwiftDataのPersistentIdentifierはプロセス内のものです。クロスデバイス同期、MCP統合(Two Agent Ecosystems, One Shopping Listで扱っています)、その他あらゆるプロセス外参照には、安定した識別子が必要です。@Attribute(.unique)を付けたUUIDが正しい形であり、プロセス境界をまたぐものに対して、プロセス内のPersistentIdentifierは誤った形となります。

@Modelが間違った答えとなる場面

SwiftDataが正しいツールではない3つのケースです。

単一レコードのキー/バリュー状態。 アプリ設定、ユーザーが選択した言語、最後の同期のタイムスタンプ。UserDefaultsまたはNSUbiquitousKeyValueStoreFive Apple Platforms, Three Shared Filesで扱っています)を使ってください。1行のためにSwiftDataのオーバーヘッドを払うのは無駄な儀式であり、キー/バリューストアが正しい基盤です。

オフラインの書き込みがないサーバー権威データ。 REST APIから取得して読み取り専用で表示するリストなど。真実の源がサーバーにあり、ローカルキャッシュが単なるキャッシュなら、SwiftDataはオーバーキルです。Documents/内のシンプルなCodableスナップショットとメモリキャッシュした配列で十分であり、データがハードリセットを生き延びる必要がないなら、SwiftDataのマイグレーション税を払う価値はありません。

マルチプロセスの協調。 SwiftDataはプロセス内で動作します。iOSアプリ外で動作するMCPサーバーは、アプリのSwiftDataコンテナを読み書きできません。プロセス間の状態には別の形が必要です。iCloud Drive JSONファイル、共有App Groupコンテナ、あるいはプロセス間を橋渡しする明示的な同期レイヤーなどです。(Get Bananasがまさにこの理由でSwiftDataとiCloud Drive JSONを組み合わせています。)6

データがめったに変わらない大きなブロブの場合。 10MBのオーディオファイル、50MBの画像データセットなど。ブロブがSwiftDataの行内にあるなら@Attribute(.externalStorage)を使い、そうでなければファイルシステムを直接使い、ファイルURLを指すメタデータをSwiftDataに置くのが良いでしょう。

このパターンがiOS 26+でリリースするアプリにとって意味すること

3つのテイクアウェイです。

  1. マクロは簡単な部分です。マイグレーションこそコストです。 @Model@Attributeは2行の宣言で多くのCore Data配管を隠してくれます。マイグレーション規律こそ、アプリのライフタイムを通じて実際に支払うものです。v1はv2を見据えて設計しましょう。

  2. 初日からのVersionedSchemaは、リリースするアプリにとって譲れません。 ラップするenumはファイル1つの追加です。後から追加する遡及コストははるかに高くなります。

  3. オプショナルフィールドと明示的な関係は、安い保険です。 同期メタデータのためのオプショナルなタイムスタンプ、関係に対する明示的なdeleteRuleinverse:。どちらも小さな宣言ですが、v2における大きな柔軟性を買ってくれます。

Apple Ecosystemクラスターの全体像はこうです。Apple Intelligenceのための型付きApp Intents、クロスLLMエージェントのためのMCPサーバー、両者の間のルーティングの問い、オンデバイスLLMとToolプロトコルのためのFoundation Models、iOS上のロックスクリーンステートマシンのためのLive Activities、Apple Watch上のwatchOSランタイム契約、フレームワーク基盤のためのSwiftUI内部、visionOSシーンのためのRealityKitの空間メンタルモデル、ビジュアルレイヤーのためのLiquid Glassパターン、クロスデバイス到達のためのマルチプラットフォームのリリース。ハブはApple Ecosystem Seriesにあります。iOSとAIエージェントのより広い文脈については、iOS Agent Development guideをご覧ください。

FAQ

@ModelとCore DataのNSManagedObjectの違いは何ですか?

@Modelは内部でNSManagedObjectの配管を生成するSwiftマクロです。SwiftDataはCore Dataを裏付けのストアとして使うため、ランタイムのモデルは同じです。違いは表面にあります。@Model.xcdatamodeldファイル、値変換のセレモニー、そしてNSManagedObjectContextのライフサイクル管理を取り除きます。Swift風のAPIで同じ永続ストアが手に入るのです。

スキーマを変更する予定がなくてもVersionedSchemaは必要ですか?

アプリがv2をリリースする可能性があるなら、必要です。一回限りのデモなら、不要です。v1からVersionedSchemaを入れるコストは、enum宣言が1つ増えるだけ。v2で遡及的に追加するコストは、フレームワークが既存データを認識できるようにv1の正確なスキーマ形状に合わせることであり、可能ですがミスを誘いやすい作業です。リリースされるほとんどのアプリは、いずれスキーマ変更が必要になります。v1のうちに予算を組んでおきましょう。

@Attribute(.unique)はいつ使うべきですか?

そのフィールドが行の自然キーである場合に使います。生成したUUID、インポートした外部ID、割り当てたスラグなどです。SwiftDataは.uniqueをアップサートとして扱います。すでに存在する.unique値を持つモデルを挿入すると、新しい行が追加されるのではなく既存の行が更新されます。このセマンティクスのおかげで、アップサート型の同期パス(同じUUIDが2つの端末から到着する場合など)が安全になります。同時にこの理由から、titleのような表示名フィールドに.uniqueを使うのは間違いです。なぜなら同じタイトルを入力した2人のユーザーが、別々のレコードを生み出さずに静かに行をマージしてしまうからです。

既存スキーマに非オプショナルフィールドを追加するにはどうすればよいですか?

MigrationStage.customと、既存行にフィールドを設定するdidMigrateクロージャを使います。あるいはもっと簡単な方法として、新しいスキーマバージョンではフィールドをオプショナルとして宣言し、アクセス時に遅延的に埋めることです。オプショナル性は安価なマイグレーションです。非オプショナルの追加には、明示的な値設定ロジックが必要となります。

PersistentIdentifierと自前のUUIDの違いは何ですか?

PersistentIdentifierはSwiftDataのプロセス内行IDで、自動生成され、稼働中のプロセスのライフタイムを通じて存続します。@Attribute(.unique)を付けた自前のUUIDは、安定したプロセス間・デバイス間の識別子です。アプリ内のプロセス内参照にはPersistentIdentifierを使い、プロセス境界をまたぐものすべて(クロスデバイス同期、外部統合、MCPツール、ネットワーク呼び出し)にはUUIDを使ってください。

References


  1. Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The ShoppingItem model evolved across the early development cycle; the lastModified: Date? field was added after the initial schema (commit 268a00d on 2025-12-01, “Make lastModified optional to fix migration crash”) because making it non-optional broke migration when existing rows had no value to populate it. 

  2. Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The @Model macro, the @Attribute constraint surface, and the relationship to Core Data’s NSManagedObjectModel

  3. Apple Developer, “Preserving your app’s model data across launches” and “Adopting SwiftData for a Core Data app”. Lightweight migration semantics and what triggers the framework to bail. 

  4. Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the ModelContainer constructor that takes a migration plan. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The @Relationship macro, deleteRule options (.cascade, .nullify, .deny, .noAction), and the role of the inverse: parameter in bidirectional relationship maintenance. 

  6. Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and Five Apple Platforms, Three Shared Files. The Get Bananas + Return cross-process and cross-device sync patterns that complement (and sometimes replace) SwiftData inside a multi-process workflow. 

関連記事

SwiftDataマイグレーション:軽量と独自実装、そしてV2が不要なケース

SwiftDataのマイグレーションモデルは、VersionedSchema、MigrationStage、SchemaMigrationPlanで構成されます。スキーマ変更の多くはV2スキーマを必要としませんが、必要なケースでは確実に必要…

2 分で読める

iOS 27のSwiftData:監視と履歴

iOS 27では、ResultsObserverによる第一級の変更監視、HistoryObserverによる永続履歴の監視、そしてcodable属性ストレージがSwiftDataに加わります。

1 分で読める

クリーンアップレイヤーこそが本当のAIエージェント市場である

Charlie Labsはエージェント構築から、エージェントの後始末をする側へとピボットしました。AIエージェント市場は生成から証明へと移行しています。クリーンアップこそが永続的なレイヤーなのです。

2 分で読める