SwiftData Background Tasks

How do you perform a SwiftData operation in the background?

Last updated: Feb 20, 2024

Core Data Private Queue Contexts

When using Core Data you perform UI operations using a view context on the main queue. To avoid blocking the main queue you perform long running tasks like parsing and importing data using a private background queue context.

You must take care not to pass Core Data managed objects between queues. If you need to pass objects between threads you do so using the NSManagedObjectID.

SwiftData Concurrency Support

SwiftData makes use of Swift’s modern concurrency features. You perform background work using a context created on a ModelActor.

Like Core Data, neither the Model objects or ModelContext can be passed between actors (neither are sendable). Unlike Core Data, the Swift compiler enforces the rules. For example, attempting to access the main view context when not on the main actor is an error:

Main actor-isolated property mainContext can not be referenced on a non-isolated actor instance

Using A ModelActor

To get started we need to create our own actor that implements the ModelActor protocol. The model actor provides the context for us to use. A ModelExecutor controls access to the model actor.

When creating the actor you use the ModelContainer to create a new context and use that to create a DefaultSerialModelExecutor. My sample code has a model for Country objects so I might create a model actor to perform background operations like this:

import SwiftData

actor CountryModelActor: ModelActor {
  let modelContainer: ModelContainer
  let modelExecutor: any ModelExecutor
    
  init(modelContainer: ModelContainer) {
    modelContainer = modelContainer
    let context = ModelContext(modelContainer)
    modelExecutor = DefaultSerialModelExecutor(modelContext: context)
  }

  func doSomething() { ... }
}

Notes:

  • The ModelContainer is sendable so it’s safe for us to pass it to the actor’s initialiser.
  • Apple warns you not to use the model executor to access the model context. Instead you should use the modelContext property of the actor.

Any work we do in this actor has access to the context to insert, fetch, and delete objects as needed. For example, I’ve added a method to my actor that fetches all visited countries and resets the visited flag to false:

func resetVisited() throws {
  let fetchDescriptor: FetchDescriptor<Country> =
    FetchDescriptor(predicate: #Predicate { $0.visited == true }) 
  let countries = try modelContext.fetch(fetchDescriptor)
  
  for country in countries {
      country.visited = false
  }
  try context.save()
}

I might use this from my view code like this:

func resetVisited() {
  Task {
    let actor = CountryModelActor(container: container)
    do {
      try await actor.resetVisited()
    } catch {
       logger.error("resetVisited: \(error.localizedDescription)")
    }
  }
}

Problems Merging Context Changes

During the Xcode 15 beta period a number of developers were complaining that changes performed on a background context weren’t immediately merged into the view context. Those issues seemed to have been fixed for me by Xcode 15 beta 7.

Accessing Models By Identifier

As with Core Data you should use a model objects persistent identifier if you need to pass it between actors:

country.persistentModelID

The ModelActor provides a convenient subscript to retrieve the model object by identifier. For example, this method in my actor sets the visited flag for a collection of countries passed by identifier:

func visit(identifiers: [Country.ID]) {
  for identifier in identifiers {
    if let country = self[identifier, as: Country.self] {
      country.visited = true
    }
  }
}

Using the subscript is equivalent to writing:

if let country = context.model(for: identifier) as? Country {