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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
SwiftData schema migrations move your data safely when models change. Core principle SwiftData's willMigrate sees only OLD models, didMigrate sees only NEW models—you can never access both simultaneously. This limitation shapes all migration strategies.
Requires iOS 17+, Swift 5.9+
Target iOS 26+ (features like propertiesToFetch)
SwiftData can migrate automatically for:
@Attribute(originalName:))You need custom migrations for:
String → AttributedString, Int → String)These are real questions developers ask that this skill is designed to answer:
→ The skill shows the two-stage migration pattern that works around the willMigrate/didMigrate limitation
→ The skill explains relationship prefetching and maintaining inverse relationships across schema versions
→ The skill covers explicit inverse relationship requirements and iOS 17.0 alphabetical naming bug
→ The skill shows @Attribute(originalName:) patterns for lightweight migration
→ The skill emphasizes real-device testing and explains why simulator success doesn't guarantee production safety
→ The skill explains SwiftData's design: each VersionedSchema is a complete snapshot, not a diff
→ The skill provides debugging steps for schema version mismatches
→ The skill covers migration testing workflow, real device testing requirements, and validation strategies
CRITICAL This is the architectural constraint that shapes all SwiftData migration patterns.
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
}
)
You cannot directly transform data from old type to new type in a single migration stage. Example:
// ❌ 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()
}
}
Solution Use two-stage migration pattern (covered below).
Every distinct schema version must be defined as a VersionedSchema.
import 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
}
}
}
Use when Changing property type (String → AttributedString, Int → String, etc.)
We want to change Note.content from String to AttributedString, but we can't access both old and new types simultaneously.
Use an intermediate schema version (V1.1) that has BOTH properties.
// 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)
}
}
Use when You have many-to-many relationships (Tags ↔ Notes)
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
}
}
}
In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order (e.g., Actor ↔ Movie works, but Movie ↔ Person fails).
Workaround Provide default values for relationship arrays:
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = [] // ✅ Default value prevents bug
Fixed in iOS 17.1+
If you need additional fields on the relationship (e.g., "when was this tag added?"), use an explicit junction model:
@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 }
}
}
Use when Migrating models with relationships to avoid N+1 queries
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 total
Use when You want to rename a property without data loss
enum 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
)
}
Why this works SwiftData sees originalName and preserves data during lightweight migration.
Use when Adding @Attribute(.unique) to a field that has duplicates
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
)
}
Simulator behavior Deletes database on rebuild, always sees fresh schema
Real device behavior Keeps persistent database across updates, schema must match
// ❌ 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
Before deploying any migration to production:
Prepare test data representing pre-migration state:
Run migration with test data:
// 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
)
Verify:
CRITICAL - Simulator success does not guarantee production safety.
# 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 populated
If you have access to production data:
See swiftdata-migration-diag for debugging tools if migration fails.
import 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
}
}
Why Each VersionedSchema is a complete snapshot of the data model, not a diff.
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
}
Simulator deletes database on rebuild. Real devices keep persistent databases across updates.
Impact Migration bugs hidden in simulator, crash 100% of production users.
Fix ALWAYS test on real device before shipping.
# In Xcode scheme, add argument:
-com.apple.coredata.swiftdata.debug 1
Output Shows actual SQL queries during migration
CoreData: 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 @Relationship(inverse:) |
| "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
)
Created 2025-12-09 Targets iOS 17+ (focus on iOS 26+ features) Framework SwiftData (Apple) Swift 5.9+