| name | axiom-swiftdata-migration |
| description | 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 |
| license | MIT |
| metadata | {"version":"1.0.0"} |
SwiftData Custom Schema Migrations
Overview
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)
When Custom Migrations Are Required
Lightweight Migrations (Automatic)
SwiftData can migrate automatically for:
- ✅ Adding new optional properties
- ✅ Adding new required properties with default values
- ✅ Removing properties
- ✅ Renaming properties (with
@Attribute(originalName:))
- ✅ Changing relationship delete rules
- ✅ Adding new models
Custom Migrations (This Skill)
You need custom migrations for:
- ❌ Changing property types (
String → AttributedString, Int → String)
- ❌ Making optional properties required (must populate existing nulls)
- ❌ Complex relationship restructuring
- ❌ Data transformations (splitting/merging fields)
- ❌ Deduplication when adding unique constraints
Example Prompts
These are real questions developers ask that this skill is designed to answer:
1. "I need to change a property from String to AttributedString. How do I migrate existing data with relationships intact?"
→ The skill shows the two-stage migration pattern that works around the willMigrate/didMigrate limitation
2. "My model has a one-to-many relationship with cascade delete. How do I preserve this during a type change migration?"
→ The skill explains relationship prefetching and maintaining inverse relationships across schema versions
3. "I have a many-to-many relationship between Tags and Notes. The migration is failing with 'Expected only Arrays for Relationships'. What's wrong?"
→ The skill covers explicit inverse relationship requirements and iOS 17.0 alphabetical naming bug
4. "I need to rename a model but keep all its relationships intact."
→ The skill shows @Attribute(originalName:) patterns for lightweight migration
5. "My migration works in the simulator but crashes on a real device with existing data."
→ The skill emphasizes real-device testing and explains why simulator success doesn't guarantee production safety
6. "Why do I have to copy ALL my models into each VersionedSchema, even ones that haven't changed?"
→ The skill explains SwiftData's design: each VersionedSchema is a complete snapshot, not a diff
7. "I'm getting 'The model used to open the store is incompatible with the one used to create the store' error."
→ The skill provides debugging steps for schema version mismatches
8. "How do I test my SwiftData migration before releasing to production?"
→ The skill covers migration testing workflow, real device testing requirements, and validation strategies
The willMigrate/didMigrate Limitation
CRITICAL This is the architectural constraint that shapes all SwiftData migration patterns.
What You Can Access
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: { context in
let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
}
)
Why This Matters
You cannot directly transform data from old type to new type in a single migration stage. Example:
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for oldNote in oldNotes {
let newNote = SchemaV2.Note()
newNote.content = oldNote.contentAsAttributedString()
}
}
Solution Use two-stage migration pattern (covered below).
Core Patterns
Pattern 1: Basic VersionedSchema Setup
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]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
var content: String
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: , : ) {
.id id
.title title
.content content
.createdAt createdAt
}
}
{
(.unique) id:
name:
(deleteRule: .cascade)
notes: [] []
(: , : ) {
.id id
.name name
}
}
{
(.unique) id:
name:
(deleteRule: .nullify)
notes: [] []
(: , : ) {
.id id
.name name
}
}
}
Key patterns
- Complete snapshot All models included, even unchanged ones
- Semantic versioning Use Schema.Version(major, minor, patch)
- Explicit init SwiftData doesn't synthesize initializers
- Inverse relationships Specify on both sides for bidirectional
Pattern 2: Two-Stage Migration for Type Changes
Use when Changing property type (String → AttributedString, Int → String, etc.)
Problem
We want to change Note.content from String to AttributedString, but we can't access both old and new types simultaneously.
Solution
Use an intermediate schema version (V1.1) that has BOTH properties.
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
@Attribute(originalName: "content")
var contentOld: String = ""
var contentNew: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [] []
(: , : , : , : ) {
.id id
.title title
.contentOld contentOld
.createdAt createdAt
}
}
{ }
{ }
}
: {
versionIdentifier .(, , )
models: [ .] {
[., ., .]
}
{
(.unique) id:
title:
(originalName: )
content: ?
createdAt:
(deleteRule: .nullify, inverse: \.notes)
folder: ?
(deleteRule: .nullify, inverse: \.notes)
tags: [] []
(: , : , : ?, : ) {
.id id
.title title
.content content
.createdAt createdAt
}
}
{ }
{ }
}
Migration Plan
enum NotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV1_1, migrateV1_1toV2]
}
static let migrateV1toV1_1 = MigrationStage.lightweight(
fromVersion: NotesSchemaV1.self,
toVersion: NotesSchemaV1_1.self
)
static let migrateV1_1toV2 = MigrationStage.custom(
fromVersion: NotesSchemaV1_1.self,
toVersion: NotesSchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes context.fetch(fetchDesc)
note notes {
note.contentNew (markdown: note.contentOld)
}
context.save()
},
didMigrate:
)
}
Apply Migration Plan
@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)
}
}
Pattern 3: Many-to-Many Relationship Migration
Use when You have many-to-many relationships (Tags ↔ Notes)
Critical Requirements
- Explicit inverse relationships SwiftData won't infer many-to-many
- Arrays on both sides Not optional, must be arrays
- iOS 17.0 bug workaround Alphabetical naming issue
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
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name:
(deleteRule: .nullify, inverse: \.tags)
notes: [] []
(: , : ) {
.id id
.name name
}
}
}
iOS 17.0 Alphabetical Bug Workaround
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] = []
Fixed in iOS 17.1+
Adding Junction Table Metadata
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
@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] = []
var tags: [Tag] {
noteTags.compactMap { $0.tag }
}
}
@Model
final class {
(.unique) id:
name:
(deleteRule: .cascade)
noteTags: [] []
notes: [] {
noteTags.compactMap { .note }
}
}
Pattern 4: Relationship Prefetching During Migration
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>()
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
fetchDesc.propertiesToFetch = [\.title, \.content]
let notes = try context.fetch(fetchDesc)
for note in notes {
let folderName = note.folder?.name
let tagCount = note.tags.count
}
try context.save()
},
didMigrate: nil
)
Performance Impact
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
Pattern 5: Renaming Properties
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
}
}
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
(originalName: )
heading:
}
}
: {
schemas: [ .] {
[., .]
}
stages: [] {
[migrateV1toV2]
}
migrateV1toV2 .lightweight(
fromVersion: .,
toVersion: .
)
}
Why this works SwiftData sees originalName and preserves data during lightweight migration.
Pattern 6: Deduplication for Unique Constraints
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
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.]
}
{
(.unique) id:
(.unique) name:
(: , : ) {
.id id
.name name
}
}
}
: {
schemas: [ .] {
[., .]
}
stages: [] {
[migrateV1toV2]
}
migrateV1toV2 .custom(
fromVersion: .,
toVersion: .,
willMigrate: { context
trips context.fetch(<.>())
seenNames <>()
trip trips {
seenNames.contains(trip.name) {
context.delete(trip)
} {
seenNames.insert(trip.name)
}
}
context.save()
},
didMigrate:
)
}
Testing Migrations
Mandatory Testing Checklist
Why Simulator Testing Is Insufficient
Simulator behavior Deletes database on rebuild, always sees fresh schema
Real device behavior Keeps persistent database across updates, schema must match
Testing Workflow
Before deploying any migration to production:
1. Create Test Data Sets
Prepare test data representing pre-migration state:
- Minimal dataset - 10-20 records with all relationship types
- Realistic dataset - 1,000+ records matching production scale
- Edge cases - Empty relationships, max relationship counts, optional fields
2. Test in Simulator
Run migration with test data:
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
let v2Container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
Verify:
- All relationships preserved
- No data loss (count records before/after)
- New fields populated correctly
- Performance acceptable with realistic dataset size
3. Test on Real Device
CRITICAL - Simulator success does not guarantee production safety.
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
4. Validate with Production Data (If Possible)
If you have access to production data:
- Copy production database to development environment
- Run migration against copy
- Verify no data corruption
- Check performance with production-sized dataset
See axiom-swiftdata-migration-diag for debugging tools if migration fails.
Migration Test Pattern
import Testing
import SwiftData
@Test func testMigrationFromV1ToV2() throws {
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()
let v2Schema = Schema(versionedSchema: SchemaV2.self)
let v2Container = try ModelContainer(
for: v2Schema,
migrationPlan: MigrationPlan.self,
configurations: v1Config
)
let v2Context = v2Container.mainContext
let notes = try v2Context.fetch(FetchDescriptor<.>())
#expect(notes.count )
#expect(notes.first.content )
}
Decision Tree: Lightweight vs Custom Migration
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 ✗
Common Mistakes
❌ Forgetting to include ALL models in VersionedSchema
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self]
}
}
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
}
Why Each VersionedSchema is a complete snapshot of the data model, not a diff.
❌ Trying to access old models in didMigrate
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: nil
)
❌ Not testing on real device with real data
❌ Many-to-many without explicit inverse
@Model
final class Note {
var tags: [Tag] = []
}
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
}
❌ Assuming simulator success = production success
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.
Debugging Failed Migrations
Enable Core Data SQL Debug
-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
Common Error Messages
| 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 |
Quick Reference
Basic Migration Setup
enum SchemaV1: VersionedSchema { }
enum SchemaV2: VersionedSchema { }
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
)
}
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)
Resources
WWDC: 2025-291, 2023-10195
Docs: /swiftdata
Skills: axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration
Created 2025-12-09
Targets iOS 17+ (focus on iOS 26+ features)
Framework SwiftData (Apple)
Swift 5.9+