Once a dataset grows, SwiftData lists start to stutter. This has been the performance complaint I’ve heard most often these past few years.
A developer’s first instinct is usually to blame SwiftUI’s List. It does bear some of the responsibility, but it isn’t the main problem. Once you actually spend time on it, you find that the slowness isn’t only the long initial load — the enormous memory footprint drags performance down just as much. This article looks at why these problems show up in SwiftData, and where to start fixing them.
A Very Common Way to Write It
Plenty of apps end up written like this, and the official samples aren’t far off:
@Model
final class Note {
var title: String
var createdAt: Date
var isPinned: Bool
var body: String // only read in the detail view
var attachment: Data // only read in the detail view
}
struct NoteListView: View {
@Query(sort: \Note.createdAt)
private var notes: [Note]
var body: some View {
List(notes) { note in
NoteRow(title: note.title)
}
}
}
The list cell usually needs nothing more than the title, the date, and the pinned state; the body and the attachment aren’t touched until the detail view. But by everyday Swift habits, putting them all on one type is the path of least resistance. With a small dataset, there’s nothing wrong with this. Once you’re into the thousands or tens of thousands, entering this view stutters noticeably.
The Missing Faulting Mechanism
Before List or LazyVStack can draw the visible rows, they first have to establish identity for the entire dataset. That step applies to all of the data, not just the dozen or so rows on screen. LazyVStack also has to estimate the height of the whole content; List is more restrained, but the moment you apply .id(...) to a row, lazy loading falls apart.
These are SwiftUI’s own problems. For a fuller treatment, see Demystifying SwiftUI List Responsiveness: Best Practices for Large Datasets, List or LazyVStack, and Tips and Considerations for Using Lazy Containers in SwiftUI.
Still, pinning it all on List isn’t fair. Given the same SwiftUI machinery, Core Data facing a dataset of the same shape and size still enters the list far faster than SwiftData does. That’s because we typically hand ForEach the objectID as the identity, and reading that identity doesn’t cause the data to be populated — the vast majority of objects stay as faults.
But SwiftData provides no faulting mechanism. There is no isFault in the public API, and no returnsObjectsAsFaults. Both fetch and @Query return [T], where T must be a PersistentModel. The moment the query finishes, the stored properties on the main model are read into memory for every matching record, turning them into fully accessible model instances.
I ran a comparison on a perfectly ordinary set of SwiftData records: a title of roughly 48 bytes, a body of 8 KB, an attachment of 32 KB, 3,000 rows. Each run used a fresh ModelContext with unsaved in-memory changes cleared. Just retrieving the array without reading a single property took about 0.30 seconds and grew memory by roughly 176 MB. Reading only persistentModelID took about 0.33 seconds. Reading the body and the attachment as well took about 0.35 seconds. All three are essentially the same.
The list only needs the title, yet the body and the attachment have already come along with it. Someone will point out that Core Data is still underneath, and that there may well be placeholders internally.
But that isn’t the point. Developers can’t see that state, and SwiftData offers no switch to defer population until the moment a cell actually reads the title. From the outside, the behavior is simply this: the fetch completes and the loading is complete with it. There is no faulting.
I wrote before WWDC 2024 that SwiftData does not support lazy loading of data. As of today (iOS / macOS 27), that hasn’t changed.
The Options That Look Useful
Apple provides propertiesToFetch on SwiftData’s FetchDescriptor. Per the documentation, it should fetch only the specified properties and issue a follow-up read when you access a field that wasn’t fetched. Core Data has a corresponding API, and there it’s extremely effective.
On the same dataset, setting Core Data’s propertiesToFetch to title brings 3,000 rows down to about 0.009 seconds — better than an order of magnitude faster than a full fetch.
Used on SwiftData, though, it does nothing at all.
This isn’t only because SwiftData still has no dictionary result type. Even when the framework reads propertiesToFetch, it populates every property anyway.
var descriptor = FetchDescriptor<Note>(
sortBy: [SortDescriptor(\.createdAt)]
)
descriptor.propertiesToFetch = [\.title]
let notes = try context.fetch(descriptor)
Turn on -com.apple.CoreData.SQLDebug 1 and you can watch it happen: even with propertiesToFetch set, the generated SQL is still a SELECT that pulls every column.
I’ve always considered this a long-standing bug in SwiftData. But given how long it has sat there, I suspect there’s an awkward technical reason behind it — that a general-purpose DataStoreSnapshot, say, can’t accommodate a special case like faulting.
For list scenarios that have to track data changes, SwiftData’s other options each fall short as well:
ResultsObservermerely moves@Queryout of the view; the results are still complete models.fetchIdentifiersis fast, but@Querydoesn’t support it.- Pagination reduces the row count, but can’t respond dynamically to data changes.
@Attribute(.externalStorage)only changes where a large file is kept; it doesn’t affect the population logic.
These interfaces aren’t useless. They just all take effect after the model’s width has already been fixed. If the main model is wide and heavy, no amount of later tuning will save it.
Oversight, or Design?
Given that Core Data is what sits underneath, why did SwiftData drop an automatic optimization as important as faulting? I asked myself that question for a long time. At first I preferred to read it as an incomplete first release, and waited for Apple to turn the switch back on. Years have passed, and it hasn’t come back in any form a developer can use. I’ve gradually come to think this may not be an omission at all, but a statement about what the framework wants to be.
Apple has placed SwiftData firmly on the “one less thing to think about” side: one @Model, one @Query, and it works with SwiftUI out of the box. What makes Core Data’s techniques powerful is precisely that they require you to understand the object graph, caching, result types, and partial population. Hand those back to developers and the learning curve returns. On performance, the bet is more on devices getting faster — CPU, memory, and the now-ubiquitous solid-state storage. For apps without much data, loading the entire row on hardware like that usually gets by fine.
SwiftData is built for today’s mobile devices and solid-state storage. The Core Data capabilities that grew out of older hardware constraints may not be invited back.
One important principle has quietly changed: Core Data is an object graph management framework, in which persistence exists to serve the object graph; SwiftData is a persistence wrapper aimed at SwiftUI, designed to lower the barrier to entry.
That isn’t to say the framework has made no performance improvements. Predicates keep gaining expressive power, so more filtering can stay inside SQLite instead of pulling everything into memory and calling filter. HistoryObserver improves when you act: syncing, incremental updates, and deciding whether a refresh is needed all get much lighter.
For how predicates translate into SQL and what has been added over the years, see Swift Predicate: Usage, Composition, and Considerations and How to Dynamically Construct Complex Predicates for SwiftData.
None of these updates change the underlying principle: objects are complete from the outside, query results are models rather than “just these few columns,” and complicated details stay hidden. Reading fewer columns, working with IDs alone, letting a list establish identity over fault shells — none of that fits the principle.
I can accept SwiftData’s design principle entirely. What’s genuinely unfortunate is that the official documentation and samples almost never present “list models should be thinner” as something to do from the outset. In the demos, Trip and Note keep every field on a single type. That’s friendly for teaching, and it has also led plenty of shipped apps to build their stores in the same shape. As datasets swell — and with CloudKit constraining how migrations can be done — solving the problem once a real performance bottleneck appears becomes extremely difficult.
A capability can be left out on principle. But when the demonstration is missing, the cost lands on the people whose first-version model is already on users’ devices.
Splitting the Data at the Modeling Stage
On relationship loading, SwiftData and Core Data still agree: related objects are lazy by default. The laziness that vanished from properties survives on relationships. And that is exactly the key to solving SwiftData’s list performance problem.
In Core Data, splitting was mostly aimed at the obviously large things: original images, very long bodies. The list entity could still hold dates, states, and summaries, because when you read the title the remaining properties stay faulted.
SwiftData has no such protection. One rarely-used string on the main model, one settings blob, one Codable — the list loads them all. So splitting the model is no longer just about large fields; it needs a stricter standard, cutting by access frequency. Compared with the past, models end up far more granular, and the design phase carries a much heavier load:
@Model
final class Note {
var title: String
var createdAt: Date
var isPinned: Bool
var preview: String? // summary for the list, deliberately duplicated
var body: NoteBody?
var attachment: NoteAttachment?
}
@Model
final class NoteBody {
var text: String
var note: Note?
}
@Model
final class NoteAttachment {
var data: Data // thumbnail
var original: AttachmentOriginal?
var note: Note?
}
@Model
final class AttachmentOriginal {
@Attribute(.externalStorage)
var data: Data // full-size image
var attachment: NoteAttachment?
}
Same 3,000 rows as before. With the model split, the cell loads only Note: about 0.029 seconds, and memory grows by roughly 0.9 MB. Against the fat model, that’s about 10× faster, with memory dropping from around 176 MB to under 1 MB.
Splitting entities is something we should have been doing back in the Core Data days. But in Core Data, even when we split a model, we’d typically keep the thumbnail and the original image on the same type, because propertiesToFetch could fetch them on demand. In SwiftData that granularity is no longer enough. If those two don’t necessarily appear together, cutting further is the only improvement available today.
A common worry: if the cell needs data from a relationship in addition to the main model, won’t that be slower?
On a mechanical disk, that kind of row-by-row read gets amplified. Today’s devices are SSDs, and a relationship amounts to another very fast primary-key read. What a list should really fear is one query loading every wide field into thousands of objects — not the user brushing past a few dozen detail records while scrolling.
If a cell consistently needs some short summary, storing a duplicate copy of it on the list entity is usually better than reaching through the relationship every time. What to avoid is putting things you only need after opening the detail view into the table @Query reads directly.
When splitting, you still have to respect SwiftData’s own constraints on relationships: explicit inverses, optionality under CloudKit, and not repeatedly calling
appendon a to-many array inside a loop. See Relationships in SwiftData: Changes and Considerations.
Would Fetching Only IDs Work?
If your SwiftData app has already shipped and has run into obvious performance problems, is there a stopgap?
Apple doesn’t offer a Query whose results are IDs and that still tracks data changes. But by combining fetchIdentifiers with HistoryObserver (iOS 27+), you can build an ID list that does track changes.
Swapping ForEach’s data for [id] and reloading the data inside each cell by ID isn’t a bad expedient.
That said, compared with loading a thin model from the start, the cell’s initial structure gets heavier. More importantly, [id] carries nothing you can use to estimate row height. The data isn’t read until the cell appears, one row at a time, and during that process the row height jumps from its placeholder value to its actual value — the layout List / LazyVStack had already computed gets revised, and scrolling can jitter or jump. Giving the cell a reference height is one improvement worth considering.
To learn how to fetch data by ID, read NSManagedObjectID and PersistentIdentifier.
This is a last resort, not an alternative.
The SwiftData Paradox
What makes SwiftData appealing is exactly that it saves you a layer: one class, one query, and the list runs. Split things finer and that simplicity is taken away.
- Inserting a note means creating the summary and the detail at the same time
- With cloud sync on, every relationship also has to be optional and carry an inverse
- It’s hard for a newcomer to get from “declare a Note” to the idea that title and body, or original and thumbnail, shouldn’t live on the same type.
Aggregation gets a little awkward after splitting, too. What used to fit in one layer turns into deeper relationships.
Which creates a paradox: SwiftData is meant to lower the barrier to entry, but if you don’t truly understand its limits, the simplicity it saved you gets erased by performance problems — and those are harder to clean up.