SwiftData Group Lab

Join us online for a deep dive into WWDC26 with Apple engineers and designers to ask questions, get advice, and follow the discussion about the week's biggest SwiftData announcements. Conducted in English.

Transcript

Engineers from the SwiftData team introduce themselves and set up a session covering SwiftData with widgets and app intents, large datasets and performance, @Query versus ResultsObserver, CloudKit sync and schema evolution, migrations, enums, multiple model contexts, and concurrency.

In multi-process setups (app plus widget or App Intents extension), have one process — the app — own the database and perform migration, and coordinate access. Be careful if the user fires up the widget before launching the app for a new version, since the extension shouldn't be the one driving a migration. Design so the app owns schema changes and the extension reads once they're applied.

First define what "large" means for your case — many rows, large blobs stored per model, or simply many models — since the approach differs. For many rows: index the properties you query so the database can fetch them quickly, and don't fetch everything at once — put limits on your fetch requests so you don't exhaust memory. For large per-model data, use the externalStorage attribute, which stores the blob in a file next to the database rather than inline. When ingesting a large dataset, insert in smaller batches using a short-lived model context you renew each batch. And when driving a @Query in SwiftUI over many rows, make sure a precise predicate expresses exactly what you need (rather than filtering again with an if in the body) and that it's covered by an index; the persistence Instrument shows how many objects are actually being fetched under the hood.

Yes — the Sample Trips app uses preview traits to seed preview data, which is a great approach. Make the sample data expressive and diverse (long names, many entries, varied cases) so your previews exercise the full range of UI states rather than a single trivial example.

ResultsObserver is the equivalent of @Query for use outside a SwiftUI view — reach for it in a view model or other non-view context where you still want observable results. @Query is the right tool inside views. Choose based on where the code lives and how much architectural control you want over observation, rather than a raw performance difference.

This is a known gap — the older NSExpression surface is being succeeded by newer Swift-based expression support, and it isn't all there yet, so a feedback request with your specific use case is genuinely useful for shaping the API. In the meantime there are workarounds: you can get min or max with a fetch limit of one plus a sort descriptor. And because Core Data and SwiftData can coexist against the same data store, where SwiftData lacks an equivalent you can reach into the Core Data stack and use NSExpression there on the same underlying data.

Distinguish non-optional view state from non-optional model properties. A common pattern is to collect the user's input in view-local state first and only construct/insert the SwiftData model once the required (non-optional) values are provided, rather than inserting a partially-initialized object. That keeps the model's non-optional invariants intact while the form is being filled in.

You can add a versioned schema at any time. Moving to an App Group container is more involved: it's a different directory and entitlements can't be aligned to the old location, so you'll get a new container and must copy the existing data over into the group container, then start from there. Set up your model configuration accordingly before switching.

Ensure every app reading from the App Group uses the same, appropriate CloudKit entitlement, since all of them sync to that shared container on the store's behalf. With consistent entitlements and a shared versioned schema across those apps, you can evolve the schema safely; mismatched entitlements or schemas across the group cause sync problems.

When a development build and the shipping app write to the same shared CloudKit store while the schema evolves, you must guard against duplicates. Keep development and production schemas coordinated through versioned schemas, and be deliberate about which clients write to the shared container so an evolving dev schema doesn't corrupt or duplicate production data.

Use fetchCount on the model context — it returns the count without materializing the objects, which is far cheaper than fetching them and counting in the view.

@Query does a normal fetch under the hood; the tricky part in SwiftUI is that the view cycle can fetch more often than you'd like. Manually fetching into a cached array and managing updates is a legitimate optimization for hot paths where you need tight control, as long as you handle observation yourself — it's a reasonable tradeoff, not an anti-pattern, when @Query's automatic re-fetching is costing you.

Beyond thorough testing, use the extensive logging that the default Core Data-backed store routes through to see what sync is doing and where time goes. CloudKit sync timing isn't fully under your control, but diagnosing via those logs — and ensuring the right client drives sync — helps you understand and reduce the cross-device lag.

As long as the associated values are Codable, you can persist the enum. New this year, you can build predicates with RawRepresentable enumerations — covered in the What's New in SwiftData talk — so enums integrate more naturally into queries as well as storage.

It depends on your objective. If the work interacts with a view, that shapes the choice; but for a self-contained background work set, a separate context (e.g. via a ModelActor) is appropriate so heavy work doesn't block the main context. Keep view-driving work on the main context and isolate background batches in their own context, merging results back deliberately.

Let Instruments tell you. Watch for UI hitches and check whether you're pulling in many more objects than you actually display — if so, paginate or limit the fetch. Use FetchDescriptor's fetch limits and offsets to load in pages sized to what the UI shows, rather than fetching an entire type up front.

It's a valid approach, not an anti-pattern. If your data already lives in JSON files, you can implement a custom SwiftData store backed by that source — there's an example app demonstrating a custom store — letting SwiftData manage local access while your existing backend continues to handle sync.

For a progress UI, you can compute how many migration stages exist and override the didMigrate handler for a custom migration stage, tracking which stage you're on versus how many remain — not wall-clock time, but a stage count. Combine that with having the app own migration so extensions don't touch the store while it's mid-migration.

Both ResultsObserver and @Query now support sectioning: provide a key path to a persistent property and your data is grouped by it. This gives a built-in group-by experience for section-based UIs (demonstrated in the Sample Trips app) without manually bucketing results yourself.

Model objects aren't Sendable — they're reference-based and part of the context's object graph, so they can't be passed across actor boundaries. Instead, have the ModelActor return a Sendable representation: the model's PersistentIdentifier (or a plain value/DTO) that the receiving actor uses to re-fetch the object in its own context, rather than handing over the model instance directly.