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

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:

  1. What’s your minimum deployment target? SwiftData requires iOS 17.
  2. How large is your dataset? A hundred records versus a hundred thousand have very different performance profiles.
  3. 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 VersionedSchema in 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:

  1. Identify which models are touched only by new SwiftUI views (low risk, migrate first)
  2. For shared models, create a SwiftData type and a read-only migration layer
  3. Use NSBatchMigrateRequest or a custom migration pass to copy data between store representations
  4. 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)

FactorSwiftDataCore Data
New project, iOS 17+ minimum deployment targetStrongly preferredNo reason to start here
Existing Core Data codebaseIncremental adoption onlyContinue, migrate gradually
CloudKit sync (production-critical)Functional but youngerMature, battle-tested
Dataset > 100k recordsUse with cautionBatch operations win
Complex schema migrations in productionProceed carefullyStrong tooling
SwiftUI-first architectureNative, zero frictionRequires bridging code
UIKit app with NSFetchedResultsControllerNot compatiblePurpose-built
iOS 16 or earlier support requiredUnavailableRequired
Complex subquery predicatesLimited #Predicate supportFull NSPredicate support
App Store shipping today (iOS 17+)Strong choiceLegitimate 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 @Model macro, @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.
  • NSPersistentCloudKitContainer has more production mileage than SwiftData’s CloudKit integration — test edge cases thoroughly before relying on SwiftData sync in production.
  • SwiftData’s #Predicate doesn’t yet support the full range of NSPredicate operations, 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.