swiftdata-migration

SwiftData Migration Guide

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "swiftdata-migration" with this command: npx skills add bluewaves-creations/bluewaves-skills/bluewaves-creations-bluewaves-skills-swiftdata-migration

SwiftData Migration Guide

Comprehensive guide for migrating from CoreData to SwiftData, managing schema versions, handling iCloud sync conflicts, and production-grade migration strategies.

Prerequisites

  • iOS 17+ for SwiftData (iOS 26 recommended)

  • Xcode 26+

  • Familiarity with existing CoreData stack (if migrating)

CoreData to SwiftData Migration

Migration Strategy Overview

┌─────────────────────────────────────────────────────────────┐ │ MIGRATION APPROACHES │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 1. COEXISTENCE (Recommended for large apps) │ │ CoreData ←→ SwiftData running side-by-side │ │ Gradual feature migration │ │ │ │ 2. COMPLETE MIGRATION (Smaller apps) │ │ CoreData → SwiftData one-time migration │ │ Requires downtime or careful orchestration │ │ │ │ 3. FRESH START (New iCloud containers) │ │ New SwiftData store, export/import user data │ │ Cleanest but requires user action │ │ │ └─────────────────────────────────────────────────────────────┘

Step 1: Audit Your CoreData Model

Before migrating, analyze your existing model:

// CoreData Model Audit Checklist /* ✓ List all entities and their attributes ✓ Document all relationships (to-one, to-many, many-to-many) ✓ Identify unique constraints ✓ Note transformable attributes and their types ✓ Check for derived attributes ✓ Review fetch request templates ✓ Audit NSManagedObject subclasses for custom logic */

// Example: Documenting CoreData entity /* Entity: Note Attributes:

  • id: UUID (unique)
  • title: String (not optional)
  • content: String (optional)
  • createdAt: Date
  • isPinned: Bool (default: false)

Relationships:

  • folder: Folder (to-one, optional, nullify)
  • tags: [Tag] (to-many, ordered) */

Step 2: Create Equivalent SwiftData Models

import SwiftData

// BEFORE: CoreData NSManagedObject /* @objc(Note) class Note: NSManagedObject { @NSManaged var id: UUID @NSManaged var title: String @NSManaged var content: String? @NSManaged var createdAt: Date @NSManaged var isPinned: Bool @NSManaged var folder: Folder? @NSManaged var tags: NSOrderedSet? } */

// AFTER: SwiftData @Model @Model class Note { // SwiftData generates its own persistent ID // Don't use @Attribute(.unique) for iCloud compatibility var legacyID: UUID? // Keep for migration reference

var title: String = ""
var content: String = ""
var createdAt: Date = Date()
var isPinned: Bool = false

// Relationships MUST be optional for iCloud
var folder: Folder?
var tags: [Tag]?  // Use array, not NSOrderedSet

init(title: String, content: String = "") {
    self.title = title
    self.content = content
    self.createdAt = Date()
}

}

Step 3: Data Migration Script

import CoreData import SwiftData

actor DataMigrator { private let coreDataContainer: NSPersistentContainer private let swiftDataContainer: ModelContainer

init(coreDataContainer: NSPersistentContainer, swiftDataContainer: ModelContainer) {
    self.coreDataContainer = coreDataContainer
    self.swiftDataContainer = swiftDataContainer
}

func migrateNotes(progressHandler: @escaping (Double) -> Void) async throws {
    let context = coreDataContainer.viewContext
    let swiftDataContext = ModelContext(swiftDataContainer)

    // Fetch all CoreData notes
    let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Note")
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]

    let coreDataNotes = try context.fetch(fetchRequest)
    let totalCount = Double(coreDataNotes.count)

    for (index, cdNote) in coreDataNotes.enumerated() {
        // Map CoreData object to SwiftData model
        let note = Note(
            title: cdNote.value(forKey: "title") as? String ?? "",
            content: cdNote.value(forKey: "content") as? String ?? ""
        )
        note.legacyID = cdNote.value(forKey: "id") as? UUID
        note.createdAt = cdNote.value(forKey: "createdAt") as? Date ?? Date()
        note.isPinned = cdNote.value(forKey: "isPinned") as? Bool ?? false

        swiftDataContext.insert(note)

        // Save in batches to avoid memory pressure
        if index % 100 == 0 {
            try swiftDataContext.save()
            progressHandler(Double(index) / totalCount)
        }
    }

    try swiftDataContext.save()
    progressHandler(1.0)
}

}

Step 4: Coexistence Pattern

For gradual migration, run both stacks:

@main struct MyApp: App { // CoreData stack for legacy features let coreDataController = CoreDataController.shared

// SwiftData for new features
let swiftDataContainer: ModelContainer

init() {
    let schema = Schema([Note.self, Folder.self, Tag.self])
    let config = ModelConfiguration(
        schema: schema,
        cloudKitDatabase: .automatic
    )

    do {
        swiftDataContainer = try ModelContainer(for: schema, configurations: config)
    } catch {
        fatalError("SwiftData init failed: \(error)")
    }
}

var body: some Scene {
    WindowGroup {
        ContentView()
            .environment(\.managedObjectContext, coreDataController.viewContext)
            .modelContainer(swiftDataContainer)
    }
}

}

// Feature flag for gradual rollout enum DataStoreFeature { static var useSwiftData: Bool { // Check if migration is complete UserDefaults.standard.bool(forKey: "swiftDataMigrationComplete") } }

Schema Versioning

Versioned Schema Definition

// Version 1: Initial schema enum NotesSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0)

static var models: [any PersistentModel.Type] {
    [Note.self]
}

@Model
class Note {
    var title: String = ""
    var content: String = ""
    var createdAt: Date = Date()

    init(title: String) {
        self.title = title
    }
}

}

// Version 2: Add isPinned and folder relationship enum NotesSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0)

static var models: [any PersistentModel.Type] {
    [Note.self, Folder.self]
}

@Model
class Note {
    var title: String = ""
    var content: String = ""
    var createdAt: Date = Date()
    var isPinned: Bool = false  // NEW
    var folder: Folder?         // NEW

    init(title: String) {
        self.title = title
    }
}

@Model
class Folder {
    var name: String = ""
    @Relationship(inverse: \Note.folder)
    var notes: [Note]?

    init(name: String) {
        self.name = name
    }
}

}

// Version 3: Add tags with many-to-many enum NotesSchemaV3: VersionedSchema { static var versionIdentifier = Schema.Version(3, 0, 0)

static var models: [any PersistentModel.Type] {
    [Note.self, Folder.self, Tag.self]
}

@Model
class Note {
    var title: String = ""
    var content: String = ""
    var createdAt: Date = Date()
    var isPinned: Bool = false
    var folder: Folder?
    var tags: [Tag]?  // NEW

    init(title: String) {
        self.title = title
    }
}

@Model
class Folder {
    var name: String = ""
    @Relationship(inverse: \Note.folder)
    var notes: [Note]?

    init(name: String) {
        self.name = name
    }
}

@Model
class Tag {
    var name: String = ""
    @Relationship(inverse: \Note.tags)
    var notes: [Note]?

    init(name: String) {
        self.name = name
    }
}

}

Migration Plan

enum NotesMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [NotesSchemaV1.self, NotesSchemaV2.self, NotesSchemaV3.self] }

static var stages: [MigrationStage] {
    [migrateV1toV2, migrateV2toV3]
}

// Lightweight migration (no data transformation)
static let migrateV1toV2 = MigrationStage.lightweight(
    fromVersion: NotesSchemaV1.self,
    toVersion: NotesSchemaV2.self
)

// Custom migration with data transformation
static let migrateV2toV3 = MigrationStage.custom(
    fromVersion: NotesSchemaV2.self,
    toVersion: NotesSchemaV3.self,
    willMigrate: { context in
        // Pre-migration: Clean up orphaned data
        let descriptor = FetchDescriptor<NotesSchemaV2.Note>(
            predicate: #Predicate { $0.title.isEmpty }
        )
        let emptyNotes = try context.fetch(descriptor)
        for note in emptyNotes {
            context.delete(note)
        }
        try context.save()
    },
    didMigrate: { context in
        // Post-migration: Set default values or transform data
        let descriptor = FetchDescriptor<NotesSchemaV3.Note>()
        let notes = try context.fetch(descriptor)

        // Create default "Untagged" tag for notes without tags
        let untaggedTag = NotesSchemaV3.Tag(name: "Untagged")
        context.insert(untaggedTag)

        for note in notes where note.tags?.isEmpty ?? true {
            note.tags = [untaggedTag]
        }

        try context.save()
    }
)

}

// Use migration plan in container let container = try ModelContainer( for: NotesSchemaV3.Note.self, NotesSchemaV3.Folder.self, NotesSchemaV3.Tag.self, migrationPlan: NotesMigrationPlan.self )

iCloud Sync Conflict Resolution

Understanding Sync Conflicts

┌─────────────────────────────────────────────────────────────┐ │ iCLOUD SYNC TIMELINE │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Device A: Edit note.title = "Hello" ──────┐ │ │ │ CONFLICT! │ │ Device B: Edit note.title = "World" ──────┘ │ │ │ │ CloudKit Resolution: LAST WRITER WINS │ │ (Based on modificationDate) │ │ │ └─────────────────────────────────────────────────────────────┘

Strategy 1: Last-Writer-Wins (Default)

@Model class Note { var title: String = "" var content: String = ""

// CloudKit uses this for conflict resolution
var modificationDate: Date = Date()

func update(title: String) {
    self.title = title
    self.modificationDate = Date()  // Update timestamp
}

}

Strategy 2: Field-Level Merge

@Model class Note { var title: String = "" var content: String = ""

// Track individual field modifications
var titleModifiedAt: Date = Date()
var contentModifiedAt: Date = Date()

func updateTitle(_ newTitle: String) {
    title = newTitle
    titleModifiedAt = Date()
}

func updateContent(_ newContent: String) {
    content = newContent
    contentModifiedAt = Date()
}

// Merge conflicting versions
func merge(with other: Note) {
    if other.titleModifiedAt > self.titleModifiedAt {
        self.title = other.title
        self.titleModifiedAt = other.titleModifiedAt
    }
    if other.contentModifiedAt > self.contentModifiedAt {
        self.content = other.content
        self.contentModifiedAt = other.contentModifiedAt
    }
}

}

Strategy 3: Operational Transformation for Text

@Model class CollaborativeNote { var title: String = ""

// Store operations instead of final state
@Attribute(.externalStorage)
var operationsData: Data?

// Computed content from operations
@Transient
var content: String = ""

struct Operation: Codable {
    enum Kind: Codable {
        case insert(position: Int, text: String)
        case delete(range: Range<Int>)
    }
    let kind: Kind
    let timestamp: Date
    let deviceID: String
}

var operations: [Operation] {
    get {
        guard let data = operationsData else { return [] }
        return (try? JSONDecoder().decode([Operation].self, from: data)) ?? []
    }
    set {
        operationsData = try? JSONEncoder().encode(newValue)
    }
}

func applyOperations() {
    var text = ""
    let sortedOps = operations.sorted { $0.timestamp < $1.timestamp }

    for op in sortedOps {
        switch op.kind {
        case .insert(let position, let insertText):
            let index = text.index(text.startIndex, offsetBy: min(position, text.count))
            text.insert(contentsOf: insertText, at: index)
        case .delete(let range):
            let start = text.index(text.startIndex, offsetBy: range.lowerBound)
            let end = text.index(text.startIndex, offsetBy: min(range.upperBound, text.count))
            text.removeSubrange(start..<end)
        }
    }

    content = text
}

}

Strategy 4: Soft Deletes for Conflict Prevention

@Model class Note { var title: String = "" var content: String = ""

// Soft delete instead of hard delete
var isDeleted: Bool = false
var deletedAt: Date?
var deletedBy: String?  // Device identifier

func softDelete(deviceID: String) {
    isDeleted = true
    deletedAt = Date()
    deletedBy = deviceID
}

func restore() {
    isDeleted = false
    deletedAt = nil
    deletedBy = nil
}

}

// Query only active notes @Query(filter: #Predicate<Note> { !$0.isDeleted }) var activeNotes: [Note]

// Periodically purge soft-deleted items func purgeDeletedNotes(olderThan days: Int, context: ModelContext) throws { let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date())!

try context.delete(model: Note.self, where: #Predicate { note in
    note.isDeleted &#x26;&#x26; (note.deletedAt ?? Date()) &#x3C; cutoff
})

}

Monitoring Sync Status

import Combine

@Observable class SyncMonitor { var syncState: SyncState = .idle var lastSyncDate: Date? var pendingChanges: Int = 0

enum SyncState {
    case idle
    case syncing
    case error(String)
}

private var cancellables = Set&#x3C;AnyCancellable>()

init() {
    // Monitor CloudKit account status
    NotificationCenter.default.publisher(for: .CKAccountChanged)
        .sink { [weak self] _ in
            Task { await self?.checkAccountStatus() }
        }
        .store(in: &#x26;cancellables)

    // Monitor remote changes
    NotificationCenter.default.publisher(
        for: NSPersistentStoreRemoteChange
    )
    .sink { [weak self] notification in
        self?.handleRemoteChange(notification)
    }
    .store(in: &#x26;cancellables)
}

private func checkAccountStatus() async {
    // Check iCloud availability
}

private func handleRemoteChange(_ notification: Notification) {
    lastSyncDate = Date()
    syncState = .idle
}

}

Production Migration Workflow

Pre-Migration Checklist

struct MigrationPreflight {

/// Verify device has sufficient storage
static func checkStorage() throws {
    let fileManager = FileManager.default
    let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!

    let values = try documentDirectory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
    let availableBytes = values.volumeAvailableCapacityForImportantUsage ?? 0

    // Require at least 500MB for migration
    guard availableBytes > 500_000_000 else {
        throw MigrationError.insufficientStorage
    }
}

/// Backup current data before migration
static func backupCoreDataStore() throws -> URL {
    let fileManager = FileManager.default
    let storeURL = CoreDataController.shared.persistentStoreURL

    let backupDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        .appendingPathComponent("Backups")

    try fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true)

    let backupURL = backupDirectory
        .appendingPathComponent("CoreData_\(Date().ISO8601Format()).sqlite")

    try fileManager.copyItem(at: storeURL, to: backupURL)

    // Also backup -wal and -shm files if they exist
    let walURL = storeURL.appendingPathExtension("wal")
    let shmURL = storeURL.appendingPathExtension("shm")

    if fileManager.fileExists(atPath: walURL.path) {
        try fileManager.copyItem(at: walURL, to: backupURL.appendingPathExtension("wal"))
    }
    if fileManager.fileExists(atPath: shmURL.path) {
        try fileManager.copyItem(at: shmURL, to: backupURL.appendingPathExtension("shm"))
    }

    return backupURL
}

/// Verify data integrity before migration
static func validateCoreDataIntegrity() async throws -> ValidationReport {
    let context = CoreDataController.shared.viewContext

    var report = ValidationReport()

    // Count all entities
    let notesFetch = NSFetchRequest&#x3C;NSManagedObject>(entityName: "Note")
    report.notesCount = try context.count(for: notesFetch)

    let foldersFetch = NSFetchRequest&#x3C;NSManagedObject>(entityName: "Folder")
    report.foldersCount = try context.count(for: foldersFetch)

    // Check for orphaned relationships
    let orphanedNotes = try context.fetch(notesFetch).filter { note in
        let folder = note.value(forKey: "folder") as? NSManagedObject
        return folder?.isDeleted ?? false
    }
    report.orphanedRelationships = orphanedNotes.count

    return report
}

}

struct ValidationReport { var notesCount: Int = 0 var foldersCount: Int = 0 var orphanedRelationships: Int = 0

var isValid: Bool {
    orphanedRelationships == 0
}

}

enum MigrationError: LocalizedError { case insufficientStorage case backupFailed case validationFailed case migrationInterrupted

var errorDescription: String? {
    switch self {
    case .insufficientStorage:
        return "Not enough storage space for migration. Please free up at least 500MB."
    case .backupFailed:
        return "Failed to create backup. Migration aborted."
    case .validationFailed:
        return "Data validation failed. Please contact support."
    case .migrationInterrupted:
        return "Migration was interrupted. Your data is safe in the backup."
    }
}

}

Migration UI

struct MigrationView: View { @State private var migrationState: MigrationState = .notStarted @State private var progress: Double = 0 @State private var error: MigrationError?

enum MigrationState {
    case notStarted
    case preparingBackup
    case validating
    case migrating
    case verifying
    case completed
    case failed
}

var body: some View {
    VStack(spacing: 24) {
        Image(systemName: stateIcon)
            .font(.system(size: 60))
            .foregroundStyle(stateColor)

        Text(stateTitle)
            .font(.title2.bold())

        Text(stateDescription)
            .foregroundStyle(.secondary)
            .multilineTextAlignment(.center)

        if migrationState == .migrating {
            ProgressView(value: progress)
                .progressViewStyle(.linear)

            Text("\(Int(progress * 100))%")
                .font(.caption)
                .foregroundStyle(.secondary)
        }

        if case .notStarted = migrationState {
            Button("Start Migration") {
                Task { await startMigration() }
            }
            .buttonStyle(.borderedProminent)
        }

        if case .completed = migrationState {
            Button("Continue to App") {
                UserDefaults.standard.set(true, forKey: "swiftDataMigrationComplete")
            }
            .buttonStyle(.borderedProminent)
        }

        if let error {
            Text(error.localizedDescription)
                .foregroundStyle(.red)
                .font(.caption)
        }
    }
    .padding()
}

private func startMigration() async {
    do {
        // Step 1: Backup
        migrationState = .preparingBackup
        _ = try MigrationPreflight.backupCoreDataStore()

        // Step 2: Validate
        migrationState = .validating
        let report = try await MigrationPreflight.validateCoreDataIntegrity()
        guard report.isValid else {
            throw MigrationError.validationFailed
        }

        // Step 3: Migrate
        migrationState = .migrating
        // ... migration logic with progress updates

        // Step 4: Verify
        migrationState = .verifying
        // ... verification logic

        migrationState = .completed

    } catch let migrationError as MigrationError {
        error = migrationError
        migrationState = .failed
    } catch {
        self.error = .migrationInterrupted
        migrationState = .failed
    }
}

private var stateIcon: String {
    switch migrationState {
    case .notStarted: return "arrow.triangle.2.circlepath"
    case .preparingBackup: return "externaldrive.badge.timemachine"
    case .validating: return "checkmark.shield"
    case .migrating: return "arrow.right.arrow.left"
    case .verifying: return "magnifyingglass"
    case .completed: return "checkmark.circle.fill"
    case .failed: return "xmark.circle.fill"
    }
}

private var stateColor: Color {
    switch migrationState {
    case .completed: return .green
    case .failed: return .red
    default: return .blue
    }
}

private var stateTitle: String {
    switch migrationState {
    case .notStarted: return "Ready to Migrate"
    case .preparingBackup: return "Creating Backup..."
    case .validating: return "Validating Data..."
    case .migrating: return "Migrating..."
    case .verifying: return "Verifying..."
    case .completed: return "Migration Complete!"
    case .failed: return "Migration Failed"
    }
}

private var stateDescription: String {
    switch migrationState {
    case .notStarted:
        return "We'll upgrade your data to the new format. This may take a few minutes."
    case .preparingBackup:
        return "Creating a backup of your data..."
    case .validating:
        return "Checking data integrity..."
    case .migrating:
        return "Transferring your notes to the new format..."
    case .verifying:
        return "Making sure everything transferred correctly..."
    case .completed:
        return "Your data has been successfully migrated!"
    case .failed:
        return "Something went wrong. Your original data is safe."
    }
}

}

Best Practices Summary

DO

// ✓ Version your schemas from day one enum MySchemaV1: VersionedSchema { ... }

// ✓ Use lightweight migrations when possible MigrationStage.lightweight(fromVersion: V1.self, toVersion: V2.self)

// ✓ Design models for iCloud from the start var folder: Folder? // Optional relationships

// ✓ Keep legacy IDs for reference during migration var legacyID: UUID?

// ✓ Create backups before migration try MigrationPreflight.backupCoreDataStore()

// ✓ Use soft deletes for iCloud sync var isDeleted: Bool = false

DON'T

// ✗ Don't use unique constraints with iCloud @Attribute(.unique) var id: String // NOT iCloud compatible

// ✗ Don't delete properties after shipping // Instead, keep them but stop using them

// ✗ Don't change property types var count: Int // Can't change to String later

// ✗ Don't force-unwrap migrated data let title = note.title! // Could be nil during migration

// ✗ Don't skip validation try container.mainContext.save() // Always validate first

Official Resources

  • SwiftData Documentation

  • Adopting SwiftData for a Core Data app

  • WWDC24: Create a custom data store with SwiftData

  • WWDC23: Migrate to SwiftData

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

photographer-testino

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lindbergh

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lachapelle

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-vonunwerth

No summary provided by upstream source.

Repository SourceNeeds Review