SwiftData vs Core Data: An Honest Comparison and Migration Decision Guide
SwiftData launched at WWDC 2023 with the promise of replacing Core Data for most use cases. Two years in, the honest answer is more nuanced — SwiftData is excellent for new projects on iOS 17+, but Core Data still wins in several important scenarios, and pretending otherwise will cost you production incidents.
This post is an analytical comparison, not a tutorial. We’ll cover what each framework genuinely does better, where the feature gaps are real, how CloudKit sync compares, and how to migrate incrementally if you need to. We won’t cover basic CRUD operations — the SwiftData introduction covers those.
Contents
- The Problem: Choosing a Persistence Stack
- What SwiftData Does Better
- What Core Data Still Does Better
- CloudKit Sync Comparison
- Migration Path: Core Data to SwiftData
- Advanced Usage: Running Both in the Same App
- When to Use (and When Not To)
- Summary
The Problem: Choosing a Persistence Stack
You’re starting a new app — a Pixar film tracker that lets users catalog their favorite movies, rate them, and sync across devices. Or you have a shipping Core Data app and you’re evaluating whether to migrate to SwiftData. Either way, you need a clear-eyed answer, not marketing copy.
The naive answer is “use SwiftData for new projects.” That’s often right, but it misses three critical questions:
- What’s your minimum deployment target? SwiftData requires iOS 17.
- How large is your dataset? A hundred records versus a hundred thousand have very different performance profiles.
- How complex are your data relationships and migration history? Core Data’s 15-year head start on schema evolution matters.
Here’s what the decision actually looks like in code. A simple PixarFilm model in both frameworks:
// Core Data — requires .xcdatamodeld file and NSManagedObject subclass
import CoreData
@objc(PixarFilm)
public class PixarFilm: NSManagedObject {
@NSManaged public var title: String
@NSManaged public var releaseYear: Int32
@NSManaged public var studio: String
@NSManaged public var userRating: Double
}
// You also need NSEntityDescription, NSFetchRequest, and
// a configured NSPersistentContainer somewhere in your app.
// SwiftData — pure Swift, no separate model file
import SwiftData
@available(iOS 17.0, *)
@Model
final class PixarFilm {
var title: String
var releaseYear: Int
var studio: String
var userRating: Double
init(title: String, releaseYear: Int, studio: String, userRating: Double) {
self.title = title
self.releaseYear = releaseYear
self.studio = studio
self.userRating = userRating
}
}
The difference in ceremony is real. But ceremony isn’t the only thing that matters in production.
What SwiftData Does Better
Pure Swift Model Definitions
The @Model macro generates the persistence infrastructure at compile time. No .xcdatamodeld file, no
NSEntityDescription, no @NSManaged dance. Your model is a plain Swift class with a single macro annotation.
Apple Docs:
@Model— SwiftData
This matters beyond aesthetics. When your model lives in a .xcdatamodeld file, you lose Swift’s type system for free —
you’re working with Any underneath, and property mismatches surface at runtime. With @Model, the compiler is your
schema validator.
SwiftUI Integration via @Query
Core Data’s NSFetchedResultsController was designed for UIKit. Bridging it to SwiftUI requires @FetchRequest or a
custom ObservableObject wrapper, and neither solution feels native.
SwiftData’s @Query is designed from scratch for SwiftUI’s
observation system:
@available(iOS 17.0, *)
struct PixarFilmListView: View {
// Automatically fetches, sorts, and re-renders when data changes.
// No NSFetchedResultsController. No @Published. No manual invalidation.
@Query(sort: \PixarFilm.releaseYear, order: .reverse)
private var films: [PixarFilm]
@Environment(\.modelContext) private var modelContext
var body: some View {
List(films) { film in
FilmRow(film: film)
}
.toolbar {
Button("Add") {
let film = PixarFilm(
title: "Inside Out 3",
releaseYear: 2027,
studio: "Pixar",
userRating: 0.0
)
modelContext.insert(film)
}
}
}
}
Compared to the equivalent Core Data implementation requiring @FetchRequest with an NSFetchRequest configuration and
explicit sort descriptors, the signal-to-noise ratio is dramatically better.
Native Async/Await Support
Core Data’s concurrency model predates Swift concurrency. Performing background saves requires explicit
performBackgroundTask closures, and you must meticulously avoid passing NSManagedObject instances across context
boundaries.
SwiftData is built around Swift’s actor model. A background operation looks like this:
@available(iOS 17.0, *)
func importFilmography(films: [FilmDTO]) async throws {
let container = try ModelContainer(for: PixarFilm.self)
// ModelActor gives you a safe background execution context.
// No performBackgroundTask. No "use objectID to re-fetch on another context."
try await container.mainContext.save()
}
Note: For heavyweight background imports, use
ModelActor(available iOS 17+) to get a dedicated background context that respects Swift’s actor isolation guarantees.
Less Infrastructure Code
A production Core Data stack typically requires 50–100 lines of boilerplate: persistent container setup, merge policy
configuration, context saving, notification observers for context changes. SwiftData collapses this to a single
modelContainer modifier on your App or scene:
@available(iOS 17.0, *)
@main
struct PixarTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// SwiftData handles the entire stack configuration.
.modelContainer(for: [PixarFilm.self, PixarDirector.self])
}
}
What Core Data Still Does Better
Performance at Scale
Core Data’s batch operations have no equivalent in SwiftData. If you’re processing 100,000+ records — think an
offline-first app syncing a large dataset — Core Data’s
NSBatchInsertRequest and
NSBatchDeleteRequest bypass the object
graph entirely and write directly to the SQLite store:
// Core Data batch insert — directly writes to SQLite, no object graph overhead
func batchImportFilms(_ filmData: [[String: Any]]) throws {
let batchInsert = NSBatchInsertRequest(
entityName: "PixarFilm",
objects: filmData
)
batchInsert.resultType = .statusOnly
let result = try context.execute(batchInsert) as? NSBatchInsertResult
guard result?.result as? Bool == true else {
throw PersistenceError.batchInsertFailed
}
}
SwiftData has no equivalent. Inserting 100,000 @Model instances creates 100,000 objects in the managed object graph
before they’re flushed to disk. In informal testing, Core Data batch inserts on large datasets run 5–10x faster than
SwiftData’s equivalent loop.
If you’re building an app where dataset size is unbounded — a photo library tool, a health data aggregator, a notes app with years of history — this gap matters.
Complex Migration Support
Core Data has 15 years of battle-hardened migration support. Lightweight migration handles common property changes
automatically. Heavyweight migration with
NSMappingModel handles complex transformations.
Custom migration policies let you run arbitrary Swift code during migration.
SwiftData’s VersionedSchema and
SchemaMigrationPlan work for
straightforward cases, but the tooling is younger and the documentation thinner. If you’re shipping an app with tens of
thousands of users who have months of data, Core Data’s migration guarantees are more battle-tested.
Note: The SwiftData Schema Migrations post covers
VersionedSchemain depth. For existing Core Data codebases, the migration tooling gap is a concrete reason to stay put for now.
Predicate Power
Core Data’s NSPredicate string format is a full query language built on top of SQL. SwiftData’s
#Predicate macro is type-safe and excellent for
simple cases, but it doesn’t yet support the full range of operations NSPredicate does.
Concrete examples of things NSPredicate handles that #Predicate does not support as of iOS 17–18:
// Core Data — subquery across a to-many relationship
let predicate = NSPredicate(
format: "SUBQUERY(characters, $c, $c.hasDialogue == YES).@count > 3"
)
// Core Data — aggregate operations
let popularFilms = NSPredicate(
format: "ANY reviews.score > 8.5"
)
// Core Data — case/diacritic insensitive string matching
let searchPredicate = NSPredicate(
format: "title CONTAINS[cd] %@", searchTerm
)
SwiftData’s #Predicate handles straightforward comparisons and basic string operations, but complex subqueries over
to-many relationships require falling back to raw fetch requests. If your app has sophisticated search or filtering
requirements, this gap is material.
Sectioned List Support
NSFetchedResultsController supports sectionNameKeyPath, which gives you zero-configuration sectioned UITableView
or List data with automatic section management. SwiftData’s @Query has no equivalent. You must sort and group data
manually in your view model or view layer:
// The SwiftData workaround — manual grouping in the view
@available(iOS 17.0, *)
struct FilmsByYearView: View {
@Query(sort: \PixarFilm.releaseYear)
private var films: [PixarFilm]
// You implement this grouping yourself. NSFetchedResultsController gives it for free.
private var filmsByYear: [(Int, [PixarFilm])] {
Dictionary(grouping: films, by: \.releaseYear)
.sorted { $0.key > $1.key }
.map { ($0.key, $0.value) }
}
var body: some View {
List {
ForEach(filmsByYear, id: \.0) { year, films in
Section(header: Text(String(year))) {
ForEach(films) { film in FilmRow(film: film) }
}
}
}
}
}
This isn’t terrible, but it means the grouping logic lives in the view, which has implications for testability and reuse.
Explicit Background Context Management
Core Data’s NSManagedObjectContext
concurrency model is verbose but explicit. You know exactly which context you’re on, merge policies are configurable per
context, and you can create an arbitrary number of child contexts for complex undo stacks or speculative edits.
SwiftData’s context management is simpler but less flexible. The mainContext is your primary interface, and
ModelActor provides background contexts — but the explicit merge conflict resolution and context hierarchy features
that experienced Core Data engineers rely on aren’t fully exposed.
CloudKit Sync Comparison
Both frameworks support iCloud sync, but the implementation maturity differs.
Core Data’s
NSPersistentCloudKitContainer has
been shipping since iOS 13. It’s been through multiple cycles of bug reports, edge case refinement, and production
hardening. The import/export event system, conflict resolution behavior, and network efficiency have all improved over
several years.
// Core Data + CloudKit — explicit, mature, well-documented
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "PixarTracker")
let description = container.persistentStoreDescriptions.first!
description.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourcompany.pixartracker"
)
container.loadPersistentStores { _, error in
if let error { fatalError("Store failed to load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}()
SwiftData adds CloudKit sync through the ModelContainer configuration:
@available(iOS 17.0, *)
let config = ModelConfiguration(
schema: Schema([PixarFilm.self]),
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic // Enables CloudKit sync
)
let container = try ModelContainer(for: PixarFilm.self, configurations: config)
SwiftData’s CloudKit integration works, but it’s currently more of a thin wrapper over NSPersistentCloudKitContainer
than a ground-up rethinking of sync. Edge cases around conflict resolution and offline behavior that Core Data apps have
learned to handle carefully are less documented in the SwiftData context.
Tip: If CloudKit sync reliability is a non-negotiable requirement for your app and you’re migrating from Core Data, test your SwiftData sync implementation exhaustively on devices with intermittent connectivity before shipping. The basics work; the edges are less proven.
Migration Path: Core Data to SwiftData
The strategic answer for most teams with an existing Core Data app is: don’t do a big-bang migration. Adopt SwiftData incrementally.
Apple provides a coexistence path via
NSPersistentContainer and
ModelContainer sharing the same underlying
SQLite store. New models can be written in SwiftData while existing models stay in Core Data.
@available(iOS 17.0, *)
func makeSharedContainer() throws -> (NSPersistentContainer, ModelContainer) {
// 1. Set up the Core Data container as normal.
let coreDataContainer = NSPersistentContainer(name: "PixarTracker")
coreDataContainer.loadPersistentStores { _, error in
if let error { fatalError("Core Data failed: \(error)") }
}
// 2. Point SwiftData at the same store URL.
guard let storeURL = coreDataContainer.persistentStoreDescriptions.first?.url else {
throw MigrationError.missingStoreURL
}
let config = ModelConfiguration(url: storeURL)
let modelContainer = try ModelContainer(
for: PixarFilmReview.self, // New SwiftData-only model
configurations: config
)
return (coreDataContainer, modelContainer)
}
This lets you write new features in SwiftData while leaving the Core Data models that power your existing screens untouched. Migrate model by model as you refactor screens. The risk at each step is contained.
Warning: When sharing a SQLite store between Core Data and SwiftData, both frameworks must agree on the schema. SwiftData will create its own metadata tables. Test this configuration carefully before shipping — a schema conflict can corrupt the store.
The practical migration checklist:
- Identify which models are touched only by new SwiftUI views (low risk, migrate first)
- For shared models, create a SwiftData type and a read-only migration layer
- Use
NSBatchMigrateRequestor a custom migration pass to copy data between store representations - Delete the Core Data model file for each entity only after all screens using it have been migrated
Advanced Usage: Running Both in the Same App
The coexistence approach isn’t just a migration strategy — some apps will run both frameworks indefinitely. An app that needs Core Data’s batch performance for a large catalog while using SwiftData for user preferences and recently-viewed items is a legitimate architecture.
When bridging the two, the key rule is: never pass NSManagedObject instances across the boundary into SwiftData
queries, and vice versa. Build a DTO (data transfer object) layer:
// Simplified for clarity — production DTOs would include more fields.
// Core Data side: fetch heavy catalog data
struct FilmCatalogDTO: Sendable {
let objectIDURIRepresentation: URL // Core Data permanent ID
let title: String
let releaseYear: Int
}
// SwiftData side: store user-specific data
@available(iOS 17.0, *)
@Model
final class FilmUserData {
var filmObjectIDURI: String // Reference back to the Core Data record
var userRating: Double
var isFavorite: Bool
var lastViewed: Date
init(filmObjectIDURI: String) {
self.filmObjectIDURI = filmObjectIDURI
self.userRating = 0
self.isFavorite = false
self.lastViewed = .now
}
}
This DTO boundary prevents the frameworks from stepping on each other’s threading guarantees and makes each framework’s responsibilities explicit.
Apple Docs:
NSManagedObjectContext— Core Data |ModelContext— SwiftData
When to Use (and When Not To)
| Factor | SwiftData | Core Data |
|---|---|---|
| New project, iOS 17+ minimum deployment target | Strongly preferred | No reason to start here |
| Existing Core Data codebase | Incremental adoption only | Continue, migrate gradually |
| CloudKit sync (production-critical) | Functional but younger | Mature, battle-tested |
| Dataset > 100k records | Use with caution | Batch operations win |
| Complex schema migrations in production | Proceed carefully | Strong tooling |
| SwiftUI-first architecture | Native, zero friction | Requires bridging code |
UIKit app with NSFetchedResultsController | Not compatible | Purpose-built |
| iOS 16 or earlier support required | Unavailable | Required |
| Complex subquery predicates | Limited #Predicate support | Full NSPredicate support |
| App Store shipping today (iOS 17+) | Strong choice | Legitimate alternative |
The meta-rule: SwiftData is the better default for new projects targeting iOS 17+. Choose Core Data deliberately when you have a specific need it meets better — not out of inertia, but not out of novelty either.
Summary
- SwiftData’s
@Modelmacro,@Query, and@Environment(\.modelContext)eliminate the majority of Core Data’s boilerplate for new SwiftUI apps. - Core Data’s batch operations (
NSBatchInsertRequest,NSBatchDeleteRequest) have no SwiftData equivalent and are significantly faster at scale. NSPersistentCloudKitContainerhas more production mileage than SwiftData’s CloudKit integration — test edge cases thoroughly before relying on SwiftData sync in production.- SwiftData’s
#Predicatedoesn’t yet support the full range ofNSPredicateoperations, particularly complex subqueries over to-many relationships. - Incremental migration — sharing a SQLite store between both frameworks — is safer than a big-bang rewrite and is officially supported.
- The minimum deployment target is non-negotiable: SwiftData requires iOS 17.
Once you’ve committed to SwiftData, the next complexity you’ll encounter is schema evolution — how to version your
@Model types without losing user data. SwiftData Schema Migrations walks
through the full migration system.