Loading...
Loading...
Use when creating SwiftData custom schema migrations with VersionedSchema and SchemaMigrationPlan - property type changes, relationship preservation (one-to-many, many-to-many), the willMigrate/didMigrate limitation, two-stage migration patterns, and testing migrations on real devices
npx skill4agent add charleswiltgen/axiom axiom-swiftdata-migrationwillMigratedidMigratepropertiesToFetch@Attribute(originalName:)StringAttributedStringIntString@Attribute(originalName:)static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ CAN access: SchemaV1 models (old)
let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// ❌ CANNOT access: SchemaV2 models
// SchemaV2.Note doesn't exist yet
},
didMigrate: { context in
// ✅ CAN access: SchemaV2 models (new)
let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
// ❌ CANNOT access: SchemaV1 models
// SchemaV1.Note is gone
}
)// ❌ IMPOSSIBLE - you can't do this in one stage
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for oldNote in oldNotes {
let newNote = SchemaV2.Note() // ❌ Doesn't exist yet!
newNote.content = oldNote.contentAsAttributedString()
}
}VersionedSchemaimport SwiftData
enum NotesSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ALL models, even if unchanged
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
var content: String // Original type
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: String, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model
final class Folder {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .nullify)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}Note.contentStringAttributedString// Stage 1: V1 → V1.1 (Add new property alongside old)
enum NotesSchemaV1_1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 1, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// OLD property (to be deprecated)
@Attribute(originalName: "content")
var contentOld: String = ""
// NEW property (target type)
var contentNew: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, contentOld: String, createdAt: Date) {
self.id = id
self.title = title
self.contentOld = contentOld
self.createdAt = createdAt
}
}
// Folder and Tag unchanged (copy from V1)
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}
// Stage 2: V1.1 → V2 (Transform data, remove old property)
enum NotesSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Renamed from contentNew
@Attribute(originalName: "contentNew")
var content: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: AttributedString?, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}enum NotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV1_1, migrateV1_1toV2]
}
// Stage 1: Lightweight migration (adds contentNew)
static let migrateV1toV1_1 = MigrationStage.lightweight(
fromVersion: NotesSchemaV1.self,
toVersion: NotesSchemaV1_1.self
)
// Stage 2: Custom migration (transform String → AttributedString)
static let migrateV1_1toV2 = MigrationStage.custom(
fromVersion: NotesSchemaV1_1.self,
toVersion: NotesSchemaV2.self,
willMigrate: { context in
// Transform data while we still have access to V1.1 models
var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()
// Prefetch relationships to preserve them
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// Convert String → AttributedString
note.contentNew = try? AttributedString(markdown: note.contentOld)
}
try context.save()
},
didMigrate: nil
)
}@main
struct NotesApp: App {
let container: ModelContainer = {
do {
let schema = Schema(versionedSchema: NotesSchemaV2.self)
return try ModelContainer(
for: schema,
migrationPlan: NotesMigrationPlan.self
)
} catch {
fatalError("Failed to create container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Array with default value
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ Array with default value
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = [] // ✅ Default value prevents bug@Model
final class NoteTag {
@Attribute(.unique) var id: String
var addedAt: Date // Metadata on relationship
@Relationship(deleteRule: .cascade)
var note: Note?
@Relationship(deleteRule: .cascade)
var tag: Tag?
init(id: String, note: Note, tag: Tag, addedAt: Date) {
self.id = id
self.note = note
self.tag = tag
self.addedAt = addedAt
}
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var tags: [Tag] {
noteTags.compactMap { $0.tag }
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var notes: [Note] {
noteTags.compactMap { $0.note }
}
}static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// Prefetch relationships (iOS 26+)
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
// Only fetch properties you need (iOS 26+)
fetchDesc.propertiesToFetch = [\.title, \.content]
let notes = try context.fetch(fetchDesc)
// Relationships are already loaded - no N+1
for note in notes {
let folderName = note.folder?.name // ✅ Already in memory
let tagCount = note.tags.count // ✅ Already in memory
}
try context.save()
},
didMigrate: nil
)Without prefetching:
- 1 query to fetch notes
- N queries to fetch each note's folder
- N queries to fetch each note's tags
= 1 + N + N queries
With prefetching:
- 1 query to fetch notes
- 1 query to fetch all folders
- 1 query to fetch all tags
= 3 queries totalenum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String // Original name
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
// Renamed from "title" to "heading"
@Attribute(originalName: "title")
var heading: String
}
}
// Migration plan (lightweight migration)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}originalName@Attribute(.unique)enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
var name: String // ❌ Not unique, has duplicates
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
@Attribute(.unique) var name: String // ✅ Now unique
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Deduplicate before adding unique constraint
let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
var seenNames = Set<String>()
for trip in trips {
if seenNames.contains(trip.name) {
// Duplicate - delete or rename
context.delete(trip)
} else {
seenNames.insert(trip.name)
}
}
try context.save()
},
didMigrate: nil
)
}// ❌ WRONG - only testing in simulator
// You rebuild → simulator deletes database → fresh install
// Migration code never runs!
// ✅ CORRECT - test on real device
// 1. Install v1 build on device
// 2. Create sample data
// 3. Install v2 build (with migration)
// 4. Verify data preserved// Create test data in V1 schema
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... populate test data ...
// Run migration
let v2Container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)# Workflow:
1. Install v1 build on real device
2. Create 100+ records with relationships
3. Verify data exists
4. Install v2 build (over existing app, don't delete)
5. Launch app
6. Verify:
- App launches without crash
- All 100+ records still exist
- Relationships intact
- New fields populatedaxiom-swiftdata-migration-diagimport Testing
import SwiftData
@Test func testMigrationFromV1ToV2() throws {
// 1. Create V1 data
let v1Schema = Schema(versionedSchema: SchemaV1.self)
let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)
let context = v1Container.mainContext
let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
context.insert(note)
try context.save()
// 2. Run migration to V2
let v2Schema = Schema(versionedSchema: SchemaV2.self)
let v2Container = try ModelContainer(
for: v2Schema,
migrationPlan: MigrationPlan.self,
configurations: v1Config
)
// 3. Verify data migrated
let v2Context = v2Container.mainContext
let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())
#expect(notes.count == 1)
#expect(notes.first?.content != nil) // String → AttributedString
}What change are you making?
├─ Adding optional property → Lightweight ✓
├─ Adding required property with default → Lightweight ✓
├─ Renaming property (with originalName) → Lightweight ✓
├─ Removing property → Lightweight ✓
├─ Changing relationship delete rule → Lightweight ✓
├─ Adding new model → Lightweight ✓
├─ Changing property type → Custom (two-stage) ✗
├─ Making optional → required → Custom (populate nulls first) ✗
├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗
└─ Complex relationship restructure → Custom ✗enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self] // ❌ WRONG: Missing Folder and Tag
}
}
// ✅ CORRECT: Include ALL models
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ✅ Even if unchanged
}
}static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ CRASH: SchemaV1.Note doesn't exist here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
// ✅ CORRECT: Use willMigrate for old models
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note exists here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: nil
)// ❌ WRONG: Simulator success ≠ production safety
// Rebuild simulator → database deleted → fresh install
// Migration never actually runs!
// ✅ CORRECT: Test migration path
// 1. Install v1 on real device
// 2. Create data (100+ records)
// 3. Install v2 with migration
// 4. Verify data preserved// ❌ WRONG: SwiftData can't infer many-to-many
@Model
final class Note {
var tags: [Tag] = [] // ❌ Missing inverse
}
// ✅ CORRECT: Explicit inverse
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Inverse specified
}# In Xcode scheme, add argument:
-com.apple.coredata.swiftdata.debug 1CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE
CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT| Error | Likely Cause | Fix |
|---|---|---|
| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add |
| "The model used to open the store is incompatible" | Schema version mismatch | Verify migration plan schemas array |
| "Failed to fulfill faulting for..." | Relationship integrity broken | Prefetch relationships during migration |
| App crashes on launch after schema change | Missing model in VersionedSchema | Include ALL models |
// 1. Define versioned schemas
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }
// 2. Create migration plan
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
// 3. Apply to container
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)