axiom-swiftdata-migration

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

25 stars

Best use case

axiom-swiftdata-migration is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

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

Teams using axiom-swiftdata-migration should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/axiom-swiftdata-migration/SKILL.md --create-dirs "https://raw.githubusercontent.com/ComeOnOliver/skillshub/main/skills/CharlesWiltgen/Axiom/axiom-swiftdata-migration/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/axiom-swiftdata-migration/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How axiom-swiftdata-migration Compares

Feature / Agentaxiom-swiftdata-migrationStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

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

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# 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

```swift
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
    }
)
```

### Why This Matters

You cannot directly transform data from old type to new type in a single migration stage. Example:

```swift
// ❌ 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).

---

## Core Patterns

### Pattern 1: Basic VersionedSchema Setup

Every distinct schema version must be defined as a `VersionedSchema`.

```swift
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
        }
    }
}
```

#### 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.

```swift
// 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 */ }
}
```

#### Migration Plan

```swift
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
    )
}
```

#### Apply Migration Plan

```swift
@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

1. **Explicit inverse relationships** SwiftData won't infer many-to-many
2. **Arrays on both sides** Not optional, must be arrays
3. **iOS 17.0 bug workaround** Alphabetical naming issue

```swift
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
        }
    }
}
```

#### 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:

```swift
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = []  // ✅ Default value prevents bug
```

**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:

```swift
@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 }
    }
}
```

---

### Pattern 4: Relationship Prefetching During Migration

**Use when** Migrating models with relationships to avoid N+1 queries

```swift
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
)
```

#### 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

```swift
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.

---

### Pattern 6: Deduplication for Unique Constraints

**Use when** Adding `@Attribute(.unique)` to a field that has duplicates

```swift
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
    )
}
```

---

## Testing Migrations

### Mandatory Testing Checklist

- [ ] Test fresh install (all migrations run from V1 → latest)
- [ ] Test upgrade from each previous version
- [ ] Test on REAL device (not just simulator)
- [ ] Verify relationship integrity after migration
- [ ] Check for data loss (count records before/after)
- [ ] Test with production-sized dataset

### 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

```swift
// ❌ 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
```

### 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:
```swift
// 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:
- 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.

```bash
# 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
```

#### 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

```swift
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
}
```

---

## 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

```swift
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.

---

### ❌ Trying to access old models in didMigrate

```swift
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
)
```

---

### ❌ Not testing on real device with real data

```swift
// ❌ 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
```

---

### ❌ Many-to-many without explicit inverse

```swift
// ❌ 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
}
```

---

### ❌ 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

```bash
# 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
```

### 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

```swift
// 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
)
```

---

## 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+

Related Skills

sql-migration-generator

25
from ComeOnOliver/skillshub

Sql Migration Generator - Auto-activating skill for Backend Development. Triggers on: sql migration generator, sql migration generator Part of the Backend Development skill category.

managing-database-migrations

25
from ComeOnOliver/skillshub

Process use when you need to work with database migrations. This skill provides schema migration management with comprehensive guidance and automation. Trigger with phrases like "create migration", "run migrations", or "manage schema versions".

exa-upgrade-migration

25
from ComeOnOliver/skillshub

Upgrade exa-js SDK versions and handle breaking changes safely. Use when upgrading the Exa SDK, detecting deprecations, or migrating between exa-js versions. Trigger with phrases like "upgrade exa", "exa update", "exa breaking changes", "update exa-js", "exa new version".

exa-migration-deep-dive

25
from ComeOnOliver/skillshub

Migrate from other search APIs (Google, Bing, Tavily, Serper) to Exa neural search. Use when switching to Exa from another search provider, migrating search pipelines, or evaluating Exa as a replacement for traditional search APIs. Trigger with phrases like "migrate to exa", "switch to exa", "replace google search with exa", "exa vs tavily", "exa migration", "move to exa".

evernote-upgrade-migration

25
from ComeOnOliver/skillshub

Upgrade Evernote SDK versions and migrate between API versions. Use when upgrading SDK, handling breaking changes, or migrating to newer API patterns. Trigger with phrases like "upgrade evernote sdk", "evernote migration", "update evernote", "evernote breaking changes".

evernote-migration-deep-dive

25
from ComeOnOliver/skillshub

Deep dive into Evernote data migration strategies. Use when migrating to/from Evernote, bulk data transfers, or complex migration scenarios. Trigger with phrases like "migrate to evernote", "migrate from evernote", "evernote data transfer", "bulk evernote migration".

elevenlabs-upgrade-migration

25
from ComeOnOliver/skillshub

Upgrade ElevenLabs SDK versions and migrate between API model generations. Use when upgrading the elevenlabs-js or elevenlabs Python SDK, migrating from v1 to v2 models, or handling deprecations. Trigger: "upgrade elevenlabs", "elevenlabs migration", "elevenlabs breaking changes", "update elevenlabs SDK", "migrate elevenlabs model", "eleven_v3 migration".

documenso-upgrade-migration

25
from ComeOnOliver/skillshub

Manage Documenso API version upgrades and SDK migrations. Use when upgrading from v1 to v2 API, updating SDK versions, or migrating between Documenso versions. Trigger with phrases like "documenso upgrade", "documenso v2 migration", "update documenso SDK", "documenso API version".

documenso-migration-deep-dive

25
from ComeOnOliver/skillshub

Execute comprehensive Documenso migration strategies for platform switches. Use when migrating from other signing platforms, re-platforming to Documenso, or performing major infrastructure changes. Trigger with phrases like "migrate to documenso", "documenso migration", "switch to documenso", "documenso replatform", "replace docusign".

deepgram-upgrade-migration

25
from ComeOnOliver/skillshub

Plan and execute Deepgram SDK upgrades and model migrations. Use when upgrading SDK versions (v3->v4->v5), migrating models (Nova-2 to Nova-3), or planning API version transitions. Trigger: "upgrade deepgram", "deepgram migration", "update deepgram SDK", "deepgram version upgrade", "nova-3 migration".

deepgram-migration-deep-dive

25
from ComeOnOliver/skillshub

Deep dive into migrating to Deepgram from other transcription providers. Use when migrating from AWS Transcribe, Google Cloud STT, Azure Speech, OpenAI Whisper, AssemblyAI, or Rev.ai to Deepgram. Trigger: "deepgram migration", "switch to deepgram", "migrate transcription", "deepgram from AWS", "deepgram from Google", "replace whisper with deepgram".

databricks-upgrade-migration

25
from ComeOnOliver/skillshub

Upgrade Databricks runtime versions and migrate between features. Use when upgrading DBR versions, migrating to Unity Catalog, or updating deprecated APIs and features. Trigger with phrases like "databricks upgrade", "DBR upgrade", "databricks migration", "unity catalog migration", "hive to unity".