How do ModelActor, context-per-actor and VersionedSchema work in SwiftData, and what can you pass between actors?
Predict first
Do a versioned schema, a migration plan, a model actor and a Sendable identifier compile together as described?
import SwiftData
import Foundation
enum V1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Item.self] }
@Model final class Item { var title: String; init(title: String) { self.title = title } }
}
enum V2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Item.self] }
@Model final class Item { var title: String; var done: Bool = false; init(title: String) { self.title = title } }
}
enum Plan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [V1.self, V2.self] }
static var stages: [MigrationStage] { [.lightweight(fromVersion: V1.self, toVersion: V2.self)] }
}
@ModelActor actor Store {
func count() throws -> Int { try modelContext.fetchCount(FetchDescriptor<V2.Item>()) }
}
func requireSendable<T: Sendable>(_: T.Type) {}
func check() { requireSendable(PersistentIdentifier.self) }
- It compiles
- It does not compile
Mental model. A model actor is a clerk with their own desk and filing tray. You do not carry papers (models) to another clerk’s desk; you hand over the folder number (the identifier) and they pull the same folder from the cabinet.
As with Core Data, a ModelContext is not Sendable and models are bound to their context, so the rule is one context per isolation domain. SwiftData supports this with the @ModelActor macro, which turns an actor into one that owns a ModelContainer and a modelContext running on the actor’s executor: @ModelActor actor Importer { func run() throws { /* use modelContext */ } }, created with Importer(modelContainer: container). All work on its context happens inside the actor’s methods, off the main thread, and callers await them. The main-actor mainContext serves the UI, and a model actor does imports and background processing. What crosses the boundary is the PersistentIdentifier of a model (it is Sendable), not the model object; the receiving context re-fetches with context.model(for: id) or a predicate. Return plain Sendable value types (a small struct) from the actor when the caller only needs data. Be careful with the actor’s initialiser context: a model actor created on the main thread can end up with a main-thread context, so build it from a task off the main actor where you need background execution. Changes saved in one context appear in the others through the container’s notifications, and @Query views update.
Schema evolution uses VersionedSchema types (each with a versionIdentifier and its models) and a SchemaMigrationPlan listing stages: .lightweight(fromVersion:toVersion:) for additive and simple changes (a new property with a default value), and .custom(fromVersion:toVersion:willMigrate:didMigrate:) for data transformations, where you run code before and after the schema change (for instance, deduplicate records before adding a unique attribute, or fill a new field from an old one). Pass the plan to the container (ModelContainer(for:migrationPlan:)). As with Core Data: version every shipped schema from the start, never edit a released version, test an upgrade from each, and keep migrations quick. Without a plan, SwiftData tries an automatic lightweight migration for simple changes, but relying on it for anything non-trivial is risky. In Swift 6 mode some SwiftData protocol requirements (like versionIdentifier as a mutable static) still produce concurrency diagnostics, so the language mode of the file that declares schemas may need relaxing until the SDK catches up.
A lightweight stage between two versioned schemas, a model actor owning its own context, and a Sendable persistent identifier are all supported together.
Common traps
- Passing a model object to another actor. Models belong to their context; pass the
PersistentIdentifierand refetch. - Creating a
@ModelActoron the main thread and expecting background work. Its context may run on the main executor; create it from a background task.
They might follow up with
- What is
Sendablein SwiftData?PersistentIdentifierand your own value types, not models or contexts. - How do you transform data during a version change? A
.custommigration stage withwillMigrateanddidMigrate.
Related questions
- 13.4.1 How do SwiftData’s @Model, ModelContainer, ModelContext, #Predicate and FetchDescriptor work together?
- 13.3.3 How do Core Data migrations work: lightweight, staged and heavyweight, and what about CloudKit?
- 10.5.1 How does region-based isolation work, and what does the sending keyword add on top of Sendable?
← How do SwiftData’s @Model, ModelContainer, ModelContext, #Predicate…