SwiftData in iOS 27: Observation and History
SwiftData shipped in iOS 17 with two ways to watch your data: @Query inside a SwiftUI view, or manual notification plumbing on ModelContext for everything else. Neither covered the case that matters most for a synced app, which is knowing when another device changed the store. iOS 27 closes both gaps in one release. ResultsObserver makes change tracking a first-class object you can hold outside a view, and HistoryObserver watches SwiftData’s persistent history and increments an observable counter when new transactions land, so your sync code can pull just the latest changes. iOS 27 adds observation as a primitive, not a SwiftUI side effect.1
The framing matches the rest of this cluster: SwiftData was cheap to start and expensive to coordinate. Watching a fetch outside the view hierarchy meant rebuilding what @Query does by hand. Keeping a store in sync with an external server, or reacting to writes from an app extension, meant walking the persistent history and reconciling transactions yourself. iOS 27 gives both jobs a named type that conforms to Observable, so the same SwiftUI update machinery that already drives your views drives your sync layer too.
TL;DR / Key Takeaways
ResultsObserverobserves and tracks changes to a collection of persistent models in a model context, providing real-time updates when the underlying data changes. It isObservable, so a SwiftUI view updates automatically, and it works outside a view where@Querycannot reach.2HistoryObserverobserves SwiftData’s persistent history and exposes a single observable property,eventCounter, that increments when new transactions arrive. You filter by model type and transaction author, then callModelContext.fetchHistoryto read the changes. It is the structured answer for syncing with an external server or reacting to app-extension writes.3@Attribute(.codable)uses the property’s codable representation to store the property, giving you a declarative way to persist aCodablevalue type without aValueTransformer.4- All three ship across iOS, iPadOS, macOS, Mac Catalyst, tvOS, visionOS, and watchOS in the 27.0 beta.234
Watching A Fetch Outside The View: ResultsObserver
@Query is excellent and constrained. It lives in a SwiftUI view, it re-runs when its predicate changes, and it hands the view an array. The constraint is the location. A view model, a sync coordinator, an export job, or a background reconciler has data that changes and no @Query to lean on. Before iOS 27 those callers subscribed to ModelContext save notifications and re-fetched by hand, which is the manual reconstruction of exactly what @Query already does internally.
ResultsObserver is iOS 27’s named answer. The declaration states what it is:2
final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable
The class automatically monitors changes to models that match specified fetch criteria and maintains a collection of fetched results, which makes it the tool for keeping any consumer synchronized with persistent data, not only a view.2 You configure it two ways: with a complete FetchDescriptor, or with individual filter predicates and sort descriptors.2 The two SectionName paths matter for grouped lists; when you do not need sectioning, you pass Never as the SectionName type parameter.2
The payoff over the old manual approach is the Observable conformance. ResultsObserver is Observable, which lets SwiftUI views automatically update when results change, the same way @Observable model objects drive view invalidation (covered in @Observable internals).2 A sync coordinator that holds a ResultsObserver gets change notifications without writing a single NotificationCenter line, and any view that reads the observer’s results re-renders for free.
In session 274, Apple introduces ResultsObserver for the case @Query cannot serve, fetching and observing the store from anywhere in your app through Swift Observation, including a state object or a game that never touches SwiftUI.5
ResultsObserver brings query-style observation to code outside SwiftUI views.
import SwiftData
import Observation
@Observable
final class ShoppingListModel {
let observer: ResultsObserver<ShoppingItem, Never>
init(context: ModelContext) {
let descriptor = FetchDescriptor<ShoppingItem>(
sortBy: [SortDescriptor(\.sortOrder)]
)
// No sectioning, so SectionName is Never.
observer = ResultsObserver(context: context, fetchDescriptor: descriptor)
}
}
Apple’s published reference confirms the class declarations and configuration surface (a FetchDescriptor or filter predicates and sort descriptors, Never for the section name when you do not section) but elides the exact initializer signatures at the time of writing, so treat the call shapes in these examples as illustrative and confirm the parameter labels against the SDK.
The mental shift is that the fetch becomes an object you own and pass around, rather than a property wrapper trapped in a view’s body. A @Query answers “what does this view show?” A ResultsObserver answers “what is the current state of this fetch, wherever I am holding it?” The second question is the one a non-view caller actually asks.
Reacting To History Changes: HistoryObserver
The gap @Query and ResultsObserver both leave open is the change that originates outside your in-process fetch. Every time your store is saved, SwiftData records a history transaction describing what changed, where the change came from, and a token that identifies it. Keeping a store in sync with an external server, or reacting to writes from an app extension, meant walking that persistent history yourself. iOS 27 gives the job a named observer.3
HistoryObserver is the structured answer:3
final class HistoryObserver
The observer watches SwiftData’s persistent history and lets your code react when new transactions are added.3 If you only need certain kinds of changes, you filter by model type and transaction author, so you react to the writes you care about rather than every store mutation.3
The whole surface is one observable property, eventCounter. When new transactions land in the persistent history, the counter increments; you observe it, and on each increment call the ModelContext.fetchHistory API to read just the latest changes.3 The history tokens live on the transactions themselves, so fetchHistory returns only what is new rather than re-scanning the store. That keeps a sync handler fast as history grows.
import SwiftData
import Observation
// Illustrative call shapes; confirm parameter labels against the SDK.
let historyObserver = HistoryObserver(container: modelContainer, authors: "App")
// Observe eventCounter; on each increment, fetch and process the new history.
let token = withContinuousObservation(of: historyObserver.eventCounter) {
Task { await processChanges() }
}
Filtering by transaction author is the detail that makes server sync correct. In Apple’s example, the observer passes "App" as the author so it reacts only to changes the app made, and does not replay changes that came from the server back to the server.3 On each increment your processChanges step calls ModelContext.fetchHistory to read the new transactions and upload them. The multi-process and external-sync patterns the apps in this cluster solved with hand-rolled history plumbing (see SwiftData schema discipline) get a framework seam to hang on.
Persisting A Value Type Cleanly: @Attribute(.codable)
The third addition is small and practical. SwiftData stores Swift’s primitive types and @Model relationships natively, but a property whose type is a custom Codable value (a struct holding a few fields, an enum with associated values) needed a ValueTransformer and the .transformable(by:) attribute, which is Core Data ceremony leaking back through the macro surface.
iOS 27 adds the codable storage option:4
static var codable: Schema.Attribute.Option { get }
The option uses the property’s codable representation to store the property, so a Codable value type persists through its own Encodable/Decodable conformance with no transformer to register.4 You apply it the same way as any other attribute option:
import SwiftData
struct Coordinate: Codable {
var latitude: Double
var longitude: Double
}
@Model
final class Place {
var name: String
// Persisted via Coordinate's own Codable conformance.
@Attribute(.codable) var location: Coordinate
}
The practical rule is to reach for .codable when a property is a self-contained Codable value that does not deserve its own @Model table. A coordinate pair, a small settings struct, an enum with payload: these are data, not entities, and .codable stores them inline through the representation they already define instead of forcing a transformer or an artificial relationship.
Apple is explicit about the trade-offs. The contents of a codable attribute are opaque to SwiftData, so you cannot use them in predicates to filter results or in sort descriptors, and a change to the codable type’s shape (adding or removing properties) will not trigger a migration, so its Codable implementation has to stay forward- and backward-compatible.5 Apple frames .codable as an escape hatch for types you do not own; for types you define, modeling them as SwiftData models or supported value types keeps sorting, filtering, and indexing on the table.5
When To Reach For Each
The three additions answer three different questions, and the question tells you which one to use.
- Reach for
ResultsObserverwhen a non-view caller needs a live fetch. A view model, a coordinator, an export task, anything that has data that changes and is not a SwiftUIbody. Inside a view,@Queryis still the lighter tool; the observer earns its place the moment the consumer is not a view.2 - Reach for
HistoryObserverwhen you sync the store with something outside your app. An external server, or an app extension writing to the same store. ObserveeventCounter, filter by model type and transaction author, and callModelContext.fetchHistoryon each increment to read just the new transactions.3 - Reach for
@Attribute(.codable)when a property is aCodablevalue, not an entity. Small structs and enums that travel with their owner. If the type needs its own identity, relationships, or queries, it wants@Modelinstead; if it is just inline data,.codableskips the transformer.4
The two observers compose. A ResultsObserver keeps your in-process fetch live; a HistoryObserver tells you when a remote push warrants acting on what changed. An app doing real multi-device sync uses both, and uses .codable to keep its value-type columns honest along the way.
FAQ
How is ResultsObserver different from @Query?
@Query is a SwiftUI property wrapper that lives inside a view and feeds that view an array. ResultsObserver is a standalone class you create and hold anywhere, including outside the view hierarchy, that observes and tracks changes to a collection of persistent models in a model context.2 Because the observer is Observable, a SwiftUI view that reads it still updates automatically, so it covers both the in-view case and the view-model or coordinator case that @Query cannot reach.2
What does HistoryObserver actually observe?
It observes SwiftData’s persistent history, the record of transactions SwiftData writes every time the store is saved.3 It exposes a single observable property, eventCounter, which increments when new transactions are available; you can filter by model type and transaction author so only the changes you care about move the counter.3 On each increment your code calls the ModelContext.fetchHistory API to read the new transactions, which makes it the structured handler for syncing with an external server or reacting to app-extension writes.3
Can I use ResultsObserver and HistoryObserver together?
Yes, and a synced app usually should. ResultsObserver keeps an in-process fetch current as the local context changes; HistoryObserver surfaces changes recorded in the persistent history, filtered by model type and transaction author.23 Both are observable objects you can react to from a SwiftUI view or another observer, so they slot into the same reactive flow without separate notification handling.23
When should I use @Attribute(.codable) instead of a relationship?
Use .codable when the property is a self-contained Codable value type that has no independent identity, since the option stores the property through its own codable representation.4 Use a @Model relationship when the value is a real entity with its own lifecycle, identity, or queries. The dividing line is whether the thing is data that belongs to its owner, or an entity that other rows reference.
The full Apple Ecosystem cluster: SwiftData schema discipline for the migration cost that observation sits on top of; the SwiftData migrations guide for the VersionedSchema and MigrationPlan machinery; @Observable internals for the observation model these classes plug into; SwiftUI internals for the framework substrate underneath. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
References
-
Apple Developer Documentation: SwiftData. The framework reference covering
@Model,ModelContext,ModelContainer, queries, and the iOS 27 observation additions. ↩ -
Apple Developer Documentation:
ResultsObserver(iOS 27.0 beta). “Observes and tracks changes to a collection of persistent models in a model context.” Declared asfinal class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable; configurable with aFetchDescriptoror with filter predicates and sort descriptors;Observable, so SwiftUI views update automatically; passNeverasSectionNamewhen no sectioning is needed. ↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple, WWDC26 session 274, What’s new in SwiftData, and Apple Developer Documentation:
HistoryObserver(iOS 27.0 beta). Declared asfinal class HistoryObserver. Per session 274, it observes SwiftData’s persistent history and “has a single observable property” (eventCounter); “when new transactions are available in the persistent history, the eventCounter increments,” and “your code can observe the eventCounter and when it increments, use ModelContext.fetchHistory API to fetch the latest changes.” It “lets you filter by model type and transaction author”; the session’s example passes"App"as the author so app-originated changes are not replayed back to an external server. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple Developer Documentation:
codable(iOS 27.0 beta). “Uses the property’s codable representation to store the property.” Declared asstatic var codable: Schema.Attribute.Option { get }. ↩↩↩↩↩↩ -
Apple, WWDC26 session 274, What’s new in SwiftData. Apple introduces
ResultsObserver, which “fetches data from your SwiftData store and then observes your store for changes” but “works anywhere in your app — independent of SwiftUI views — using Swift Observation,” and names a state object or a game written in SceneKit as cases@Querycannot reach. ↩↩↩