← すべての記事

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

Get BananasのShoppingItemは、SwiftDataにおけるスキーマの規律がなぜ重要なのかを示す典型例です。最初のスキーマにはlastModifiedというタイムスタンプが含まれていませんでした。後から追加するには特定の形のマイグレーションが必要になります。すでに既存のデータがディスク上にあったからです。しかもこのフィールドがOptionalなのは、最初に非Optionalとして追加したときに起きたマイグレーションのクラッシュを修正するためでした。1

SwiftDataのAPIはマクロ2つだけです。classに付ける@Modelが、その型を永続化される型にします。プロパティに付ける@Attribute(.unique)が一意性の制約を与えます。フレームワークはCore Dataのスタック管理、value transformerをめぐる儀式、NSManagedObjectContextのボイラープレートを隠してくれます。隠してくれないものが、スキーマのマイグレーションです。フレームワークはそれを命令的ではなく宣言的にするだけなのです。マイグレーションに注意を払わなかった代償は、何気ないアップデートでユーザーのデータを消し飛ばすバグという形で現れます。

本稿の主張はこうです。SwiftDataは始めるのが安く、雑にマイグレーションすると高くつきます。守るべき規律は、命名、Optional性、そして初日からのVersionedSchema。「やっておけばよかった」と気づいた日からでは遅いのです。

TL;DR

  • @ModelマクロはclassをSwiftDataの永続型に変えます。フレームワークはプロパティ宣言からコンパイル時にスキーマを生成します。
  • Optionalなプロパティの追加は、実質何も起きないマイグレーションです。SwiftDataのlightweight migrationが処理してくれます。既存スキーマへの非Optionalなプロパティの追加には、VersionedSchemaに加えて、既存の行の新しいフィールドをどう埋めるかをフレームワークに伝えるMigrationPlanが必要です。
  • 初日からVersionedSchemaを用意しない代償は、v2で少しでも込み入ったスキーマ変更を行うたびにユーザーのデータベースを失うリスクが生じる点にあります。lightweightな経路は保守的で、マイグレーションを推論できないときは処理を打ち切るからです。
  • @Attribute(.unique)は自然キー(自分で生成したUUID、インポートした外部ID)に適したツールです。@Relationshipは親子の参照に適したツールです。どちらもマクロであり、裏側で適切なCore Dataの配管を生成します。2

@Modelが実際に行っていること

SwiftDataの型とは、@Modelマクロを適用したSwiftのclassです。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はコンパイル時にclassの定義を読み取り、スキーマを合成します。classのプロパティがモデルの属性となり、そのSwiftの型がカラムの型となります。メンテナンスすべき.xcdatamodeldファイルは存在しません(ただしCore Dataの基盤であるNSManagedObjectModelは依然として存在し、実行時にスキーマを支えているのはこちらです)。2

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

@Modelのclassは値型ではなく参照型です。 ShoppingItemインスタンスのプロパティを変更すると、SwiftDataの変更追跡が働きます。フレームワークがその変更を登録し、次のコンテキスト保存で永続化します。@QueryによるSwiftUIとの統合では、一致するpredicateを監視しているビューが再描画されます。このパターンは@ObservableSwiftUIは何でできているかで解説)に似ており、その上に永続化が重ねられた形です。

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

ShoppingItemlastModified: Date?はOptionalであり、そのOptional性が構造を支えています。このフィールドはv1のリリース後、デバイス間の同期と競合解決のために追加されました。ユーザーの端末にある既存の行にはlastModifiedの値がありません。デフォルト値のないOptionalフィールドであれば、SwiftDataのlightweight migrationがマイグレーションコードを一切書かずに追加を処理してくれます。既存の行にはnilが入り、新しい行にはinitが設定した値が入るわけです。3

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

  • Optionalなプロパティの追加
  • プロパティの削除(データは破棄され、既存の読み取りではそのカラムが見えなくなります)
  • フレームワークがヒントで対応付けられる属性のリネーム(@Attribute(originalName: ...)を使用)
  • フレームワークが対応付けられる@Modelクラスのリネーム(@Model.originalNameまたはヒントを使用)

lightweightな経路が処理を打ち切るのは次のケースです。

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

lightweightな経路が打ち切るとき、安全な挙動はマイグレーションを失敗させることです。危険な挙動はデータベースを捨ててやり直すことでしょう。フレームワークは保守的で、それを黙って行うことを拒みます。ユーザーには起動時にマイグレーションエラーでクラッシュする様子が見え、開発者にはスキーマの不一致を指すスタックトレースが見えます。データを失う人はいませんが、全員が信頼を失います。

初日からVersionedSchemaを省いた代償は、v2 → v3の境界で表面化します。lightweightな経路の守備範囲を超えるスキーマ変更を伴う、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)
    ]
}

モデルのclass自体は、バージョン付きスキーマの名前空間の中へ移動します。

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を足します。どの変更が自動で、どれに明示的なステージが必要か、そして不要なV2を宣言したときに踏むチェックサムのクラッシュまで含めたマイグレーションモデルの全体像は、姉妹編のSwiftDataのマイグレーション:lightweightとカスタムで扱っています。

守るべき規律は、バージョンが1つしかない段階でも、v1の時点でVersionedSchemaをリリースすることです。そのコストはファイル1つとenum宣言1つ。やらなかった場合のコストは、v2で最初の自明でないスキーマ変更を行うときに、v1をさかのぼってVersionedSchemaで包む作業です。これは不可能ではありませんが、既存のデータをSchemaV1としてフレームワークに識別させるため、v1の形を寸分たがわず一致させる注意が要ります。v2に取り組む未来の自分がその税を払うことになります。今の自分なら、一度払って忘れてしまえるのです。

難しいケースのためのカスタムMigrationStage

lightweight migrationは、追加系の変更のほとんどをカバーします。型の変更、分割、統合、条件付きの値の埋め込みには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のコード(fetch descriptor、model contextの保存など、動作中のアプリで使うのと同じAPI)で、マイグレーション中の一時的なコンテキストを相手に動きます。

本番で生き残るパターンは、willMigrateを空のままにして、値を埋めるロジックをすべてdidMigrateに置くことです。willMigrateの中でv1のデータを読むこと自体は許されていますが、フレームワークから見るとv2のスキーマはまだ存在しません。そのため、何らかの計算結果はdidMigrateクロージャが読める一時的なストアへ退避しておく必要があります。もっと簡潔なルールにするならこうです。構造的なマイグレーションはフレームワークの仕事、既存の行にv2固有のフィールドを埋めるのはdidMigrateの仕事。

@Attribute@Relationshipが名に値する働きをするとき

@Modelのclassにおけるスキーマ装飾の大半は、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つ分の手間で、永久にバグを1つ防いでくれるのです。

アクターの境界を越える:グラフではなく識別子を渡す

@ModelのclassはSendableではありません。そして正しい判断は、Sendableにしようとするのをやめることです。インスタンスはModelContextが保持する生きたオブジェクトグラフへの参照であり、そのグラフを別のアクターから読んで安全だとフレームワークは約束できません。だからこの型は、あえて非Sendableのまま残されています。準拠を無理に付けてもデータ競合は消えません。隠れるだけです。7

うまくいくパターンは、同一性と素の値だけを渡し、受け取った側で再フェッチすることです。PersistentIdentifierSendableなので、境界をきれいに越えられます。渡し先が必要とするスカラー値(名前、フラグ、小さなstructに入れた差分)を取り出し、識別子と一緒に渡します。受け取ったアクターは、その識別子を使って自分のコンテキストからモデルを再フェッチします。

// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)

// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem

避けるべき失敗は、モデルのグラフそのものを渡すことです。グラフの一部が境界を越えると、受け手は向こう側で部分的にしか実体化しないモデルを手にします。元のコンテキストでフォルトインされていなかったリレーションシップや遅延読み込みのプロパティが、誤ったコンテキストに対して解決される(あるいはまったく解決されない)ため、その後に続くのは静かなタイプのバグです。安全な契約は識別子と取り出した値であって、グラフではありません。ModelActorは、コンテキストを自ら所有し、インスタンスではなく値を提供することで、この規律をカプセル化します。7

CloudKit同期とApp Group entitlementの罠

ウィジェットやエクステンションから読めるようにSwiftDataのストアをApp Groupのコンテナへ移す作業は、CloudKit同期と噛み合って、リリース後のアプリを刺します。前提となる事実は2つです。

まず、ストアの置き場所。既定のModelConfigurationを使う場合、アプリがApp Groupなしからありへ進化する際に、SwiftDataが既存のストアをApp Groupのコンテナへコピーしてくれます。Appleの表現では、SwiftDataは「既存のストアをapp groupコンテナへコピーする」となっています。8 カスタムのストアURLを使う場合、置き場所は自分の責任です。ファイルを新しいコンテナへコピーし、設定をそこへ向けるのは自分の仕事になります。既定の経路が便利なのは、まさにフレームワークがコピーを行ってくれるからです。カスタムの経路は、その利便性と引き換えに制御を得ます。

次に、entitlement。CloudKitで同期されるストアを読むApp Groupのメンバーは、すべて同じCloudKitのentitlementを持たなければなりません。それぞれのプロセスが、自分自身のためにそのコンテナを同期しにいくからです。この要件が罠になります。ウィジェットやエクステンションには、饒舌な同期を駆動するだけの実行時間の余裕も、フォアグラウンドの時間もありません。それなのにCloudKitのentitlementを渡すと、やろうとしてしまうのです。解決策は、ModelConfigurationを2つに分けることです。同期するストア(CloudKitのentitlementを持ち、メインアプリが所有)と、App Groupのコンテナに置くローカルのストア(ウィジェットやエクステンションが同期せずに読む)ですね。同期はフォアグラウンドのアプリがきちんとこなせる場所に置き、読み取りのために共有するデータは同期の経路から外しておきましょう。8

自分ならどう作り直すか

このクラスタのアプリが実際に採用している、あるいは採用しておけばよかったと思っているパターンが3つあります。

v1からVersionedSchemaをリリースする。 リリースするすべての@Modelクラスは、初日からVersionedSchemaの中に置くべきです。コストは、スキーマのバージョンごとに包むenumが1つ。見返りは、v2で最初の自明でない変更が、2日がかりのさかのぼりリファクタではなくMigrationPlan.schemasへの1行追加で済むことです。

タイムスタンプはすべてOptionalにする。 デバイス間の同期や競合解決のために存在するlastModifiedcreatedAtupdatedAtといったフィールドは、v1のプロダクトが必要としないならv1ではOptionalにしておくべきです。Optional性は、(実際に必要になった)v2へのマイグレーションを安く保ちます。didMigrateで既存の行に値を埋めるのはループ1つ。v1から非Optionalにするのは、ユーザーのデータでバックフィルを壊しかねない制約です。

自然キーにはPersistentIdentifierではなくUUIDを使う。 SwiftDataのPersistentIdentifierはプロセス内のものです。デバイス間の同期、MCP連携(2つのエージェント・エコシステム、1つの買い物リストで解説)、そしてプロセス外からの参照には、安定した識別子が必要になります。@Attribute(.unique)を付けたUUIDが正しい形であり、プロセス内のPersistentIdentifierは、プロセスの境界を越えるあらゆる用途にとって間違った形です。

@Modelが答えにならないとき

SwiftDataが適切なツールではないケースが3つあります。

単一レコードのkey/valueな状態。 アプリの設定、ユーザーが選んだ言語、最後に同期した時刻など。UserDefaultsNSUbiquitousKeyValueStoreを使いましょう(5つのAppleプラットフォーム、3つの共有ファイルで解説)。1行のためにSwiftDataのオーバーヘッドを払うのは無駄な儀式であり、key-valueのストアこそが適した基盤です。

オフライン書き込みのない、サーバーが権威となるデータ。 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を指すメタデータを置きましょう。

それでもCore Dataが勝つとき

SwiftDataはCore Dataの上に載る層であって、そのすべてを置き換えるものではありません。3年が経ったいまも、特定の仕事は古いフレームワークの領分です。これらのためにCore Dataを選ぶのはレガシーな判断ではなく、現時点で正しい判断となります。

データベース側での集計。 SwiftDataには、Core DataのNSExpressionベースのフェッチ、つまりsumaverageminmaxをSQLiteへ押し込み、行を読み込まずにデータベースに計算させるあの仕組みに相当するものがありません9。SwiftDataではフェッチしてメモリ上でreduceすることになり、大きなテーブルでは意味を失います。文書化された逃げ道は共存です。Appleは「完全に独立した2つの永続化スタック、すなわち1つのCore Dataスタックと1つのSwiftDataスタックが、同じ永続ストアと会話する」構成を説明しており9、これによりCore Data側が、SwiftDataの所有するファイルに対してSQLへ押し込んだ集計を実行できます。NSPersistentHistoryTrackingKeyの要件を含む具体的な仕組みは、SwiftDataのパフォーマンスはストレージの問題で扱っています。

共有と、CloudKitのパブリックデータベース。 SwiftDataの自動的なiCloud同期は、裏でNSPersistentCloudKitContainerに乗っており、それが構成するコンテナは、ストアをユーザーのプライベートなCloudKitデータベースへミラーリングします10CKShareによる異なるiCloudユーザー間のコラボレーションや、パブリックデータベースへの公開は、Core Data + CloudKitとして文書化された機能であり、iOS 27のベータの時点でSwiftDataレベルのAPIは存在しません10。中心となる機能が共有リストや共同編集ドキュメントであるアプリは、同期するストアだけCore Dataに降りるか、CloudKitのレイヤーを手で組み上げるかのどちらかになります。

ストアレベルのバッチ更新。 Core DataのNSBatchUpdateRequestは、オブジェクトを読み込むことなく、該当する行をストアの中で直接書き換えます11。SwiftDataのModelContextには削除の側(delete(model:where:)がpredicateを受け取ります)はありますが、バッチ更新に相当するものがないため、SwiftDataでフィールドを一括で書き換えるということは、影響を受けるすべてのモデルを実体化することを意味します。

iOS 17を下回るデプロイメントの下限。 SwiftDataはiOS 17を要求します。Core Dataはアプリがサポートするかぎりどこまでも遡れ、そのCloudKitコンテナはiOS 13まで戻れます1012。OSの裾野が長いコードベースに、選ぶ余地はありません。

このリストから外れたものにも触れておく価値があります。「ビューの外でNSFetchedResultsControllerが必要」という点は、iOS 27のベータがResultsObserverを追加するまではCore Dataの勝ちどころでした。これはSwift Observationを通じて、アプリのどこからでもフェッチを監視します13。差分のリストはリリースを重ねるごとに縮んでいます。賭け方はこうです。新しいアプリはSwiftDataで始め、上で挙げた特定の仕事には共存を採り入れ、共有・パブリックデータベース・デプロイメントの下限がそれを強いるときにだけ、フルのCore Dataスタックを答えとして扱う。

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

持ち帰りは3つです。

  1. マクロは簡単な部分。コストはマイグレーションにあります。 @Model@Attributeは、Core Dataの配管を大量に隠してくれる2行の宣言です。アプリの生涯を通じて実際に支払うのは、マイグレーションの規律のほうです。v2を念頭にv1を設計しましょう。

  2. リリースするアプリにとって、初日からのVersionedSchemaは譲れません。 包むためのenumはファイル1つ分。後から追加するさかのぼりのコストは、それよりずっと高くつきます。

  3. Optionalなフィールドと明示的な関係は、安い保険です。 同期のメタデータ用のOptionalなタイムスタンプ、関係に対する明示的な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シリーズにあります。iOSとAIエージェントをめぐるより広い文脈は、iOSエージェント開発ガイドをご覧ください。

よくある質問

@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をupsertとして扱います。.uniqueな値がすでに存在するモデルを挿入すると、新しい行が追加されるのではなく既存の行が更新されます。このセマンティクスこそが、upsert方式の同期経路(同じUUIDが2台のデバイスから届く場合)を安全にしてくれるものです。同時にそれは、titleのような表示名のフィールドに.uniqueが不向きな理由でもあります。2人のユーザーが同じタイトルを入力すると、2つの別々のレコードができる代わりに、行が黙ってマージされてしまうからです。

既存のスキーマに非Optionalなフィールドを追加するにはどうすればよいですか?

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

PersistentIdentifierと自前のUUIDはどう違いますか?

PersistentIdentifierはSwiftDataのプロセス内の行IDで、自動的に生成され、実行中のプロセスの寿命のあいだ有効です。@Attribute(.unique)を付けた自前のUUIDは、プロセスをまたぎデバイスをまたいで安定した識別子です。アプリ内のプロセス内参照にはPersistentIdentifierを使いましょう。プロセスの境界を越えるもの(デバイス間の同期、外部との連携、MCPのツール、ネットワーク呼び出し)にはUUIDを使いましょう。

いまでもSwiftDataではなくCore Dataを選ぶべきなのは、どんなときですか?

iOS 27のベータ時点で4つのケースがあります。データベース側での集計(SwiftDataにはないNSExpressionのフェッチ)、CKShareやパブリックなCloudKitデータベースを介したiCloudユーザー間の共有(SwiftDataの同期がカバーするのはプライベートデータベースです)、ストアレベルのバッチ更新(NSBatchUpdateRequest)、そしてiOS 17を下回るデプロイメントターゲットです91011。集計のケースについては、SwiftDataを捨てる必要はありません。同じストアのファイルに対して、共存するCore Dataのスタックを走らせましょう。

参考文献


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

  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”。lightweight migrationのセマンティクスと、フレームワークが処理を打ち切る条件について。 

  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のプロセス間・デバイス間の同期パターンについて。 

  7. Apple Developer, “PersistentIdentifier”Sendableに準拠)および“ModelActor”。SwiftDataチームはWWDC 2026のSwiftData Group Labにおいて、@ModelのオブジェクトはSendableではなく、無理に準拠させるべきでもないと確認しました。コンテキストの内側に存在する参照のグラフだからです。推奨される境界の契約は、SendablePersistentIdentifierと取り出した素の値を渡し、渡し先のコンテキストで再フェッチすることであり、モデルのグラフを渡すと受け手には部分的にしか実体化していないオブジェクトが残る、という点も示されました。WWDC 2026のSwiftData Group Labをローカルで文字起こしした記録からの要約です。Appleはこれらのラボについて公式のキャプションを公開していません。 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”。既定の設定では「SwiftDataが既存のストアをapp groupコンテナへコピーする」一方、カスタムのストアURLでは置き場所の管理が自分に委ねられる、と述べられています。App Groupのメンバーに対するCloudKit entitlementの要件と、ウィジェットやエクステンションを同期の経路から外すための2つのModelConfigurationへの分割(一方は同期あり、他方はローカル)は、WWDC 2026のSwiftData Group Labで説明されたものです。WWDC 2026のSwiftData Group Labをローカルで文字起こしした記録からの要約です。Appleはこれらのラボについて公式のキャプションを公開していません。 

  9. Apple, WWDC 2023セッション10189, “Migrate to SwiftData”。共存という枠組み(「完全に独立した2つの永続化スタック、すなわち1つのCore Dataスタックと1つのSwiftDataスタックが、同じ永続ストアと会話する」)の出典です。あわせてApple Developer, “NSExpression”。Core DataがSQLへ押し込む集計フェッチの仕組みであり、SwiftDataには相当するものがありません。このギャップは、WWDC 2026のSwiftData Group LabにおけるSwiftDataエンジニアリングのパネルで確認されました(ローカルで文字起こしした記録からの要約)。 

  10. Apple Developer, “Syncing model data across a person’s devices”。「SwiftDataはCloudKitの同期を扱うためにCore DataのNSPersistentCloudKitContainerクラスを使用する」と述べられています。また“NSPersistentCloudKitContainer”(iOS 13.0+)は、その概要で「選択した永続ストアをCloudKitのプライベートデータベースへ」ミラーリングすると説明しています。さらに“Sharing Core Data objects between iCloud users”は、CKShareによるコラボレーションのために文書化されたCore Dataの経路です。SwiftDataのドキュメントは、iOS 27のベータの時点で共有やパブリックデータベースのAPIを公開していません。 

  11. Apple Developer, “NSBatchUpdateRequest”、および“ModelContext.delete(model:where:includeSubclasses:)”。後者はSwiftDataのpredicateベースのバッチ削除です。SwiftDataのModelContextのドキュメントには、バッチ更新に相当するものが載っていません。 

  12. Apple Developerのドキュメントに基づくプラットフォームの対応状況:SwiftData(iOS 17.0+)とCore Data(iOS 3.0+)。 

  13. Apple Developer, “ResultsObserver”(iOS 27.0ベータ)。「モデルコンテキスト内の永続モデルのコレクションに対する変更を監視し追跡する」ものであり、Observableに準拠して、以前はCore DataのNSFetchedResultsControllerを必要としていた「ビューの外での監視」という役割を担います。 

関連記事

SwiftDataのマイグレーション:lightweightとcustomの違い、そしてV2が不要なケース

SwiftDataのマイグレーションはVersionedSchema、MigrationStage、SchemaMigrationPlanで構成されます。ほとんどのスキーマ変更にV2スキーマは不要ですが、本当に必要なケースも確かに存在します…

16 分で読める

iOS 27のSwiftData:ObservationとHistory

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

11 分で読める

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

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

17 分で読める