← すべての記事

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

ジャンル: shipped-code。この記事では、Get Bananas、Return、RepsにおけるSwiftDataのスキーマ判断をドキュメント化しています。3つのアプリのうち、スキーマがクリーンなマイグレーションを生き残ったもの、あるいはマイグレーション計画を立てなかったために代償を払ったもの、それぞれの事例があります。Get BananasのShoppingItemは典型例です。元のスキーマにはlastModifiedタイムスタンプが含まれておらず、後から追加する際には特定のマイグレーション形式が必要でした。既存データがすでにディスク上にあったためです。このフィールドが最初に非オプショナルとして追加されたときに発生したマイグレーションのクラッシュを修正するため、オプショナルにされました。1

SwiftDataのAPIは2つのマクロです。クラスに@Modelを付与すれば永続化型になります。プロパティに@Attribute(.unique)を付与すれば一意性制約が与えられます。フレームワークはCore Dataのスタック管理、value-transformerの煩雑な処理、NSManagedObjectContextのボイラープレートを隠蔽してくれます。フレームワークが隠さないものは、スキーマのマイグレーションです。命令的ではなく宣言的にしているだけなのです。マイグレーションに注意を払わないコストは、ありふれたアップデートでユーザーのデータが消えるバグとなって現れます。

主張はこうです。SwiftDataは始めるのが安く、ぞんざいにマイグレーションするのが高くつきます。規律とは、命名、オプショナリティ、そして初日からのVersionedSchemaであって、必要だと気づいた日からではないのです。

TL;DR

  • @ModelマクロはクラスをSwiftDataの永続化型に変えます。フレームワークはコンパイル時にプロパティ宣言からスキーマを生成します。
  • 新しいオプショナルプロパティの追加はノーオペレーションマイグレーションです。SwiftDataの軽量マイグレーションが処理してくれます。既存スキーマへの非オプショナルプロパティの追加には、フレームワークに既存行の新フィールドの埋め方を伝えるVersionedSchemaMigrationPlanが必要となります。
  • 初日から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
  • 1つのモデルを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をリリースする際は、schemasSchemaV3.selfを追加し、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)であり、一時的なin-migrationコンテキストに対して動作します。

プロダクションを生き抜くパターンは、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の最初の些細でない変更が、MigrationPlan.schemasへの1行追加であり、2日がかりの遡及的リファクタリングではないことです。

すべてのタイムスタンプをオプショナルにする。 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またはNSUbiquitousKeyValueStoreを使ってください(Five 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)を使い、そうでなければファイルシステムを直接使い、SwiftDataにはファイルURLを指すメタデータを置いてください。

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

3つの要点です。

  1. マクロは簡単な部分です。マイグレーションがコストです。 @Model@Attributeは、多くのCore Data配管を隠す2行宣言です。マイグレーション規律こそが、アプリの寿命を通じて実際に支払うものです。v2を念頭にv1を設計してください。

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

  3. オプショナルフィールドと明示的な関係性は、安価な保険です。 同期メタデータ用のオプショナルなタイムスタンプ、関係性での明示的なdeleteRuleinverse:。どちらも小さな宣言で、多くのv2の柔軟性を買えるのです。

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

FAQ

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

@Modelは、内部でNSManagedObjectの配管を生成するSwiftマクロです。SwiftDataはバッキングストアとしてCore Dataを使用するため、ランタイムモデルは同じです。違いは表面です。@Model.xcdatamodeldファイル、value-transformerの儀式、NSManagedObjectContextのライフサイクル管理を取り除きます。Swift形のAPIで同じ永続化ストアが手に入るのです。

スキーマを変更するつもりがない場合でもVersionedSchemaは必要ですか?

アプリがv2をリリースする可能性があるなら、必要です。一回限りのデモなら、不要です。v1からVersionedSchemaを入れるコストは、追加enum宣言1つです。v2で遡及的に追加するコストは、フレームワークが既存データを認識できるようv1の正確なスキーマ形に合わせることであり、可能ですがエラーが起きやすいものです。リリースするアプリの大半は、最終的にスキーマ変更が必要になります。v1でその予算を組んでください。

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

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

既存スキーマに追加された非オプショナルフィールドはどう扱いますか?

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

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

PersistentIdentifierはSwiftDataのプロセス内行IDで、自動生成され、稼働中プロセスの寿命の間生き残ります。@Attribute(.unique)を持つ自前のUUIDは、安定的なプロセス間・端末間の識別子です。アプリ内のプロセス内参照にはPersistentIdentifierを使い、プロセス境界を越えるもの(クロスデバイス同期、外部統合、MCPツール、ネットワーク呼び出し)にはUUIDを使ってください。

References


  1. 著者のGet Bananas、SwiftUIのショッピングリストアプリで、SwiftDataとiCloud DriveのJSON同期、MCPサーバーをペアにしています。ShoppingItemモデルは初期開発サイクルで進化しました。lastModified: Date?フィールドは初期スキーマの後に追加されました(2025-12-01のコミット268a00d、「Make lastModified optional to fix migration crash」)。非オプショナルにすると、既存行に埋める値がないためマイグレーションが壊れたためです。 

  2. Apple Developer、“SwiftData”および“Adding and editing persistent data in your app”@Modelマクロ、@Attribute制約のサーフェス、Core DataのNSManagedObjectModelとの関係。 

  3. Apple Developer、“Preserving your app’s model data across launches”および“Adopting SwiftData for a Core Data app”。軽量マイグレーションのセマンティクスとフレームワークが中断する条件。 

  4. Apple Developer、“VersionedSchema”および“SchemaMigrationPlan”。バージョン化されたスキーマ宣言、マイグレーションステージ定義、マイグレーションプランを取るModelContainerコンストラクタ。 

  5. Apple Developer、“Defining data relationships with enumerations and model classes”および“Schema.Relationship”@Relationshipマクロ、deleteRuleオプション(.cascade.nullify.deny.noAction)、双方向関係性メンテナンスにおけるinverse:パラメータの役割。 

  6. 著者の分析、Two Agent Ecosystems, One Shopping List、2026年4月29日、およびFive Apple Platforms, Three Shared Files。マルチプロセスワークフロー内でSwiftDataを補完(時には置換)するGet Bananas + Returnのプロセス間・端末間同期パターン。 

関連記事

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 分で読める

Foundation Models On-Device LLM: The Tool Protocol

iOS 26's Foundation Models framework puts a 3B-parameter LLM on every Apple Intelligence device. The Tool protocol is th…

15 分で読める

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 分で読める