Core Data to SwiftData Migration Guide: Incremental Adoption Strategy


You have 200,000 lines of production Core Data code, a CloudKit sync layer that took six months to stabilize, and a manager asking when you will “just switch to SwiftData.” The answer is not a weekend rewrite. It is a deliberate, incremental migration that lets both stacks coexist until SwiftData earns its place in every corner of your persistence layer.

This guide walks you through the full migration path — from running Core Data and SwiftData side by side against the same store, to rewriting fetch requests as #Predicate expressions, to migrating relationships and finally removing the Core Data stack entirely. We will not cover SwiftData fundamentals or schema versioning in isolation — those topics have their own dedicated posts.

Contents

The Problem

Most production iOS apps did not start with SwiftData. They have years of Core Data infrastructure: NSManagedObject subclasses, NSFetchedResultsController bindings, CloudKit mirroring through NSPersistentCloudKitContainer, and migration plans baked into NSPersistentContainer. Ripping all of that out at once is a recipe for data loss and regressions.

Consider a Pixar movie catalog app with the following Core Data model:

// Existing Core Data NSManagedObject subclass
final class CDMovie: NSManagedObject {
    @NSManaged var title: String
    @NSManaged var releaseYear: Int16
    @NSManaged var boxOffice: Decimal
    @NSManaged var director: CDDirector?
    @NSManaged var characters: NSSet?
}

final class CDDirector: NSManagedObject {
    @NSManaged var name: String
    @NSManaged var movies: NSSet?
}

This model has been shipping for three years. It syncs via CloudKit. Users depend on it. You cannot afford a flag-day migration where one build uses Core Data and the next uses SwiftData — if anything goes wrong, you lose user data.

The safe path is incremental: run both stacks against the same underlying SQLite store, migrate one entity at a time, and remove Core Data only when SwiftData has proven it handles every edge case in production.

Setting Up Coexistence

Apple designed SwiftData to share the same SQLite store as Core Data. The key is configuring both stacks to point at the same persistent store URL and using a compatible schema. Apple covered this approach in WWDC23 session “Migrate to SwiftData”.

First, define your SwiftData model alongside the existing Core Data model. The @Model class name can differ from the NSManagedObject subclass, but the underlying entity name in the .xcdatamodeld must match.

import SwiftData

@Model
final class Movie {
    var title: String
    var releaseYear: Int
    var boxOffice: Decimal

    @Relationship(deleteRule: .nullify, inverse: \Director.movies)
    var director: Director?

    @Relationship(deleteRule: .cascade, inverse: \Character.movie)
    var characters: [Character]

    init(title: String, releaseYear: Int, boxOffice: Decimal) {
        self.title = title
        self.releaseYear = releaseYear
        self.boxOffice = boxOffice
        self.characters = []
    }
}

Note: SwiftData uses Int where Core Data used Int16. SwiftData handles the underlying SQLite type mapping automatically — you do not need to match the exact scalar width.

Now configure both stacks to share the same store. The critical piece is passing the same store URL to both NSPersistentContainer and ModelContainer:

import CoreData
import SwiftData

struct PersistenceCoordinator {
    static let sharedStoreURL: URL = {
        let appSupport = FileManager.default.urls(
            for: .applicationSupportDirectory,
            in: .userDomainMask
        ).first!
        return appSupport.appendingPathComponent("PixarCatalog.sqlite")
    }()

    // Core Data stack -- your existing setup
    static func makeCoreDataContainer() -> NSPersistentCloudKitContainer {
        let container = NSPersistentCloudKitContainer(name: "PixarCatalog")
        let description = NSPersistentStoreDescription(url: sharedStoreURL)
        description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
            containerIdentifier: "iCloud.com.example.pixarcatalog"
        )
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { _, error in
            if let error { fatalError("Core Data store failed: \(error)") }
        }
        return container
    }

    // SwiftData stack -- points at the same store
    static func makeModelContainer() throws -> ModelContainer {
        let schema = Schema([Movie.self, Director.self, Character.self])
        let config = ModelConfiguration(
            "PixarCatalog",
            schema: schema,
            url: sharedStoreURL,
            cloudKitDatabase: .automatic
        )
        return try ModelContainer(for: schema, configurations: [config])
    }
}

Warning: Both stacks must agree on the schema. If your Core Data model has attributes or entities that your SwiftData @Model classes do not declare, the store will still load, but SwiftData will ignore those columns. Any writes from SwiftData will not populate undeclared columns, which can cause data inconsistencies. Migrate entities one at a time and keep the schemas in sync.

Bridging ModelContext and NSManagedObjectContext

During the migration period, some parts of your app will read from Core Data and others from SwiftData. You need a strategy for keeping both contexts aware of changes.

The simplest approach is to treat the SQLite file as the source of truth and refetch after writes. A more reactive approach uses NSPersistentStoreRemoteChangeNotification to detect when either stack writes to the shared store:

import CoreData
import SwiftData
import Combine

@Observable
final class MovieCatalogStore {
    private let coreDataContext: NSManagedObjectContext
    private let modelContext: ModelContext
    private var cancellables = Set<AnyCancellable>()

    init(
        coreDataContainer: NSPersistentContainer,
        modelContainer: ModelContainer
    ) {
        self.coreDataContext = coreDataContainer.viewContext
        self.modelContext = ModelContext(modelContainer)

        // Listen for remote changes from either stack
        NotificationCenter.default.publisher(
            for: .NSPersistentStoreRemoteChange
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _ in
            self?.coreDataContext.refreshAllObjects()
            // SwiftData auto-refreshes on the next query
        }
        .store(in: &cancellables)
    }

    /// Use this during migration: read from SwiftData, fall back to Core Data
    func fetchMoviesByYear(_ year: Int) throws -> [Movie] {
        let predicate = #Predicate<Movie> { $0.releaseYear == year }
        let descriptor = FetchDescriptor(predicate: predicate)
        return try modelContext.fetch(descriptor)
    }
}

Tip: Set NSPersistentStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) on your Core Data store description. This enables persistent history tracking, which is required for NSPersistentStoreRemoteChangeNotification to fire and is essential for CloudKit sync.

Migration Order Strategy

Do not migrate everything at once. Follow this order:

  1. Read-only entities first. Start with entities your SwiftData code only reads. This eliminates write-conflict risk.
  2. Leaf entities next. Entities with no relationships, or only outgoing relationships, are simpler to migrate because you do not need to update inverse references.
  3. Core graph entities last. Entities at the center of your relationship graph (like Movie in our example) should migrate last, once all connected entities are already on SwiftData.

Rewriting NSFetchRequest as #Predicate

The most tedious part of migration is converting NSFetchRequest with NSPredicate format strings into SwiftData’s type-safe #Predicate macro. The good news: the compiler catches mistakes that NSPredicate format strings hide until runtime.

Here is a typical Core Data fetch:

// Core Data -- stringly-typed, no compile-time safety
let request: NSFetchRequest<CDMovie> = CDMovie.fetchRequest()
request.predicate = NSPredicate(
    format: "releaseYear >= %d AND director.name == %@",
    2015,
    "Pete Docter"
)
request.sortDescriptors = [
    NSSortDescriptor(key: "releaseYear", ascending: false)
]
let movies = try coreDataContext.fetch(request)

The equivalent SwiftData query:

// SwiftData -- type-safe, compiler-verified
let directorName = "Pete Docter"
let minYear = 2015

let predicate = #Predicate<Movie> {
    $0.releaseYear >= minYear &&
    $0.director?.name == directorName
}

var descriptor = FetchDescriptor(predicate: predicate)
descriptor.sortBy = [SortDescriptor(\.releaseYear, order: .reverse)]

let movies = try modelContext.fetch(descriptor)

Apple Docs: FetchDescriptor — SwiftData

Common NSPredicate to #Predicate Conversions

Here is a reference table for patterns you will encounter frequently:

NSPredicate Format#Predicate Equivalent
"title CONTAINS[cd] %@"$0.title.localizedStandardContains(query)
"characters.@count > %d"$0.characters.count > threshold
"releaseYear IN %@"validYears.contains($0.releaseYear)
"director == nil"$0.director == nil
"ANY characters.name == %@"$0.characters.contains(where: { $0.name == name })

Warning: #Predicate does not support every operation that NSPredicate does. Notable gaps include SUBQUERY, regex matching, and certain aggregate functions. If you hit an unsupported operation, keep that specific fetch on Core Data until SwiftData adds support. Check the Foundation Predicate documentation for the current list of supported expressions.

Batch Fetching and Pagination

Core Data’s fetchBatchSize and fetchLimit have direct SwiftData equivalents:

// Core Data
request.fetchBatchSize = 50
request.fetchLimit = 200
request.fetchOffset = 100

// SwiftData equivalent
var descriptor = FetchDescriptor<Movie>(predicate: predicate)
descriptor.fetchLimit = 200
descriptor.fetchOffset = 100
// SwiftData handles batching automatically through faulting

Migrating Relationships

Relationships are where incremental migration gets tricky. When Movie is on SwiftData but Director is still on Core Data, neither stack can enforce the relationship at the object-graph level. You have two options during the transition:

Option 1: Store the Foreign Key Manually

Temporarily replace the relationship with a stored identifier. This is the safer approach for large codebases:

@Model
final class Movie {
    var title: String
    var releaseYear: Int
    var boxOffice: Decimal

    // Temporary: store director's objectID as a URI string
    // until Director is migrated to SwiftData
    var directorURI: String?

    @Relationship(deleteRule: .cascade, inverse: \Character.movie)
    var characters: [Character]

    init(title: String, releaseYear: Int, boxOffice: Decimal) {
        self.title = title
        self.releaseYear = releaseYear
        self.boxOffice = boxOffice
        self.characters = []
    }
}

Resolve the reference at query time:

extension MovieCatalogStore {
    func director(for movie: Movie) -> CDDirector? {
        guard let uriString = movie.directorURI,
              let uri = URL(string: uriString),
              let objectID = coreDataContext.persistentStoreCoordinator?
                  .managedObjectID(forURIRepresentation: uri)
        else { return nil }

        return try? coreDataContext.existingObject(with: objectID) as? CDDirector
    }
}

Option 2: Migrate Connected Entities Together

If two entities are tightly coupled (like Movie and Director in a catalog app), migrate them as a pair. This preserves the relationship at the SwiftData level and avoids the temporary foreign-key workaround:

@Model
final class Director {
    var name: String

    @Relationship(deleteRule: .nullify)
    var movies: [Movie]

    init(name: String) {
        self.name = name
        self.movies = []
    }
}

Write a one-time data migration that reads from Core Data and inserts into SwiftData:

func migrateDirectorsAndMovies(
    from coreDataContext: NSManagedObjectContext,
    to modelContext: ModelContext
) throws {
    let request: NSFetchRequest<CDDirector> = CDDirector.fetchRequest()
    let cdDirectors = try coreDataContext.fetch(request)

    for cdDirector in cdDirectors {
        let director = Director(name: cdDirector.name)

        let cdMovies = cdDirector.movies?.allObjects as? [CDMovie] ?? []
        for cdMovie in cdMovies {
            let movie = Movie(
                title: cdMovie.title,
                releaseYear: Int(cdMovie.releaseYear),
                boxOffice: cdMovie.boxOffice
            )
            movie.director = director
            director.movies.append(movie)
            modelContext.insert(movie)
        }

        modelContext.insert(director)
    }

    try modelContext.save()
}

Tip: Run data migrations on a background ModelContext (via ModelActor) to avoid blocking the main thread. For catalogs with thousands of entries — imagine every Pixar short and feature film — this operation can take several seconds.

Advanced Usage

CloudKit Coexistence

If your Core Data stack uses NSPersistentCloudKitContainer, the shared store already syncs via CloudKit. When you add SwiftData pointed at the same store, SwiftData will also benefit from that sync — but with caveats.

SwiftData’s own ModelConfiguration.cloudKitDatabase property controls whether SwiftData initiates its own CloudKit mirroring. During migration, you should let only one stack drive sync:

// During migration: let Core Data handle CloudKit sync
let config = ModelConfiguration(
    "PixarCatalog",
    schema: schema,
    url: PersistenceCoordinator.sharedStoreURL,
    cloudKitDatabase: .none // Core Data manages sync
)

Once you have fully migrated to SwiftData and removed the Core Data stack, switch to .automatic:

// Post-migration: SwiftData handles sync
let config = ModelConfiguration(
    "PixarCatalog",
    schema: schema,
    url: PersistenceCoordinator.sharedStoreURL,
    cloudKitDatabase: .automatic
)

Warning: Running both stacks with CloudKit mirroring enabled simultaneously can cause duplicate sync operations and conflict records. Always disable CloudKit on the SwiftData side until Core Data is fully removed.

Thread Safety During Migration

Core Data’s concurrency model (perform/performAndWait) and SwiftData’s actor-based isolation do not interoperate directly. If your app uses background Core Data contexts for heavy writes, ensure those writes complete before the SwiftData ModelContext reads from the same store:

actor BackgroundMigrator: ModelActor {
    let modelContainer: ModelContainer
    let modelExecutor: any ModelExecutor

    init(container: ModelContainer) {
        self.modelContainer = container
        let context = ModelContext(container)
        self.modelExecutor = DefaultSerialModelExecutor(modelContext: context)
    }

    func importLegacyData(_ records: [LegacyMovieRecord]) throws {
        for record in records {
            let movie = Movie(
                title: record.title,
                releaseYear: record.year,
                boxOffice: record.revenue
            )
            modelExecutor.modelContext.insert(movie)
        }
        try modelExecutor.modelContext.save()
    }
}

Apple Docs: ModelActor — SwiftData

Handling the Transition in SwiftUI

During migration, your SwiftUI views may need to read from both stacks. Use a wrapper that abstracts the data source:

struct MovieListView: View {
    @Query(
        filter: #Predicate<Movie> { $0.releaseYear >= 2020 },
        sort: \.releaseYear,
        order: .reverse
    )
    private var recentMovies: [Movie]

    var body: some View {
        List(recentMovies) { movie in
            VStack(alignment: .leading) {
                Text(movie.title)
                    .font(.headline)
                Text("Released: \(movie.releaseYear)")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .navigationTitle("Recent Pixar Films")
    }
}

Views that still read Core Data entities can continue using @FetchRequest until those entities are migrated. There is no requirement to migrate all views simultaneously.

Performance Considerations

Incremental migration means two object-relational mapping layers are active simultaneously. Here is what to watch for:

Memory overhead. Both NSManagedObjectContext and ModelContext maintain their own row caches. If the same entities are loaded by both stacks, you are paying double the memory cost. Migrate one entity at a time and remove the Core Data fetch path before migrating the next entity.

SQLite locking. Both stacks use WAL (Write-Ahead Logging) mode by default, which allows concurrent reads. However, simultaneous writes from both stacks can cause brief lock contention. Profile with Instruments’ Core Data template if you notice UI hitches during writes.

Faulting behavior differences. Core Data aggressively faults objects (loads them lazily). SwiftData also supports faulting through @Relationship but may pre-fetch related objects depending on the query. If you notice increased memory usage after migrating an entity with many relationships, check whether SwiftData is eagerly loading the full graph:

// Limit relationship prefetching in your FetchDescriptor
var descriptor = FetchDescriptor<Movie>()
descriptor.propertiesToFetch = [\.title, \.releaseYear]
descriptor.relationshipKeyPathsForPrefetching = [] // Don't prefetch relationships

Migration script performance. For large datasets (tens of thousands of records), batch your one-time data migration and save periodically to avoid unbounded memory growth:

func batchMigrate(
    from coreDataContext: NSManagedObjectContext,
    to modelContext: ModelContext,
    batchSize: Int = 500
) throws {
    let request: NSFetchRequest<CDMovie> = CDMovie.fetchRequest()
    request.fetchBatchSize = batchSize

    let cdMovies = try coreDataContext.fetch(request)
    var count = 0

    for cdMovie in cdMovies {
        let movie = Movie(
            title: cdMovie.title,
            releaseYear: Int(cdMovie.releaseYear),
            boxOffice: cdMovie.boxOffice
        )
        modelContext.insert(movie)
        count += 1

        if count % batchSize == 0 {
            try modelContext.save()
        }
    }

    // Save remaining records
    try modelContext.save()
}

When to Use (and When Not To)

Greenfield app or prototype: Skip Core Data entirely. Use SwiftData from day one.

App with fewer than 5 Core Data entities, no CloudKit: Migrate all at once — the scope is small enough for a single release.

App with CloudKit sync and complex relationships: Use the incremental strategy described here. Migrate one entity at a time over multiple releases.

App using Core Data with NSFetchedResultsController heavily: Migrate views one screen at a time. Replace NSFetchedResultsController with SwiftData’s @Query macro in SwiftUI, or keep using Core Data in UIKit screens until they are rewritten.

App with custom NSIncrementalStore: SwiftData does not support custom store types in the same way. Evaluate whether custom SwiftData data stores (iOS 18+) meet your needs before committing.

App that must support iOS 16 or earlier: SwiftData requires iOS 17+. You cannot migrate until you drop iOS 16.

Note: Apple continues to invest in both Core Data and SwiftData. Core Data is not deprecated. If your existing Core Data stack works well and you are not planning a SwiftUI rewrite, there is no urgency to migrate. Migrate when SwiftData gives you a concrete advantage — simpler code, better SwiftUI integration, or reduced maintenance burden.

Summary

  • Run Core Data and SwiftData against the same SQLite store using a shared store URL and compatible schemas.
  • Migrate entities incrementally: read-only first, leaf entities next, core graph entities last.
  • Rewrite NSPredicate format strings as type-safe #Predicate expressions — the compiler will catch errors that previously crashed at runtime.
  • Handle cross-stack relationships with temporary URI-based foreign keys, or migrate tightly coupled entities together.
  • Disable CloudKit mirroring on the SwiftData side until Core Data is fully removed to avoid duplicate sync operations.

For schema versioning strategies that protect user data during these migrations, see SwiftData Schema Migrations. And once your SwiftData stack is live, SwiftData Persistent History and Change Tracking will help you keep multiple contexts and devices in sync.