WWDC 2026's "What's new in SwiftData" session is a tight, practical one. There's no sweeping redesign here, just four targeted additions that fill gaps developers have been working around for a while. If you've ever fought SwiftData to group a list, store a type you don't own, or react to data changes outside a SwiftUI view, this release is for you.
Here's everything that's new and how to use it.
1. Sectioned fetching with @Query
Grouping a list by some property used to mean fetching everything flat and then partitioning it yourself in the view. Now @Query does it for you.
You pass a key path to the new sectionBy: parameter. The key path starts at the model root and points to the property you want to group on:
struct TripListView: View {
@Query(sort: \Trip.startDate,
sectionBy: \.destination)
var trips: [Trip]
var body: some View {
List(selection: $selection) {
ForEach(_trips.sections) { section in
Section(section.id) {
ForEach(section) { trip in
TripListItem(trip: trip)
}
}
}
}
}
}
A few things to notice:
- The wrapped value (
trips) is still a plain[Trip], so existing code that reads it keeps working unchanged. - To reach the sections, you use the underscore-prefixed projected value
_trips, which exposes asectionsproperty. - Each section has an
id(the value from yoursectionBykey path, in this case the destination string) and is itself a collection of models you can iterate.
So adding sections is additive: keep your flat array where you had it, and reach for _trips.sections only where you actually want the grouping. Sorting still applies across the whole result set, which keeps section ordering predictable.
2. Storing custom types with @Attribute(.codable)
SwiftData builds a schema by inspecting your models at launch. That works automatically for its supported value types and for anything you mark @Model. It falls apart the moment you try to persist a class you don't control.
The session's example is MKMapItem.Identifier from MapKit. Add it to a model as-is and the app crashes at launch with:
Class property within Persisted Struct/Enum is not supported
The reason: SwiftData can't inspect a non-@Model class to generate a schema, and you can't go annotate a type that lives inside MapKit.
The fix in this release is a new attribute option. If the type conforms to Codable, mark the property @Attribute(.codable) and SwiftData stores the encoded representation instead of trying to model it:
import SwiftData
@Model
class Trip {
struct Location: Codable {
var latitude: Double
var longitude: Double
}
var name: String
var destination: String
var startDate: Date
var endDate: Date
var location: Location?
@Attribute(.codable)
var mapItemIdentifier: MKMapItem.Identifier?
}
This is genuinely useful, but treat it as an escape hatch, not a default. The trade-offs matter:
- Opaque to SwiftData. A codable attribute is stored as a serialized blob, so you can't use it in predicates to filter or in sort descriptors to sort.
-
No migration awareness. If the shape of the codable type changes (you add or remove a property), it won't trigger a migration. Your
Codableimplementation has to encode and decode in a forward- and backward-compatible way on its own.
The rule of thumb from the session: use .codable for types you don't own (framework or third-party types). For types you define, model them as @Model or use supported value types so you keep filtering, sorting, indexing, and migrations.
3. Observing query results anywhere with ResultsObserver
@Query is great, but it only lives inside SwiftUI views. It fetches when the view appears and keeps watching the store, re-rendering when results change. The problem: plenty of code isn't a SwiftUI view. A state object that derives values from your store, or an app with no SwiftUI at all (think a SceneKit game), had no clean equivalent.
ResultsObserver is that equivalent. It fetches from your store and observes for changes using Swift Observation, and it works anywhere. It supports the same primitives you already know from @Query: filtering, sorting, and the sectioning above.
The session uses it to drive a map camera that should always frame all your trips:
@Observable @MainActor
final class MapCameraController {
private let resultsObserver: ResultsObserver<Trip>
var bounds: MapCameraBounds?
private var token: ObservationTracking.Token?
init(modelContext: ModelContext) throws {
resultsObserver = try ResultsObserver<Trip>(modelContext: modelContext)
token = withContinuousObservation(options: [.didSet]) { [weak self] event in
guard let self else { return }
self.bounds = self.calculateBounds(trips: self.resultsObserver.results)
}
}
private func calculateBounds(trips: [Trip]) -> MapCameraBounds? { /* ... */ }
}
How it fits together:
- You create a
ResultsObserverfor the model. No predicate and no section key path here means "give me everything." -
withContinuousObservation(options: [.didSet])gives you a callback whenever the results change. ReadresultsObserver.resultsinside the closure to get the current set. - The call returns an
ObservationTracking.Token. That token defines the lifetime of the observation, so store it on the object to keep updates flowing for as long as the object lives. Drop the token and observation stops.
In the demo, deleting a trip fires the observation, recalculates the bounds, and the map re-frames. The mental model is "@Query, but for non-view code."
A naming note: the session and Apple's chapter summaries call this
ResultsObserver, while Apple's published sample snippet writesModelResultsObserver<Trip>. Check the final SwiftData headers in your SDK for the exact spelling when you adopt it.
4. Reacting to store changes with HistoryObserver
The second observer targets a different problem: syncing and reacting to changes rather than reading current results.
Quick refresher on SwiftData history. Every time your store is saved, SwiftData records a history transaction describing what changed, where the change came from (the author), and a token that uniquely identifies it. You fetch newer transactions with ModelContext.fetchHistory. (The WWDC 2024 session "Track model changes with SwiftData history" covers the foundations.)
HistoryObserver makes reacting to that history easy. It exposes a single observable property, eventCounter, which increments whenever new transactions land. You observe the counter, and when it ticks, you fetch and process the new history. You can also filter by model type and by transaction author, which is what makes server sync clean:
@SyncActor
final class ServerSync {
private var observer: HistoryObserver?
private var token: ObservationTracking.Token?
func start() throws {
let observer = try HistoryObserver(
authors: ["App"],
modelContainer: modelContainer
)
self.observer = observer
token = withContinuousObservation(options: .didSet) { [weak self] _ in
// Touch eventCounter so Swift Observation tracks it
_ = self?.observer?.eventCounter
self?.processChanges()
}
}
private func processChanges() {
// Use ModelContext.fetchHistory to fetch and upload changes
}
}
The important details:
- Filtering by
authors: ["App"]means you only see changes your app made. That's how you avoid replaying server-originated changes back to the server in an infinite loop. - You must actually read
eventCounterinside the observation closure. Swift Observation only tracks what you touch, so reading the property is what registers the dependency. - Same lifetime rule as
ResultsObserver: hold onto theObservationTracking.Tokenfor as long as you want to keep observing.
This is the tool for keeping a server, an app extension, or any external system in sync with what's happening in your store.
When to reach for which
A quick way to keep the two observers straight:
- Use
ResultsObserverwhen you need the current set of models outside a SwiftUI view and want to recompute when they change (state objects, non-SwiftUI apps, derived values). - Use
HistoryObserverwhen you care about the stream of changes themselves, especially for syncing with something outside your app.
Inside a SwiftUI view, @Query is still your first choice; the observers are for everywhere else.
Wrapping up
This year's SwiftData is about filling gaps rather than reinventing anything:
-
Sectioned fetching moves grouping into
@QuerywithsectionBy:. -
@Attribute(.codable)is an explicit escape hatch for persisting types you don't own, at the cost of querying, sorting, and migration awareness. -
ResultsObserverbrings@Query-style fetching and observation to non-SwiftUI code. -
HistoryObservergives you a simple, observable signal for reacting to persistent-history changes.
None of these are flashy, but each removes a real workaround. If you've been dropping into manual fetches or hand-rolled grouping, this release lets you delete some code.
Top comments (0)