DEV Community

ArshTechPro
ArshTechPro

Posted on

WWDC 2026 - What's New in SwiftData: Sectioned Queries, Codable Attributes, and Observers

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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 a sections property.
  • Each section has an id (the value from your sectionBy key 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
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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 Codable implementation 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? { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

How it fits together:

  • You create a ResultsObserver for 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. Read resultsObserver.results inside 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 writes ModelResultsObserver<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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 eventCounter inside 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 the ObservationTracking.Token for 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 ResultsObserver when 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 HistoryObserver when 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 @Query with sectionBy:.
  • @Attribute(.codable) is an explicit escape hatch for persisting types you don't own, at the cost of querying, sorting, and migration awareness.
  • ResultsObserver brings @Query-style fetching and observation to non-SwiftUI code.
  • HistoryObserver gives 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)