# Core Data and SwiftData together

For a question about running both frameworks or moving from one to the other,
start at [Sharing one store](#sharing-one-store). The Core Data stack section
is context for reading an existing app, not a Core Data tutorial.

## Contents

- [The Core Data side at a glance](#the-core-data-side-at-a-glance)
- [Sharing one store](#sharing-one-store)
- [Migrating an app to SwiftData](#migrating-an-app-to-swiftdata)
- [Checklist](#checklist)

## The Core Data side at a glance

Core Data remains fully supported, and many apps keep it. The pieces you will
meet when reading such an app:

```swift
import CoreData

final class LibraryStack {
    let container: NSPersistentContainer

    init() {
        // "Library" is also the name of Library.xcdatamodeld.
        container = NSPersistentContainer(name: "Library")
        let main = container.viewContext
        main.automaticallyMergesChangesFromParent = true
        main.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
        container.loadPersistentStores { description, loadError in
            guard let loadError else { return }
            fatalError("Could not open \(description.url?.lastPathComponent ?? "store"): \(loadError)")
        }
    }
}
```

- `viewContext` runs on the main queue and serves reads and UI.
- Writes and bulk work use `newBackgroundContext()` inside
  `await context.perform { }`.

Fetching:

```swift
func overdueLoans(for member: String, in context: NSManagedObjectContext) throws -> [CDLoan] {
    let request = NSFetchRequest<CDLoan>(entityName: "Loan")
    let conditions = [
        NSPredicate(format: "memberName ==[cd] %@", member),
        NSPredicate(format: "dueAt < %@", Date() as NSDate)
    ]
    request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: conditions)
    request.sortDescriptors = [NSSortDescriptor(keyPath: \CDLoan.dueAt, ascending: true)]
    request.fetchLimit = 50
    request.relationshipKeyPathsForPrefetching = ["book"]
    return try context.fetch(request)
}

let openCount = try context.count(for: NSFetchRequest<CDLoan>(entityName: "Loan"))
```

Saving, with failures turned into app errors:

```swift
enum LibraryStoreError: Error { case duplicate, missingField, other(Error) }

func commit(_ moc: NSManagedObjectContext) throws {
    if !moc.hasChanges { return }
    do {
        try moc.save()
    } catch let error as NSError {
        moc.rollback()
        switch error.code {
        case NSManagedObjectConstraintMergeError: throw LibraryStoreError.duplicate
        case NSValidationMissingMandatoryPropertyError: throw LibraryStoreError.missingField
        default: throw LibraryStoreError.other(error)
        }
    }
}
```

Bulk insert (iOS 13+) bypasses the context, so push the result into the view
context afterwards:

```swift
func importBooks(_ rows: [[String: any Sendable]], stack: LibraryStack) async throws {
    let context = stack.container.newBackgroundContext()
    let inserted: [NSManagedObjectID] = try await context.perform {
        let insert = NSBatchInsertRequest(entity: CDBook.entity(), objects: rows)
        insert.resultType = .objectIDs
        let outcome = try context.execute(insert)
        return (outcome as? NSBatchInsertResult)?.result as? [NSManagedObjectID] ?? []
    }
    let changes = [NSInsertedObjectsKey: inserted]
    NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes,
                                        into: [stack.container.viewContext])
}
```

Use `resultType = .count` when you only need how many rows went in; request
`.objectIDs`, as above, when you want to merge the inserted objects into other
contexts.

Apple documentation: the `NSPersistentContainer` reference
(https://developer.apple.com/documentation/coredata/nspersistentcontainer) and
the Core Data stack setup article in the same section.

## Sharing one store

Both frameworks can open the same SQLite file, which lets an app move one
screen at a time.

Point SwiftData at the file Core Data already uses:

```swift
let storeURL = NSPersistentContainer.defaultDirectoryURL()
    .appendingPathComponent("Library.sqlite")
let sharedStore = ModelConfiguration("Library", url: storeURL)
let swiftDataSide = try ModelContainer(for: Book.self, Loan.self, configurations: sharedStore)
```

Rules for the shared store:

- Each `@Model` class name equals the Core Data entity name. Name the Core Data
  classes differently (for example `CDBook`) so the Swift types do not collide.
- Property names and types match exactly. Where the Swift name has to differ,
  use `@Attribute(originalName:)`.
- Both stacks use the same file.
- Turn on persistent history (`NSPersistentHistoryTrackingKey`) in the Core
  Data store description.
- Only one framework writes a given entity at a time. Pick the writer per
  entity and keep it there until that entity moves.
- `automaticallyMergesChangesFromParent` and
  `NSPersistentStoreRemoteChangeNotification` let each side notice the other's
  writes.
- A mismatch between the `.xcdatamodeld` and the `@Model` classes crashes at
  load time. Test the pairing thoroughly.

A phased move:

1. Core Data keeps writing; new screens read through SwiftData.
2. New features read and write with SwiftData.
3. Remaining code moves over entity by entity.
4. Delete the Core Data stack and the `.xcdatamodeld`.

Apple documentation: the SwiftData adoption article for Core Data apps, in the
Core Data section of the developer documentation.

## Migrating an app to SwiftData

### Step 1: write one model per entity

To-one relationships become optional references; to-many become arrays. Give
both ends an inverse.

```swift
@Model
final class Book {
    var title: String
    var isbn: String
    var publishedOn: Date?
    @Relationship(deleteRule: .nullify, inverse: \Loan.book)
    var loans: [Loan] = []

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

@Model
final class Loan {
    var memberName: String
    var dueAt: Date
    @Relationship(deleteRule: .nullify)
    var book: Book?

    init(memberName: String, dueAt: Date) {
        self.memberName = memberName
        self.dueAt = dueAt
    }
}
```

Type mapping, SwiftData type first:

| Swift type in the `@Model` | Core Data attribute it replaces |
|---|---|
| `String` | String |
| `Bool` | Boolean |
| `Int` | any of the Integer types (16, 32 or 64 bit) |
| `Double` / `Float` | Double / Float |
| `Decimal` | Decimal |
| `Date` | Date |
| `UUID` | UUID |
| `URL` | URI |
| `Data` | Binary Data |
| A `Codable` struct or enum, stored as a composite attribute | Transformable |
| Optional reference to another `@Model` | To-one relationship |
| `[RelatedModel]` array | To-many relationship |

Compatible `Codable` structs and enums are storable in any SwiftData release;
there is no separate OS threshold for storing them.

### Step 2: open the existing store

If the SwiftData models match the Core Data model exactly, the store opens with
no migration. If you also want to change the schema, describe the current shape
as the first `VersionedSchema` and migrate from there:

```swift
enum LibraryV1: VersionedSchema {
    static let versionIdentifier: Schema.Version = .init(1, 0, 0)

    static var models: [any PersistentModel.Type] { [Book.self, Loan.self] }
    @Model final class Book {
        var title: String
        var isbn: String
        init(title: String, isbn: String) { self.title = title; self.isbn = isbn }
    }
    @Model final class Loan {
        var memberName: String
        var dueAt: Date
        init(memberName: String, dueAt: Date) { self.memberName = memberName; self.dueAt = dueAt }
    }
}

enum LibraryV2: VersionedSchema {
    static let versionIdentifier: Schema.Version = .init(2, 0, 0)

    static var models: [any PersistentModel.Type] { [Book.self, Loan.self, Shelf.self] }
    @Model final class Book {
        var title: String
        var isbn: String
        var isArchived: Bool = false
        init(title: String, isbn: String) { self.title = title; self.isbn = isbn }
    }
    @Model final class Loan {
        var memberName: String
        var dueAt: Date
        init(memberName: String, dueAt: Date) { self.memberName = memberName; self.dueAt = dueAt }
    }
    @Model final class Shelf {
        var code: String
        init(code: String) { self.code = code }
    }
}

enum LibraryMigration: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [LibraryV1.self, LibraryV2.self] }
    static var stages: [MigrationStage] {
        [.lightweight(fromVersion: LibraryV1.self, toVersion: LibraryV2.self)]
    }
}
```

### Step 3: test against a real store

Copy a fixture store taken from a real install, including its `-wal` and
`-shm` files, open it through the migration plan, and check the data.

```swift
import Testing
import SwiftData
import Foundation

@Test func legacyLibraryStoreMigrates() throws {
    let fm = FileManager.default
    let scratch = fm.temporaryDirectory.appending(path: "migration-\(UUID().uuidString)")
    try fm.createDirectory(at: scratch, withIntermediateDirectories: true)
    defer { try? fm.removeItem(at: scratch) }

    for suffix in ["", "-wal", "-shm"] {
        guard let source = Bundle.module.url(forResource: "Library.sqlite\(suffix)",
                                             withExtension: nil) else { continue }
        try fm.copyItem(
            at: source,
            to: scratch.appendingPathComponent("Library.sqlite\(suffix)"))
    }

    let config = ModelConfiguration(url: scratch.appendingPathComponent("Library.sqlite"))
    let container = try ModelContainer(
        for: LibraryV2.Book.self, LibraryV2.Loan.self, LibraryV2.Shelf.self,
        migrationPlan: LibraryMigration.self,
        configurations: config
    )
    let context = ModelContext(container)
    let books = try context.fetch(FetchDescriptor<LibraryV2.Book>())

    #expect(!books.isEmpty)
    #expect(books.allSatisfy { !$0.isArchived })
}
```

Run this against copies of production data before every release that touches
the schema.

## Checklist

- [ ] One `@Model` per entity, with identical names and types.
- [ ] Relationships have inverses on both sides.
- [ ] Non-trivial changes go through a `VersionedSchema` and a
      `SchemaMigrationPlan`.
- [ ] The SwiftData configuration points at the existing SQLite file.
- [ ] The migration was tested on a copy of production data.
- [ ] Each entity has exactly one writer at any time.
- [ ] `automaticallyMergesChangesFromParent` is on in the Core Data stack.
- [ ] The `.xcdatamodeld` is deleted only after everything is verified.
