SwiftData Performance Is a Storage Problem
The WWDC 2026 SwiftData group lab was staffed by the people who own the layers underneath the framework, including the engineer who maintains SQLite across Apple’s platforms and the manager of Core Data and SwiftData. The throughline of their answers was a useful correction to how most developers reach for performance: once SwiftData is in your app, the expensive thing is I/O, not your Swift code, and the wins come from reading less and understanding the storage engine rather than from adding concurrency. Most of what follows is grounded in Apple’s documentation and SQLite’s; where a claim is the lab’s engineering reasoning rather than a documented fact, it is marked as such.
The WWDC 2026 SwiftData Group Lab.
TL;DR
- SwiftData’s SQLite store uses write-ahead logging (WAL) by default, which means multiple readers run concurrently with a single writer. It is not a reader/writer lock, a distinction the lab said developers routinely get wrong.23
- Read without materializing objects:
fetchCount(_:)returns a match count andfetchIdentifiers(_:)returns[PersistentIdentifier], both without hydrating models. Pair them with history observation to decide whether a refresh is even needed.45 @Modelobjects are notSendableand should not be forced to be. To cross an actor boundary, pass thePersistentIdentifier(which isSendable) plus any extracted values, then re-fetch on the destination context.6- SwiftData has no equivalent to Core Data’s SQL-pushed aggregate queries (sum, average, min, max). The escape hatch is coexistence: run a Core Data stack against the same store file and let it compute the aggregate.8
- The lab’s performance message: profile to find out why SwiftUI refetched before assuming the database is slow, because view over-invalidation reads like an I/O problem when it is not.110
WAL: concurrent readers, one writer, not a lock
The single most useful correction from the lab concerns concurrency at the storage layer. SwiftData is built on the Core Data SQLite store, and that store has defaulted to write-ahead logging since iOS 7.3 Under WAL, as SQLite’s documentation puts it, “WAL provides more concurrency as readers do not block writers and a writer does not block readers. Reading and writing can proceed concurrently.”2 There is still exactly one writer at a time, but the mental model of a mutex that serializes all database access is wrong: your reads do not have to wait behind the write.
The lab’s framing, paraphrased from the recording, was that people treat the single-writer rule as a reader/writer lock and architect around a constraint that does not exist.1 The accurate model is the WAL one: design for many concurrent reads and a serialized write, not for global exclusion.
Read less: count and identify without hydrating
If I/O is the cost, the highest-leverage move is to stop loading objects you do not need. SwiftData gives two primitives for this, both verified in the API:
fetchCount(_:) on ModelContext takes a FetchDescriptor and returns the number of matching models as an Int without instantiating any of them.4 When you need a count for a badge or a section header, this is strictly cheaper than fetching and calling .count.
fetchIdentifiers(_:) returns [PersistentIdentifier] for a descriptor, again without materializing the models, and a fetchIdentifiers(_:batchSize:) overload batches the work.5 The lab’s suggested use, paraphrased, pairs this with history observation: when a change comes in, fetch the affected identifiers and compare against what your view actually displays before deciding whether to reload anything.1 The history and observation APIs themselves are covered in SwiftData’s iOS 27 observation and history; fetchIdentifiers is the lightweight read that makes them efficient. The observation type to reach for outside SwiftUI is ResultsObserver, the Swift Observation-based observer introduced for the 2027 releases, which supports the same primitives as @Query including key-path sectioning through sectionBy:.9
The Sendable boundary is real, and the model graph does not cross it
SwiftData models are reference types wired into a graph inside their context, and they are not Sendable. The lab was blunt that you cannot sanely force them to be, because the graph is not thread-safe and partially hydrating it on another actor leads to trouble.1 The supported pattern uses PersistentIdentifier, which is Sendable, Hashable, and Codable, as the identity you move across boundaries.6 Extract the values you need into a struct, attach the PersistentIdentifier, hand that to the other actor, and re-fetch the model on the destination context if you need the live object.
One precision worth keeping: Apple notes that a decoded PersistentIdentifier and one created by the default store are not always considered equivalent, so treat the identifier as a stable cross-context handle rather than assuming a decoded copy equals a live one.6
The same identity-not-graph discipline shows up across processes. When you move a store into an app group to share it with a widget or extension, the default configuration copies the existing store into the app group container for you; with a custom store URL you manage the location yourself.7 Either way, the processes coordinate through the store and its identifiers, not by passing live objects between them.
The aggregate gap, and the Core Data escape hatch
A real limitation the lab named: SwiftData has no equivalent to Core Data’s NSExpression-based aggregate queries, the ones that push sum, average, min, and max down into SQLite so the database computes them without loading rows.8 In SwiftData you would fetch the rows and reduce in memory, which defeats the purpose on a large table. For min or max you can fetch with a sort descriptor and a fetch limit of one; for genuine aggregates, the lab pointed at coexistence.
Coexistence, as Apple framed it at WWDC 2023, is “two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store.”8 Both stacks point at the same store URL, and because SwiftData enables persistent history tracking automatically, the Core Data side must enable NSPersistentHistoryTrackingKey too or the store opens read-only.8 With that in place, you can run the SQL-pushed aggregate through Core Data against the very file SwiftData owns. It is more machinery than most apps need, but it is the documented path when you genuinely need database-side aggregation.
Profile the invalidation, not only the database
The lab’s most practical performance guidance, paraphrased, was that a SwiftData app’s apparent I/O cost is often a SwiftUI invalidation problem in disguise: a view that invalidates too often refetches, and a profiler shows the refetch as database time when the real fault is that the view should not have refreshed at all.1 The fix is the same view-isolation discipline that helps any SwiftUI performance issue, covered in SwiftUI performance and interop: break large views into smaller ones with narrower dependencies, and pass already-fetched models down so the query does not rerun.
The tooling supports this read. Instruments ships a SwiftUI template that bundles the SwiftUI instrument alongside the Hangs and Hitches instruments, a File Activity template whose Reads and Writes instrument shows real disk traffic (device only, not the simulator), and the Core Data template with its Data Persistence instrument reporting faults, fetches, and saves.10 Running the SwiftUI and persistence views together tells you whether a refetch was a genuine read or a redundant one triggered by over-invalidation.
A caution the lab raised about benchmarking, paraphrased: there are caches all the way down, the SQLite page cache, the OS file cache, and the storage controller, so a “fast” run may be a cache hit rather than a real improvement. Measure against a realistically large dataset and use the File Activity instrument to confirm that actual I/O happened.1
On adding concurrency
The lab’s strongest opinion, and the part to treat as engineering reasoning rather than documented fact, was a caution against reaching for concurrency as a performance fix. The engineers described SwiftData’s connection pooling as deliberately bounded and argued that beyond a small number of concurrent operations you hit the storage hardware’s ceiling, so more contexts buy diminishing returns at the cost of more memory and more I/O.1 Apple does not document a specific concurrency limit, so do not take a hard number from anyone, including this post. The defensible takeaway is the directional one: on a flash-storage device, piling on concurrent writers is not a reliable way to go faster, and the WAL model already gives you concurrent reads for free.
What to take from it
The lab reframes SwiftData performance around the storage engine. The verified levers are concrete: lean on WAL’s concurrent reads instead of fearing a lock, use fetchCount and fetchIdentifiers to avoid hydrating objects, move PersistentIdentifier across actors rather than the model graph, and reach for Core Data coexistence when you need a real aggregate. The profiling discipline is to confirm an I/O cost is real before optimizing the database, because the culprit is often a view that refreshed when it should not have.
FAQ
Does SwiftData lock the database during writes?
Not in the reader/writer-lock sense. The store uses SQLite write-ahead logging, which allows multiple readers to run concurrently with a single writer; reads do not block the writer and the writer does not block reads.23 There is one writer at a time, but reads proceed alongside it.
How do I count or check records without loading them?
Use ModelContext.fetchCount(_:) for a match count and ModelContext.fetchIdentifiers(_:) for [PersistentIdentifier] values, neither of which materializes model objects.45 Combine fetchIdentifiers with history observation to decide whether a change actually affects what your view shows before reloading.
How do I pass a SwiftData object to another actor?
You do not pass the object. @Model types are not Sendable. Pass the PersistentIdentifier (which is Sendable) plus any extracted values, then re-fetch on the destination context.6 Avoid handing the live model graph across the boundary.
Can SwiftData do sum/average/min/max in the database?
No. SwiftData has no equivalent to Core Data’s NSExpression SQL-pushed aggregates.8 For min/max, fetch with a sort and a fetch limit of one; for true aggregates, run a Core Data stack against the same store file (coexistence), which requires matching the store URL and enabling persistent history tracking on the Core Data side.8
The SwiftData lane on this blog covers the schema and migration discipline in schema discipline and the migrations guide, and the iOS 27 observation and history APIs in observation and history. This post adds the performance and storage layer. The full series hub is the Apple Ecosystem Series.
References
-
Apple, WWDC 2026 session 8017, SwiftData Group Lab. Paraphrased from a locally transcribed recording; Apple publishes no official captions for the labs, so the wording here is a paraphrase, not a quotation, and exact phrasing is unverified. Source for the reader/writer-lock misconception framing, the
fetchIdentifiers-plus-history refresh-gating suggestion, the@Modelnon-Sendable transfer guidance, the view-invalidation-masquerading-as-I/O point, the “caches all the way down” benchmarking caution, and the connection-pool/concurrency-ceiling position (which is the lab’s engineering reasoning, not documented behavior; no specific concurrency number is asserted here because Apple does not document one). ↩↩↩↩↩↩↩ -
SQLite, Write-Ahead Logging. Source for the WAL concurrency model: “WAL provides more concurrency as readers do not block writers and a writer does not block readers,” with a single writer at a time. ↩↩↩
-
Apple, Technical Q&A QA1809: Setting the SQLite journaling mode for a Core Data store. Source for write-ahead logging being the default journaling mode for Core Data SQLite stores since iOS 7 and OS X Mavericks; SwiftData is built on the Core Data SQLite store. ↩↩↩
-
Apple,
ModelContext.fetchCount(_:). Signaturefunc fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel; returns the number of models matching the descriptor without instantiating them. ↩↩↩ -
Apple,
ModelContext.fetchIdentifiers(_:)andfetchIdentifiers(_:batchSize:). Returns[PersistentIdentifier]for a fetch descriptor without materializing the models, with a batched overload. ↩↩↩ -
Apple,
PersistentIdentifier. The aggregate identity of a SwiftData model; it isSendable,Hashable, andCodable, making it the type to move across actor boundaries. Apple notes a decodedPersistentIdentifierand one created by the default store are not always considered equivalent, so treat it as a stable cross-context handle. ↩↩↩↩ -
Apple, Adopting SwiftData for a Core Data app. Source for the app-group behavior: when an app evolves to use an app group container, SwiftData copies the existing store into the app group container under the default configuration; with a custom store URL you manage the location yourself. ↩
-
Apple, WWDC 2023 session 10189, Migrate to SwiftData, and
NSExpression. Source for coexistence (“two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”), the requirement that both use the same store URL and that the Core Data stack enableNSPersistentHistoryTrackingKeyor the store opens read-only, and for Core Data’sNSExpression-based SQL aggregates that SwiftData does not provide an equivalent to. ↩↩↩↩↩↩ -
Apple, WWDC 2026 session 274, What’s new in SwiftData. Source for
ResultsObserver, the Swift Observation-based observation type that supports the same primitives as@Queryincluding key-path sectioning viasectionBy:, shipping in the 2027 platform releases. ↩ -
Apple, WWDC 2025 session 306, Optimize SwiftUI performance with Instruments, and the Instruments File Activity and Core Data templates. Source for the SwiftUI Instruments template (bundling the SwiftUI instrument and the Hangs and Hitches instruments), the File Activity template’s Reads and Writes instrument (device only), and the Data Persistence instrument reporting faults, fetches, and saves. ↩↩
