SwiftData vs SQLiteData vs Core Data in 2026

SwiftData vs SQLiteData vs Core Data in 2026

⚠️ Speculative Architecture & Preview: This article discusses future system iterations (e.g., iOS 27, Xcode 27) as conceptual planning and architectural design patterns. Technical details represent previews and proposals rather than finalized APIs.

SwiftData vs SQLiteData vs Core Data in 2026

The selection criterion for an iOS persistence 2026 stack is the hardest query your app will actually run — not the demo query, the one your analytics screen issues against 800,000 rows on a background thread. SwiftData vs Core Data both cede ground to raw SQL the moment that query needs a grouped aggregate; the question is how far that gap is allowed to push the architecture.

Write the query three ways before any model code exists:

// "Top 3 lists by completed-item ratio, trailing 30 days, empty lists
// excluded." Attempt one — SwiftData. The aggregate lives in SQL but
// #Predicate will not let us express it, so we load the table and
// reduce in Swift. Fine at 10k rows; at 1.5M this fallback is what
// Instruments calls "the cold-launch materialisation of everything".
let since = Calendar.current.date(byAdding: .day, value: -30, to: .now)!
let all = try context.fetch(FetchDescriptor<Task>(
    predicate: #Predicate { $0.dueAt >= since }
))
let ranked = Dictionary(grouping: all, by: \.listID)
    .compactMap { group -> (UUID, Double)? in
        guard !group.value.isEmpty else { return nil }
        let done = group.value.reduce(0) { $0 + ($1.isDone ? 1 : 0) }
        return (group.key, Double(done) / Double(group.value.count))
    }
    .sorted { $0.1 > $1.1 }
    .prefix(3)

// Attempt two — Core Data. NSExpressionDescription + propertiesToGroupBy
// push COUNT(*) into SQLite; one round trip, three rows back. You pay
// for NSPredicate escapecraft: the format string is a runtime typo
// away from crashing, and you traded compile-time checkability for the
// reach. For 2026, that is a deliberate trade, not an accident.
let groupAgg = NSExpression(forFunction: "count:",
                            arguments: [NSExpression(forKeyPath: "self")])
let desc = NSExpressionDescription()
desc.name = "count"
desc.expression = groupAgg
desc.resultType = .integer64AttributeType
let req = NSFetchRequest<NSFetchRequestResult>(entityName: "Task")
req.resultType = .dictionaryResultType
req.propertiesToGroupBy = [NSExpression(forKeyPath: "listID")]
req.propertiesToFetch = [NSExpression(forKeyPath: "listID"), desc]
req.havingPredicate = NSPredicate(format: "count: > 0")

// Attempt three — SQLiteData (GRDB). The query, in the language the
// store speaks, with SQLiteData's Swift-native row surface holding it.
let rows = try Row.fetchAll(database, sql: """
    SELECT listID, CAST(SUM(isDone) AS REAL) / COUNT(*) AS ratio
    FROM task WHERE dueAt >= :since
    GROUP BY listID ORDER BY ratio DESC LIMIT 3
    """, arguments: ["since": since])

Two of the three compile, run, and give the right answer. Only the third tells the store which lists are empty without loading them — and it is the one that is never the default choice. That asymmetry is the entire thesis of this article.

The Verdict

  • SwiftData wins the developer experience and loses the three decisions that come back to bite you: migration control, predicate expressiveness, and large-dataset behavior. The lead it holds over Core Data on ergonomics is real, and it is narrow.
  • Core Data still owns the production floor — full NSPredicate/NSExpression reach (including SUBQUERY and grouped aggregates executed in SQL), NSBatchUpdateRequest/NSBatchDeleteRequest, persistent history tokens, and a two-decade migration toolchain.
  • SQLiteData exposes SQLite directly with a Swift-native surface (built on GRDB): joins, aggregates, common table expressions, and raw SQL behind @FetchAll/@FetchOne, usable from UIKit, @Observable models, and SwiftUI alike.
  • Choose by query complexity and schema churn, not team preference. Apps with frequent migrations or analytical queries outgrow SwiftData and pay a painful escape later — and the escape is more expensive than the adoption was.
  • Prototype the hardest query before committing. The stack that expresses your worst query first is the stack you should keep.

One SQLite File, Three APIs Standing on It

None of the three is a new storage engine. SwiftData is an API surface over the Core Data store, which is itself SQLite with a metadata overlay — Apple’s own coexistence sample reads and writes the same store file from both a Core Data host app and a SwiftData widget extension. SwiftData and Core Data share the on-disk format; SQLiteData talks to SQLite directly through GRDB and can be pointed at the same bytes from a different schema contract.

So the “which persistence framework” decision is really a decision about which API abstraction you want to live under, and both halves of that sentence matter:

  • The shared storage floor means you are not locked out of an escape by the file format — an escape is gated by your code, not by the store.
  • The API layer is where the differences that justify the verdicts live, and they are differences of control, not of storage capability.

The Ergonomics Win Is Real

SwiftData removed the two things that made Core Data expensive for new features: the .xcdatamodeld editor and the NSManagedObject subclassing ceremony. @Model derives schema from Swift declarations, @Query binds results to SwiftUI views with observation for free, #Predicate replaces NSPredicate format strings with macros the compiler checks, and @ModelActor gives background work an owning actor instead of perform-block discipline.

// Swift 6. The version-control-friendly migration ladder. We did not
// create a VersionedSchema for the first two additive changes and paid
// for it when V3 needed a custom stage: there was no clean chain to hang
// it on. Ship SchemaV1 even when the store is trivial.
enum TripSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }

    @Model
    final class Trip {
        var name: String = ""
        var destination: String = ""
    }
}

Teams that adopt SwiftData for greenfield CRUD report that the reactive layer — a category of code that previously lived under NSFetchedResultsController glue — effectively disappears. That is the honest part of the marketing. It is also where SwiftData’s contribution ends: everything below the API surface is inherited, including the ceilings.

Migration Control: Where the Default Assumption Breaks

The SwiftData migration story is code-first and more compact than Core Data’s mapping models — VersionedSchema snapshots, MigrationStage hops, SchemaMigrationPlan ordering, with willMigrate/didMigrate closures carrying data transforms. Lightweight stages handle additive changes; custom stages carry the rest. That is the good version.

The production version has sharp edges:

  • The checksum trap. Declaring a VersionedSchema V2 for a change SwiftData would migrate automatically (a property with an inline default) produces “Duplicate version checksums across stages detected” — a device-only launch crash that builds clean. The rule of thumb that emerged from field reports: lightweight for schema-shape changes, custom only for data-shape changes, and never declare a version ladder for both at once.
  • Migration failure is launch failure. The ModelContainer initializer runs the plan at container creation. A custom stage that throws, a checksum collision, or an un-saved didMigrate means the app does not open. Core Data’s failures surface at store load too, but after twenty years the failure modes are documented and the escape routes are known.
  • Custom stages run on a fetched-context basis, not a transactional script. A transform over 800,000 rows has no incremental checkpoint; the whole thing either lands or the user is stuck behind an error screen. This is exactly the class of problem where the iOS 27.1 checkpoint discipline applies — treat every migration as a gate that must be rehearsed on a real old-store copy before release — and it is the discipline most SwiftData-first teams skip because the first two migrations were free.

Core Data’s counterweight is ceremony, but it is settled ceremony: NSMigrationManager, .xcmappingmodel files, NSEntityMigrationPolicy subclasses, and inference options have known operational playbooks. For a schema that churns monthly, “more ceremony but a documented rainy-day path” is a feature.

The Expressible Ceiling

Core Data predicate reach is the part of the old stack that is still ahead of everything Swift-native. NSPredicate accepts a string and therefore accepts anything NSExpression understands: SUBQUERY, @sum/@count/@avg collection operators, keypath.collection[SIZE], function expressions. It is untypechecked and runtime-crashable, but the set of queries you can write is the full Core Data expression grammar.

SwiftData’s #Predicate macro is checked, and smaller:

  • Computed properties and custom functions are not translatable — you denormalize a differently-shaped stored property to express them.
  • Raw-representable enums and Boolean ordering were unsupported for sorting; field reports forced Int columns where an enum was the correct type. iOS 27 narrowed the enum-filtering gap and added Predicate(all:)/Predicate(any:) compound predicates along with .codable blobs — a meaningful 2026 improvement, but the class of limitation persists: aggregates, grouped queries, and SUBQUERY are still out of reach, which is precisely what the hardest-query sprint demonstrated.
  • Optional ordering is opaque — the “nil values last” idiom that SQL does as ORDER BY x ASC NULLS LAST has no SwiftData counterpart.
  • A predicate can compile and then fail at runtime when SwiftData cannot translate the expression tree into the store’s SQL — the error arrives during fetch, not in the editor.

The practical verdict is not “SwiftData has no predicates.” It is that SwiftData’s predicate is scoped to the CRUD shape, and CRUD is not the query that breaks your app.

What the Benchmarks Do and Don’t Tell You

Reported production numbers, synthesized from teams that migrated both directions, are consistent in direction if not in magnitude:

WorkloadReported SwiftData effect vs Core Data
Insert batch, ~100k rows~10–15% slower
Compound predicate on ~1.5M rows~25–40% slower, after it compiles
Memory for a loaded context, 10k entities~40% higher (observation registration on every property access)
Cold launch with store load~5–15% slower

Context matters more than the spread. @Model routes every property access through the observation system — excellent for SwiftUI reactivity, a real tax in near-rectangular loops over thousands of rows. For a settings screen or a two-hundred-row note list, the differences are invisible and the perceived performance under SwiftData’s @Query reactivity is often better. For 1M+ rows, compound predicates, and background bulk processing, the direction of the gap is consistent across every source that measured it.

Two structural absences compound the large-dataset story: SwiftData has no NSBatchUpdateRequest/NSBatchDeleteRequest equivalent (per-object saves cannot match one batched SQL statement), and no NSPersistentHistoryToken analogue — teams that need change tracking for sync hand-roll it in 200+ lines or abandon it. Neither matters for CRUD-of-a-model-of-a-model apps. Both are load-bearing for offline-first and analytical ones.

SQLiteData: The SQLite Surface Without the Ceremony

SQLiteData (pointfreeco, built on GRDB) is the third candidate because it is the answer to a specific question: what if I want the store’s full power without leaving Swift types behind? It gives you @FetchAll/@FetchOne property wrappers that mirror @Query — but usable from UIKit view controllers, @Observable classes, and widget code, not just SwiftUI views — backed by a @Dependency(\.defaultDatabase) database writer:

// Swift 6. The escape isn't all-or-nothing: a GRDB-backed writer can
// sit behind a repository boundary while SwiftData keeps owning the
// SwiftUI-facing stores. We started this way after the aggregate sprint
// and kept it — one writer, two surfaces, one store.
@Dependency(\.defaultDatabase) private var database

func topLists(since: Date) throws -> [ListRank] {
    try Row.fetchAll(database, sql: """
        SELECT listID, CAST(SUM(isDone) AS REAL) / COUNT(*) AS ratio
        FROM task WHERE dueAt >= :since
        GROUP BY listID ORDER BY ratio DESC LIMIT 3
        """, arguments: ["since": since])
    .map { ListRank(id: $0["listID"], ratio: $0["ratio"]) }
}

When even Core Data’s expression grammar is not enough — multi-join reporting, common table expressions, window functions, custom sync logic — this is the layer that still has the store’s entire vocabulary. The costs are honest: you own migrations in SQL (ALTER TABLE and PRAGMA user_version are now your contract), you author versioning that the framework no longer scaffolds, and your schema lives in code you write rather than code a macro derives. Teams that already think of the database as a database rarely regret the swap; teams that wanted the framework to think for them find the responsibility heavy.

A Decision Rule, Not a Preference

The verdict collapses into two axes, and both are objective:

  • Query complexity — measured by your hardest query, not your demo query.
  • Schema churn — the rate of non-additive changes: type changes, model splits, transformations, uniqueness backfills.
Query complexity ↓ / churn →Low churnHigh churn
CRUD-shapedSwiftDataCore Data (settled migration tooling)
Analytical / aggregateSQLiteData or Core DataSQLiteData (you own migrations already)

If SwiftData expresses every hard query cleanly, take it — it is the best CRUD experience on the platform and the store file is escape-compatible. The moment one query forces an in-memory fallback, that fallback is your ceiling, and the ceiling compounds every release. Apps with frequent SwiftData migration work or analytical screens are the ones that outgrow it, and the growth is measurable in staff-months, not sprints.

The Escape Tax

Adopting SwiftData is cheap; escaping it is not, and the asymmetry is the hidden cost the verdict exists to make explicit. By the time the ceiling bites, @Model classes are referenced from every @Query property in the view layer, the observation pipeline is wired to store updates, the schema has written itself into the store, and migration history has accumulated against the version ladder. Escaping to Core Data or SQLiteData means re-skipping the model layer, the reactive layer, and the migration layer together — an order of magnitude more work than the original adoption, which is why most teams stop at “hybrid, new features only” and never actually un-mount SwiftData. Hybrid is a reasonable resting state.

The countermeasure is free but feels like ceremony: put a repository boundary between the persistence API and the features, even on day one. The decision stays reversible while the config is one file; it stops being reversible the day @Query shows up in screens.

Prototype the Hardest Query

The one actionable rule, and the way to stop the flamewars:

  1. List the queries that will actually run — reporting, user search, enum-based filters, sorts that must place nil-last, relationship walks, subqueries.
  2. Write the five hardest first in SwiftData, Core Data, and SQLiteData, on production-shaped volume (generated 1M+ rows, not 200 fixtures).
  3. Measure with Instruments, not wall-clock feel — the SQLite3 SQL planning pane, allocations, page reads.
  4. Simulate the migration path — seed a V1 store, upgrade through V2→V3, including a transform of the full row count, and time it.
  5. Then commit. The stack that survived the sprint is the stack that survives the release cycle.

If the query you skipped was the one the analytics screen lives on, the prototype finds it in a day instead of a quarter.

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap