Highlight
SwiftData adds three capabilities:
@Querycan group by property withsectionBy,@Attribute(.codable)can persist third-party framework types, andModelResultsObserverplusHistoryObserverlet non-SwiftUI code observe data changes.
Core Content
Grouped Lists No Longer Need Hand-Written Dictionaries
Anyone who has built a grouped list has gone through this flow: fetch all data from SwiftData, manually group it into a Dictionary by some property, then convert the dictionary into Section values inside a List. The code is not short, and every data change forces you to recompute the grouping.
At 00:36, Apple adds a sectionBy parameter to @Query. Pass in a KeyPath, and SwiftData groups the results at the query layer. The view only needs two ForEach loops: the outer loop iterates over sections, and the inner loop iterates over the data inside each section.
Third-Party Types Finally Get an Escape Hatch
Want to store an MKMapItem.Identifier in your model? Previously SwiftData would throw a fatal error: “Class property within Persisted Struct/Enum is not supported.” Because MKMapItem.Identifier is a MapKit class, you cannot add @Model to its source, and SwiftData cannot inspect it.
At 04:52, the new @Attribute(.codable) lets SwiftData delegate serialization to the type itself. As long as the type conforms to Codable, SwiftData stores its encoded result instead of trying to generate a schema.
There is a cost: Codable properties are opaque to SwiftData. They cannot be used in Predicate filters or sorting. Structural changes to the type also do not trigger migration. Apple explicitly calls this an “escape hatch” for third-party types you do not control. For models you own, you should still use @Model or value types.
Data Observation Leaves SwiftUI
@Query can only be used in SwiftUI views. If your app has an independent state object that needs to fetch data from SwiftData and respond to changes, you previously needed a workaround: hide an invisible SwiftUI view, or manually listen through NotificationCenter.
At 07:21, ModelResultsObserver extracts the capability behind @Query. It supports filtering, sorting, and grouping. Combined with withContinuousObservation from the Swift Observation framework, any Swift code can receive notifications when data changes.
At 09:30, HistoryObserver goes further. Every SwiftData save records a history transaction, including what changed, who changed it, and a unique token. HistoryObserver observes these transactions and is useful for server sync or responding to app extension changes. You can filter by author to avoid uploading changes that were just pulled down from the server.
Details
Sectioned Fetching
At 00:01, the sectionBy parameter of @Query accepts a KeyPath and groups query results by that property’s value. The wrapped value is still [Trip], but you can access the underlying Query object with the underscore-prefixed property and retrieve its sections collection.
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)
}
}
}
}
}
}
Key points:
sectionBy: \.destinationtells SwiftData to group by thedestinationproperty_tripsis the underlying property-wrapper instance; use it to accesssections- Each
section’sidis the grouping-key value, used here as theSectionheader - The inner
ForEachiterates oversectionitself, which is aRandomAccessCollection
Codable Attributes
At 04:59, @Attribute(.codable) hands persistence to the type’s Codable implementation. SwiftData no longer generates a schema for this kind of property; it stores the encoded data instead.
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?
}
Key points:
MKMapItem.Identifieris a MapKit class, and SwiftData cannot inspect it automatically- It conforms to
Codable, so it can be marked with the.codableattribute - SwiftData stores encoded binary or JSON data, and the internal structure is opaque to it
- It cannot be used in
#Predicatefilters orSortDescriptorsorting - Structural changes to the type do not trigger automatic migration, so your encoding logic must remain backward compatible
ModelResultsObserver
At 08:20, ModelResultsObserver provides @Query-equivalent capabilities in non-SwiftUI code: query data and continuously observe changes.
@Observable @MainActor final class MapCameraController {
private let observer: ModelResultsObserver<Trip>
var bounds: MapCameraBounds?
private var token: ObservationTracking.Token?
init(modelContext: ModelContext) throws {
observer = try ModelResultsObserver<Trip>(modelContext: modelContext)
token = withContinuousObservation(options: [.didSet]) { [weak self] _ in
self?.bounds = self?.calculateBounds(trips: observer.results)
}
}
private func calculateBounds(trips: [Trip]) -> MapCameraBounds? { /* ... */ }
}
Key points:
ModelResultsObserverneeds aModelContextat initialization- It supports
predicate,sort, andsectionBy, with an API consistent with@Query - The
tokenreturned bywithContinuousObservationmust be strongly referenced, or observation stops immediately - The closure needs to read
observer.results; the Observation framework tracks dependencies through property access [weak self]avoids retain cycles
HistoryObserver
At 08:21, HistoryObserver observes SwiftData persistent history transactions, which is useful for server sync scenarios.
@SyncActor final class ServerSync {
private let observer: HistoryObserver
private var token: ObservationTracking.Token?
func start() throws {
observer = try HistoryObserver(authors: ["App"], modelContainer: modelContainer)
token = withContinuousObservation(options: .didSet) { [weak self] _ in
_ = self?.observer.eventCounter
self?.processChanges()
}
}
private func processChanges() {
// Use ModelContext.fetchHistory to get transactions and upload them to the server.
}
}
Key points:
- The
authorsparameter filters transaction sources, preventing server-synced changes from being uploaded again eventCounteris the only observable property and increments when a new transaction arrives- The closure must access
eventCounterso the Observation framework can establish the dependency - During actual processing, call
ModelContext.fetchHistoryto get transaction details - Each transaction has a unique token, which can be used to track processed changes
Key Takeaways
1. Fully separate the data layer from the view layer
In MVVM architectures, ViewModels previously had to work around SwiftData if they wanted to observe changes. Now ModelResultsObserver lets a ViewModel own its own data observation, while the View only renders. What to do: refactor existing projects and move @Query out of the ViewModel. Why it is worth doing: the coupling between View and data is reduced, and tests become easier. How to start: replace @Query with ModelResultsObserver, then use withContinuousObservation to receive changes and notify the View through @Observable.
2. Build a real-time collaborative notes app
HistoryObserver’s authors filtering and transaction tokens are naturally suited to multi-device sync. What to do: mark locally edited transactions as "App", mark server-pushed transactions as "Server", and have HistoryObserver observe only "App" changes for upload. Why it is worth doing: you do not need to implement change tracking yourself; SwiftData already records it. How to start: pass authors: ["App"] when initializing HistoryObserver, then call fetchHistory inside processChanges to get the transaction list.
3. Use SwiftData for progress storage in SpriteKit or SceneKit games
Games often do not use SwiftUI, so SwiftData’s real-time observation capability was previously hard to use. What to do: use ModelResultsObserver to observe player data changes and dynamically adjust game state. Why it is worth doing: the game architecture does not need to force in SwiftUI just to use SwiftData. How to start: hold a ModelResultsObserver<PlayerData> inside the game’s GameManager and update in-game variables when data changes.
4. Build schedule or todo lists grouped by date
sectionBy makes grouping by date trivial. What to do: show events in a schedule app grouped by day. Why it is worth doing: what used to require manual Calendar grouping is now a single sectionBy line. How to start: @Query(sort: \.startDate, sectionBy: \.dateString), where dateString is a computed property that returns a formatted date string.
5. Store complex configuration objects directly in your model
Some third-party SDK configuration types conform to Codable but have complex structure. What to do: store them directly in SwiftData with @Attribute(.codable), without manually splitting them into primitive fields. Why it is worth doing: it removes conversion code and keeps the model cleaner. How to start: confirm the type conforms to Codable, then mark it with @Attribute(.codable).
Related Sessions
- What’s new in SwiftUI — New SwiftUI features and deep integration between SwiftData and
@Query - What’s new in SwiftData persistence — Low-level improvements to SwiftData persistence
- Track Model Changes with SwiftData History — Details of the persistent history transaction mechanism (WWDC 2024)
- Add persistence with SwiftData — Introductory SwiftData code-along
- What’s new in Swift — New Swift language features, including improvements to the Observation framework
Comments
GitHub Issues · utterances