All Articles
CategoryiOS
Reading Time
14 min read
Published
2026-06-26
Word Count
3,279words

Grab a coffee — this one is a deep dive!

SwiftData iOS 27: Sectioned Query, .codable, ResultsObserver

Summary

What changed in iOS 27 SwiftData with sectioned query, the .codable attribute, and ResultsObserver? A WWDC26 session 274 guide with code examples.

  • iOS 27 adds a sectionBy parameter to @Query, moving list grouping directly into the query.
  • The .codable attribute lets you store Codable types you don't own, but they can't be used in Predicate/Sort and don't trigger migration.
  • ResultsObserver is the non-SwiftUI counterpart of @Query; it tracks query results on any code path via Swift Observation.
  • HistoryObserver tracks a ModelContainer's remote (including CloudKit) changes incrementally via historyTokens.
SwiftData iOS 27: Sectioned Query, .codable, ResultsObserver

iOS 27 brought four new capabilities to SwiftData in WWDC26 session 274: sectioned query, the .codable attribute, ResultsObserver, and HistoryObserver. This post shows step by step how to use the sectioned query feature through @Query, how to store types you don't own with .codable, and how to listen to query results outside SwiftUI with ResultsObserver. All four are covered based on the WWDC26 session 274 transcript and Apple's official SwiftData docs, as they stood during the beta period (June 2026).

💡 Pro Tip: Use the .codable attribute only for types you don't own (e.g. a framework's types), not for models you define yourself — full @Model support (filtering, sorting, indexing) is always more powerful for your own types.

Table of Contents

The iOS 27 SwiftData Delta: WWDC26 Session 274

Apple introduced four additions to SwiftData in WWDC26 session 274: sectioning query results, persisting custom/third-party types via Codable, observing queries anywhere outside SwiftUI with ResultsObserver, and tracking data store changes with HistoryObserver. These same four capabilities are listed in the same order on Apple's official "SwiftData updates" page; the session itself is presented as the SwiftData innovations in "Apple's 2027 releases" — the iOS 27 and macOS 27 (Golden Gate) cycle.

This post describes the API surface as it stood during the beta period (as of June 26, 2026). There's no new feature or announcement on the Core Data side in the same cycle; object-ID interop is still missing. So if you're already using SwiftData in production, these four additions directly concern you.

It's no coincidence that these four are grouped in the same session and the same "what's new" page — all of them aim to take SwiftData beyond SwiftUI, into service layers and complex list UIs.

There's no announcement in either WWDC26 session 274 or Apple's official SwiftData docs (Query macros, Predicate, Schema.Attribute.Option) about filtering #Predicate with enum values on iOS 27; this post therefore does not cover enum predicates. The four sections below cover, in order, these four additions that have a one-to-one counterpart in the session and the documentation.

Sectioned Query: Moving List Grouping Into the Query

Before iOS 27, if you wanted to section a list with SwiftData (say, grouping trips by destination city), you had to manually group the entire fetched result with Dictionary(grouping:by:) or manually populate Section views. The sectionBy parameter added to @Query moves this work directly into the query: you provide a KeyPath starting from the model's root type, and SwiftData groups the results by that value and hands you a ready-made section collection.

swift
1import SwiftData
2import SwiftUI
3 
4struct TripListView: View {
5 @Query(sort: \Trip.startDate, sectionBy: \Trip.destination)
6 private var trips: [Trip]
7 
8 var body: some View {
9 List {
10 ForEach(_trips.sections) { section in
11 Section(section.id) {
12 ForEach(section) { trip in
13 Text(trip.name)
14 }
15 }
16 }
17 }
18 }
19}

The .sections collection, accessed via the property wrapper's underscored name (_trips), consists of sections each carrying the value of the sectionBy KeyPath as its id. Since the grouping now comes from the query, manual grouping with Dictionary(grouping:by:) and keeping the section list in sync go away; on the SwiftUI side you just build the outer ForEach over the sections and the inner ForEach over the section itself. Because the section itself is a collection, you can hand it directly to the inner ForEach — you don't need to reach for a separate elements array.

Apple's official documentation published this API under "Additional query macros" with concrete overloads: signatures like Query(_:animation:sectionBy:) and Query(filter:sort:transaction:sectionBy:) exist. In other words, sectioning can be used in the same macro together with filtering and sorting — you don't need to learn a separate API.

When Does Sectioned Query Pay Off?

Scenario
Is sectioned query recommended?
Why
Fixed, small number of groups (e.g. status: active/done)
Yes
Section ids come from the query, no manual sync needed
Grouping key changes often (e.g. search results)
Use with care
The section collection is recomputed on every change
Grouping by a .codable field
No
Apple doesn't state this explicitly; the fact that codable content is opaque to SwiftData raises the expectation that grouping isn't supported either — the safe route is keeping the grouping key in a separate native String field

In practice, deciding whether to add sectioning comes down to three questions:

  • Is the number of groups limited?: A small set of fixed values like status, category, or priority — sectioning significantly simplifies UI code.
  • Is the grouping key a String?: The sectionBy KeyPath must resolve to a String or String? field; grouping by an enum or Date means adding a computed String field.
  • Used together with sorting?: The Query(filter:sort:transaction:sectionBy:) overload combines sectioning with your existing sort parameter in one query — no separate fetch needed.

The .codable Attribute: Storing Types You Don't Own

Until now, storing a model property required either a natively supported type (String, Int, Date, small structs) or your own Codable-conforming type. The .codable option added in iOS 27 opens a third path: you can now store a Codable-conforming type you don't own (e.g. one defined by a framework) directly, without SwiftData needing to infer a schema for it.

swift
1import MapKit
2import SwiftData
3 
4@Model
5final class Trip {
6 var name: String
7 var startDate: Date
8 var destination: String
9 
10 @Attribute(.codable)
11 var externalLocation: MKMapItem.Identifier?
12 
13 init(name: String, startDate: Date, destination: String) {
14 self.name = name
15 self.startDate = startDate
16 self.destination = destination
17 }
18}

Defined on Schema.Attribute.Option as static var codable: Schema.Attribute.Option { get }, this option tells SwiftData to delegate serialization to the type's own Codable implementation.

Apple positions this clearly in the WWDC26 session: .codable is not the recommended path for types you define yourself — it's an "escape hatch" for persisting types SwiftData doesn't natively support. For types you own, modeling with full @Model support (filtering, sorting, indexing) should always be preferred.

The Limits of .codable: Predicate, Sort, and Migration

The content of .codable attributes stays opaque to SwiftData — bringing two concrete limitations. In the transcript's words: "the contents of codable attributes are opaque to SwiftData... they can't be used in Predicates to filter results or for sorting." In other words, you can't filter on externalLocation inside #Predicate or sort with a SortDescriptor; these fields stay read/write only.

The second limitation is on the migration side — but this time it works in your favor: even if the shape of the codable type changes (a field added, one removed), SwiftData doesn't treat this as a migration and doesn't trigger schema versioning. The responsibility falls entirely on your Codable implementation — your encode/decode must remain forward and backward compatible.

The practical implications of these two limitations:

  • Loss of filtering: For a "filter by externalLocation" search feature, you may need a separate native field (e.g. plain String) instead of .codable.
  • Loss of sorting: To sort a list by a .codable field, duplicate the sort key into a separate native field — SwiftData won't do it for you.
  • Migration freedom: In exchange, you can evolve your codable payload fully independently of SwiftData's schema migration — no VersionedSchema needed for a new field.
  • Escape-hatch discipline: Taking Apple's "escape hatch" wording seriously means using .codable only for types you genuinely don't own, keeping your own domain models on native @Model fields.
swift
1// Even if the shape of externalLocation changes, SwiftData won't trigger a migration;
2// your Codable implementation must guarantee forward/backward compatibility.
3struct LegacyLocationPayload: Codable {
4 var identifier: String
5 var displayName: String? // field added later — must stay optional in decode
6}
Feature
Native @Model field
.codable attribute
Filterable with #Predicate
Yes
No
Sortable with SortDescriptor
Yes
No
Triggers migration on shape change
Yes
No
Can store a type you don't own (3rd-party)
No (generally)
Yes

ResultsObserver: Live Query Observation Outside SwiftUI

@Query has always been tied to a SwiftUI view — there was no official way to set up the same live observation outside one (in a service layer, an engine class). The ResultsObserver final class added in iOS 27 fills this gap: without depending on SwiftUI, it tracks changes to models matching your fetch criteria in real time via Swift Observation.

ResultsObserver shares the same primitive as @Query: in the non-sectioned case you pass Never as the SectionTitle type parameter, and in the sectioned case you pass a concrete type (e.g. String) along with sectionBy. To listen for changes, you use withContinuousObservation with the didSet option; this function triggers a callback on every change and returns an ObservationTracking.Token so you can control the lifetime of the observation.

swift
1import Observation
2import SwiftData
3 
4@MainActor
5final class MapCameraController {
6 private var observationToken: ObservationTracking.Token?
7 private let observer: ResultsObserver<Trip, Never>
8 
9 init(context: ModelContext) throws {
10 observer = try ResultsObserver<Trip, Never>(modelContext: context)
11 observationToken = withContinuousObservation(options: [.didSet]) { [weak self] _ in
12 // Outside SwiftUI, e.g. in a camera controller,
13 // reposition the camera based on the current Trip list
14 _ = self?.observer.results
15 }
16 }
17}

This shows ResultsObserver isn't just "@Query without SwiftUI" — it also shares the sectioning primitive: a sectioned ResultsObserver gives the same grouped results outside SwiftUI via .sections.

The doors ResultsObserver practically opens:

  • Service-layer integration: A sync service or background task can now observe the current data set independently of the UI, via an official API.
  • Testability: Since it needs no SwiftUI view hierarchy, it can be set up inside a unit test — verify your query observation logic without rendering a view.
  • Reusing the same fetch logic: Sharing the same FetchDescriptor and sectionBy logic between @Query and ResultsObserver means you don't write your filtering rule twice.

HistoryObserver: Persistent History and Remote Changes

While ResultsObserver tracks results matching a specific fetch criterion, HistoryObserver answers a broader problem: how do you notice remote changes to the container's data stores (e.g. from CloudKit sync)? It's a separate, new class that automatically listens to the ModelContainer's remoteChange notifications and determines whether an incoming change is relevant.

When a relevant change is detected, the observer increments its own @Observable eventCounter property; a SwiftUI view or another observer can react by watching this eventCounter. On the performance side, HistoryObserver tracks its position in each data store's transaction history via historyTokens — so only the new transactions added since the last check are processed incrementally. You can also narrow the observation to specific model types with the observedModels parameter.

swift
1import SwiftData
2 
3final class SyncStatusStore {
4 private let historyObserver: HistoryObserver
5 
6 init(container: ModelContainer) throws {
7 historyObserver = try HistoryObserver(
8 observedModels: [Trip.self],
9 modelContainer: container
10 )
11 }
12 
13 var hasRemoteChanges: Bool {
14 historyObserver.eventCounter > 0
15 }
16}

ResultsObserver answers "which results changed", while HistoryObserver answers "did anything change anywhere in the container" — two complementary APIs operating at different levels.

Points to keep in mind with HistoryObserver:

  • historyTokens work incrementally: It doesn't rescan the entire history on every check, only new transactions since the last check — cost stays low even with frequent checks.
  • Narrowing observedModels affects performance: Specifying only the types you actually care about, instead of watching every model type, prevents unnecessary callback triggering.
  • Central role in CloudKit sync: It's the official way to notice remote changes (from another device via CloudKit sync) — previously this needed hand-built mechanisms like NSPersistentHistoryTracking.

Which Constraints From "Production Lessons" Got Closed?

In my post on six months with SwiftData in production, I described three scenarios where we went back to Core Data; these four iOS 27 additions only close one of them. In Scenario 3, we tracked sync state with NSPersistentHistoryToken in a cross-device offline-first app, and as I put it there, SwiftData had "no equivalent of fetchHistory(after: token). Implementing it manually is 200+ lines." HistoryObserver, with ModelContext's fetchHistory method, replaces exactly this hand-written layer. Scenario 1 (25-40% slowdown on 1.5M rows with compound predicates) and Scenario 2 (custom NSManagedObject behavior, validation hooks) remain open — sectioning, .codable, and ResultsObserver don't touch either.

That said, iOS 27 doesn't close one constraint: performance characteristics. The differences I measured in the SwiftData vs Core Data comparison don't change with these four additions — they're all API-surface and developer-experience additions; they don't touch the query engine itself. So keep relying on that post's measurements for a performance decision; this post only answers "what can you newly do."

For general SwiftData fundamentals, you can also check out our comprehensive introduction to SwiftData — sectioned query and .codable build on top of the basic @Model/@Query concepts covered there.

Migration and Backward Compatibility

The migration behavior of .codable attributes is clear from above: no migration is triggered even on shape changes, and compatibility responsibility falls on your Codable implementation. On the sectioned query side, sectionBy is a fetch-side grouping parameter, so it doesn't change the schema; Apple doesn't separately clarify the migration impact of adding or removing it. When extending an existing Codable model, the safest approach is to always define new fields as optional and handle the missing-field case in decode.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

I've collected the items you should check before taking this post into production code into a single checklist. These are the steps most easily skipped when adding sectioned query, .codable, and ResultsObserver to a project at the same time; you can work through them one by one.

FAQ

What is SwiftData sectioned query and how do you use it?

The sectionBy parameter added to @Query in iOS/iPadOS 27 takes a KeyPath starting from the model's root type and groups the results by that value. You iterate over both the sections and the elements within each section using ForEach on the .sections collection, accessed via the property wrapper's underscored name (_trips); each section's id is the value of the sectionBy KeyPath.

How do you store custom types in SwiftData (.codable)?

To store a Codable-conforming type you don't own (e.g. one defined by a framework), you add @Attribute(.codable) to the model property. SwiftData delegates serialization to the type's own Codable implementation; in exchange, this field can't be filtered in #Predicate or sorted with SortDescriptor, and it doesn't trigger migration even if its shape changes.

How do you use ResultsObserver outside SwiftUI?

ResultsObserver is the non-SwiftUI counterpart of @Query: it supports the same filtering/sorting/sectioning primitives, but works on any code path via Swift Observation. You get a change callback by calling withContinuousObservation with the didSet option, and you keep the returned ObservationTracking.Token alive for the lifetime of the observation.

Does iOS 27 SwiftData change the decision to go back to Core Data?

No. The iOS 27 additions (sectioning, .codable, ResultsObserver, HistoryObserver) are real but targeted improvements; there's no innovation on the Core Data side in the same cycle, and object-ID interop is still missing. So it's more accurate to read iOS 27 not as a release that declares SwiftData "now definitively production-ready," but as an incremental step that closes some of the previous limitations.

Update (September 2026)

The SwiftData section of Apple's iOS & iPadOS 27 Release Notes and macOS 27 Golden Gate Release Notes pages now includes this item: a deadlock that could occur for @Query when saving a ModelContext on a background actor while new async tasks were being scheduled for a ModelActor at the same time has been fixed (bug 178113288). This is not a change to the sectioned query, .codable, ResultsObserver, or HistoryObserver API surface described in this post — it's the resolution of a concurrency bug.

Conclusion

The four additions iOS 27 brings to SwiftData — sectioned query, .codable, ResultsObserver, HistoryObserver — share a common theme: moving workarounds you had to hand-write in real apps into the official API. Layer sectioned query and .codable on top of the basics from our comprehensive introduction to SwiftData; for a performance decision, keep relying on our SwiftData vs Core Data comparison, since these additions don't change the query engine's performance. Some constraints from our production lessons post are eased; not all are closed.

This post describes the API surface as it stood during the beta period (June 2026); if you notice a behavior change after GA, don't forget to check the Update section of this post.

Sources

Tags

#SwiftData#iOS 27#WWDC26#Sectioned Query#Codable#ResultsObserver#Swift
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share