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
- Setting Up Coexistence
- Bridging ModelContext and NSManagedObjectContext
- Rewriting NSFetchRequest as #Predicate
- Migrating Relationships
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
Intwhere Core Data usedInt16. 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
@Modelclasses 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 forNSPersistentStoreRemoteChangeNotificationto fire and is essential for CloudKit sync.
Migration Order Strategy
Do not migrate everything at once. Follow this order:
- Read-only entities first. Start with entities your SwiftData code only reads. This eliminates write-conflict risk.
- Leaf entities next. Entities with no relationships, or only outgoing relationships, are simpler to migrate because you do not need to update inverse references.
- Core graph entities last. Entities at the center of your relationship graph (like
Moviein 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:
#Predicatedoes not support every operation thatNSPredicatedoes. Notable gaps includeSUBQUERY, 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(viaModelActor) 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
NSPredicateformat strings as type-safe#Predicateexpressions — 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.