A SwiftData store shared by an app, a widget, and an App Intent looks simple on a diagram: put the file in an App Group and point every target at it. That is only the storage-location decision. The hard part is that three independent processes can launch in an inconvenient order, run different lifetimes, and carry different assumptions about the schema.
The failure worth designing for is not exotic. A user installs an update, sees the widget before opening the app, and WidgetKit launches the extension against the previous store. If that extension migrates the file, a short-lived process now owns your most consequential persistence operation.
Apple’s SwiftData engineers recommend a clearer arrangement for multi-process apps: choose one process—normally the main app—to own the database and migration, then coordinate access for the other processes. This guide turns that recommendation into a production design. It uses shipping SwiftData APIs for the implementation and keeps the iOS 27-era observation APIs announced at WWDC26 in a separate preview section.
1. Treat the shared store as a process boundary
Start with three rules:
- The main app is the migration owner. It is the only process allowed to establish that a new schema is ready.
- Extensions check readiness before constructing a container. If readiness is missing or stale, they return placeholder or recovery UI rather than touching the store.
- Live
@Modelobjects never leave the context and actor that fetched them. Cross-boundary values are identifiers or immutable DTOs.
These rules matter more than which repository pattern you choose. A widget is not a view running inside the app, and an App Intent may execute in an extension process. Every target therefore needs a composition root, much like the process-aware setup in App Intents across Siri, Shortcuts, and widgets.
App Groups provide a shared container to related, entitled targets. They do not serialize your code, negotiate versions, or make UserDefaults transactional with a database migration.
2. Put one schema in a shared module
Keep the model, versioned schemas, migration plan, DTOs, and store factory in a Swift package linked by all participating targets. That prevents the widget from quietly compiling a different model graph. The package boundary should contain persistence code, not WidgetKit or application UI; this is the same dependency discipline used in SPM modularization for large iOS codebases.
Here is a small two-version model. Version 2 adds a defaulted isCompleted property, which qualifies for a lightweight migration.
import Foundation
import SwiftData
enum TasksSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [TaskItem.self] }
@Model
final class TaskItem {
var id: UUID
var title: String
var createdAt: Date
init(id: UUID = UUID(), title: String, createdAt: Date = .now) {
self.id = id
self.title = title
self.createdAt = createdAt
}
}
}
enum TasksSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [TaskItem.self] }
@Model
final class TaskItem {
var id: UUID
var title: String
var createdAt: Date
var isCompleted: Bool = false
init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isCompleted: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isCompleted = isCompleted
}
}
}
enum TasksMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[TasksSchemaV1.self, TasksSchemaV2.self]
}
static var stages: [MigrationStage] {
[
.lightweight(
fromVersion: TasksSchemaV1.self,
toVersion: TasksSchemaV2.self
)
]
}
}
typealias TaskItem = TasksSchemaV2.TaskItem
Do not duplicate these declarations across targets. A readiness integer also cannot replace VersionedSchema and SchemaMigrationPlan: the plan evolves stored data; the marker only decides which process may open it.
3. Configure SwiftData with an explicit App Group
Enable the same App Groups capability for the app, widget, and App Intents extension. A missing entitlement should be a build or launch failure in development—not a reason to silently create a private store elsewhere.
SwiftData’s ModelConfiguration.GroupContainer.identifier(_:) tells the framework to place persistent storage in the specified group container:
import SwiftData
enum SharedDatabase {
static let appGroupID = "group.com.example.tasks"
static let currentSchemaVersion = 2
static let schema = Schema(versionedSchema: TasksSchemaV2.self)
private static func configuration(allowsSave: Bool) -> ModelConfiguration {
ModelConfiguration(
"Tasks",
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: allowsSave,
groupContainer: .identifier(appGroupID),
cloudKitDatabase: .none
)
}
// Only the main app calls this factory. Supplying the migration plan lets
// this process upgrade an older store to the current schema.
static func makeAppContainer() throws -> ModelContainer {
let configuration = configuration(allowsSave: true)
return try ModelContainer(
for: schema,
migrationPlan: TasksMigrationPlan.self,
configurations: [configuration]
)
}
// Extensions never receive the migration plan. Opening the store succeeds
// with the current schema or throws, which the extension handles.
static func makeExtensionContainer(allowsSave: Bool) throws -> ModelContainer {
let configuration = ModelConfiguration(
"Tasks",
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: allowsSave,
groupContainer: .identifier(appGroupID),
cloudKitDatabase: .none
)
return try ModelContainer(for: schema, configurations: [configuration])
}
}
The example opts out of CloudKit so local multi-process behavior can be tested independently. Add an explicit cloudKitDatabase only after making the schema compatible.
Use allowsSave: false for read-only extension containers. This is useful defense in depth: code in that container can fetch but cannot persist changes. Crucially, makeExtensionContainer does not receive TasksMigrationPlan, so an extension cannot intentionally become the migration owner through this composition root. Its attempted open either succeeds against the current schema or throws.
4. Make the main app the sole migration owner
The app constructs the writable container during startup. Only after that succeeds does it publish a marker to shared defaults. The marker is deliberately written last.
import Foundation
import SwiftData
enum SharedStoreReadiness {
private static let key = "readySwiftDataSchemaVersion"
private static var defaults: UserDefaults? {
UserDefaults(suiteName: SharedDatabase.appGroupID)
}
static var readyVersion: Int? {
defaults?.object(forKey: key) as? Int
}
static func markCurrentSchemaReady() {
defaults?.set(SharedDatabase.currentSchemaVersion, forKey: key)
}
static func clear() {
defaults?.removeObject(forKey: key)
}
static var isCurrentSchemaReady: Bool {
readyVersion == SharedDatabase.currentSchemaVersion
}
}
@MainActor
final class AppDatabaseBootstrapper {
enum State {
case loading
case ready(ModelContainer)
case failed(String)
}
private(set) var state: State = .loading
func start() {
SharedStoreReadiness.clear()
do {
let container = try SharedDatabase.makeAppContainer()
SharedStoreReadiness.markCurrentSchemaReady()
state = .ready(container)
} catch {
state = .failed(error.localizedDescription)
}
}
}
Clearing first closes an important window: after an app update, an old “version 1 ready” value cannot authorize a version 2 extension. Requiring exact equality also makes a downgrade fail closed. In a real app, include a store identity or account identifier if one installation can switch among stores.
This marker is not a lock or proof of compatibility. Shared defaults are not committed atomically with the database, and another process may already have an older container open. Treat the marker only as a cheap, fail-closed gate that avoids an obviously premature open. The extension’s no-migration container open is the authoritative compatibility check: if it throws, the extension stops and asks the user to open the app. Keep extension operations short and test upgrades on devices.
5. Fail closed in widgets and extensions
An extension checks the marker before it initializes SwiftData. If the main app has not completed migration, a widget returns a timeline that asks the user to open the app.
import SwiftData
import WidgetKit
struct TasksEntry: TimelineEntry {
let date: Date
let tasks: [TaskSummary]
let requiresAppLaunch: Bool
}
struct TaskSummary: Identifiable, Sendable {
let id: UUID
let title: String
let isCompleted: Bool
}
struct TasksProvider: TimelineProvider {
func placeholder(in context: Context) -> TasksEntry {
TasksEntry(date: .now, tasks: [], requiresAppLaunch: false)
}
func getSnapshot(
in context: Context,
completion: @escaping (TasksEntry) -> Void
) {
loadEntry(completion: completion)
}
func getTimeline(
in context: Context,
completion: @escaping (Timeline<TasksEntry>) -> Void
) {
loadEntry { entry in
completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(900))))
}
}
private func loadEntry(completion: @escaping (TasksEntry) -> Void) {
guard SharedStoreReadiness.isCurrentSchemaReady else {
completion(TasksEntry(date: .now, tasks: [], requiresAppLaunch: true))
return
}
do {
// This factory has no migration plan. A failed open becomes the
// recovery entry below; the widget never attempts migration.
let container = try SharedDatabase.makeExtensionContainer(allowsSave: false)
let context = ModelContext(container)
var descriptor = FetchDescriptor<TaskItem>(
predicate: #Predicate { !$0.isCompleted },
sortBy: [SortDescriptor(\TaskItem.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 5
let summaries = try context.fetch(descriptor).map {
TaskSummary(id: $0.id, title: $0.title, isCompleted: $0.isCompleted)
}
completion(TasksEntry(date: .now, tasks: summaries, requiresAppLaunch: false))
} catch {
completion(TasksEntry(date: .now, tasks: [], requiresAppLaunch: true))
}
}
}
Do not call fatalError in an extension. Render “Open Tasks to finish updating,” deep-link where supported, and log a privacy-safe diagnostic.
Keep extension queries bounded. Apply a predicate and sort order, map immediately to DTOs, and cap the results.
6. Isolate writes with ModelActor and return DTOs
After migration, other processes can read and write the shared database. But SwiftData models remain reference-based members of a context’s object graph, not values to move across actors.
Use @ModelActor for serialized storage work and return a Sendable result:
import Foundation
import SwiftData
struct CompletionResult: Sendable {
let id: UUID
let title: String
let isCompleted: Bool
}
enum TaskStoreError: Error {
case taskNotFound
}
@ModelActor
actor TaskStore {
func markCompleted(id: UUID) throws -> CompletionResult {
let descriptor = FetchDescriptor<TaskItem>(
predicate: #Predicate { $0.id == id }
)
guard let task = try modelContext.fetch(descriptor).first else {
throw TaskStoreError.taskNotFound
}
task.isCompleted = true
try modelContext.save()
return CompletionResult(
id: task.id,
title: task.title,
isCompleted: task.isCompleted
)
}
}
Construct TaskStore(modelContainer:) in the process that performs the operation. The generated model actor owns an isolated model context and serial executor. For more detail on why isolation is different from merely “doing work in the background,” see Swift concurrency and UI freezes.
A PersistentIdentifier is another valid boundary value. Pass the identifier, then re-fetch using the receiving actor’s context. DTOs are usually more convenient for UI because they are immutable, explicit, and easy to test.
7. Route extension writes deliberately
For a display-only widget, keep its container read-only. For a mutating App Intent, choose one of two explicit policies:
- Require the app to open for operations that need account state, UI, a long migration, or complex conflict handling.
- Allow the extension to write only after readiness succeeds, using the shared repository and a short, actor-isolated transaction.
Here is the second policy for a lightweight “complete task” action:
import AppIntents
import SwiftData
import WidgetKit
struct CompleteTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Complete Task"
@Parameter(title: "Task ID")
var taskID: String
func perform() async throws -> some IntentResult {
guard SharedStoreReadiness.isCurrentSchemaReady else {
throw StoreUnavailableError.openAppToUpgrade
}
guard let id = UUID(uuidString: taskID) else {
throw StoreUnavailableError.invalidIdentifier
}
let container: ModelContainer
do {
// This writable extension container deliberately has no migration
// plan. Its successful open, not the marker, proves compatibility.
container = try SharedDatabase.makeExtensionContainer(allowsSave: true)
} catch {
// Never pass the migration plan to this process as a fallback.
throw StoreUnavailableError.openAppToUpgrade
}
let store = TaskStore(modelContainer: container)
_ = try await store.markCompleted(id: id)
WidgetCenter.shared.reloadTimelines(ofKind: "TasksWidget")
return .result()
}
}
enum StoreUnavailableError: LocalizedError {
case openAppToUpgrade
case invalidIdentifier
var errorDescription: String? {
switch self {
case .openAppToUpgrade:
return "Open the app once to finish updating your tasks."
case .invalidIdentifier:
return "The task identifier is invalid."
}
}
}
Call reloadTimelines only after save() succeeds. It requests a new timeline; it does not guarantee an immediate redraw.
If the mutation is financially significant, destructive, or requires several services, prefer bringing the app forward and executing through the app’s composition root. An App Intent is a system adapter, not a second persistence architecture. Keeping both routes behind the same use case follows the dependency direction described in MVVM with Clean Architecture.
8. Design a recoverable store-opening experience
Store initialization can fail because of migration errors, missing entitlements, unavailable protected data, disk pressure, or a damaged store. The main app needs a UI state beyond a force unwrap:
import SwiftUI
struct DatabaseRootView: View {
let state: AppDatabaseBootstrapper.State
let retry: () -> Void
var body: some View {
switch state {
case .loading:
ProgressView("Preparing your data…")
case .ready(let container):
TasksView()
.modelContainer(container)
case .failed:
ContentUnavailableView {
Label("Tasks couldn't be opened", systemImage: "externaldrive.badge.exclamationmark")
} description: {
Text("Your data has not been deleted. Try again, or export diagnostics for support.")
} actions: {
Button("Try Again", action: retry)
}
}
}
}
Never automatically delete the store and start over. Preserve the underlying error for local logging without exposing file paths or personal values, and offer an explicit recovery path.
Extensions should degrade quietly. A widget can show its last timeline or an “Open app” state; an intent should throw an actionable error instead of attempting repair.
9. Add CloudKit only after local coordination works
App Groups share a local container among processes on one device. CloudKit synchronizes compatible records among a person’s devices asynchronously.
SwiftData’s CloudKit integration has schema constraints. Unique constraints cannot be enforced by CloudKit, relationships must be optional because relationship changes are not guaranteed to process atomically, and the deny delete rule is unsupported. Every target that opens a syncing store also needs the correct iCloud container entitlement and compatible configuration.
CloudKit does not replace app-owned migration. First prove local coordination with .none; then enable a specific private database, promote its schema through Apple’s workflow, and test offline and multi-device conflicts.
On shipping systems, SwiftData History can discover transactions written by another process. Set a context author, fetch after a stored token, and handle expired tokens. History is not a mutex or migration permission.
10. Keep iOS 27 observation APIs behind a preview boundary
At WWDC26, Apple previewed ResultsObserver, HistoryObserver, and continuous observation for Apple’s 2027 OS releases. They solve useful notification problems:
ResultsObservermaintains fetch results outside SwiftUI and responds to changes from other contexts, processes, and CloudKit.HistoryObserverincrements an observable event counter when new history transactions arrive; your code then fetches and processes history.withContinuousObservationkeeps an observation active for the lifetime of its token.
These APIs are documented as beta and require the matching preview SDK. Do not paste their syntax into a production target that supports today’s shipping toolchain, and do not confuse change notification with migration coordination. An observer can tell you that a ready store changed; it cannot make an incompatible store safe to open.
Wrap experiments in a separate branch or availability-gated module, and retain the shipping strategy: timeline reloads, scene-activation refresh, bounded fetches, and SwiftData History where appropriate. Revisit the implementation when Apple ships the SDK and finalizes signatures and behavior.
11. Test launch order, not just repository methods
An in-memory container cannot reproduce App Group permissions, process boundaries, or upgrades. Add file-backed integration and device tests for these sequences:
- Install version 1, create data, install version 2, launch the widget first, and verify it asks for the app without opening the store.
- Launch the app, complete migration, relaunch the widget, and verify old data plus new default values.
- Force container creation to fail and verify that the readiness marker remains absent.
- Run an intent and app write close together, then confirm both saves and the final business invariant.
- Launch with a missing App Group entitlement in a test configuration and ensure the app reports configuration failure rather than creating a private fallback store.
- Simulate an older binary against a newer readiness marker and verify exact-version checking fails closed.
- If CloudKit is enabled, test offline creation, delayed import, relationship conflicts, account changes, and extension refresh.
Run migrations with realistic store sizes. If production data takes time, keep migration in the app and provide visible progress and recovery.
Key Takeaways
- An App Group chooses a shared storage location; it does not coordinate processes or migrations.
- Make the main app the sole migration owner; only its factory receives the schema migration plan.
- Treat a shared-defaults version as a cheap gate, not an atomic lock or compatibility proof. A no-plan extension container opening successfully is the authoritative check.
- Prefer read-only extension containers. Allow writes only after readiness, behind a shared use case and actor-isolated context.
- Pass
SendableDTOs or persistent identifiers across actors, never live SwiftData models. - Show recoverable UI when the app cannot open the store, and never delete user data automatically in response to a migration error.
- Test extension-first launches, upgrades, concurrent writes, entitlement mistakes, and CloudKit delays on real file-backed stores.
- Keep
ResultsObserver,HistoryObserver, and continuous observation labeled as iOS 27-era preview APIs until they ship.
SwiftData can support a main app, widgets, and App Intents over one store, but the safe design is intentionally asymmetric. The app owns evolution. Extensions verify readiness, do bounded work, and fail gracefully. Once that contract is explicit, the shared file stops being an accidental coupling point and becomes a persistence boundary you can test.