SwiftData Persistent History and Change Tracking Across Contexts
You ship a SwiftData app, enable CloudKit sync, and within a week your first support ticket lands: “My data disappeared after I edited on my iPad.” The root cause is almost always the same — your main context has no idea that a background import or a remote sync just rewrote the underlying store. SwiftData’s persistent history API, introduced in iOS 18, gives you the machinery to detect, inspect, and react to every change that happens outside your current context.
This post covers HistoryDescriptor, change tokens, transaction enumeration, and the patterns you need to keep multiple
contexts (and multiple devices) in sync. We will not cover CloudKit configuration itself — that belongs in its own
dedicated post on SwiftData + CloudKit Sync.
Contents
- The Problem
- How SwiftData History Works
- Fetching History with HistoryDescriptor
- Processing Transactions and Changes
- Responding to Remote Store Changes
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a Pixar movie catalog app where users can add, rate, and organize films across an iPhone and an iPad. A
background import service fetches new releases overnight, and CloudKit pushes remote changes whenever the user edits on
another device. Your main ModelContext is blissfully unaware of any of this.
import SwiftData
@Model
final class PixarMovie {
var title: String
var releaseYear: Int
var rating: Double
var isFavorite: Bool
init(
title: String,
releaseYear: Int,
rating: Double = 0.0,
isFavorite: Bool = false
) {
self.title = title
self.releaseYear = releaseYear
self.rating = rating
self.isFavorite = isFavorite
}
}
// Background import -- runs on a separate ModelContext
func importNewReleases(container: ModelContainer) throws {
let backgroundContext = ModelContext(container)
backgroundContext.insert(
PixarMovie(title: "Elio", releaseYear: 2025)
)
try backgroundContext.save()
// The main context still shows stale data
}
After backgroundContext.save() completes, the main context’s @Query results do not refresh. The user stares at a
list that is missing the movie you just inserted. Worse, if CloudKit merges a remote delete, the user might tap a row
that references a model object the store has already removed — and crash.
Before iOS 18, you had to drop down to Core Data’s NSPersistentHistoryTransaction API, manually bridging between
SwiftData and Core Data primitives. That bridge was fragile and required you to understand both frameworks in detail.
SwiftData’s native history API eliminates that indirection entirely.
How SwiftData History Works
SwiftData persistent history builds on the same SQLite WAL journal that Core Data’s NSPersistentHistoryTracking uses
under the hood, but exposes it through pure Swift types. Three concepts form the core:
Transactions represent a single save operation. Every call to ModelContext.save() — whether from your UI context,
a background context, or a CloudKit sync — produces exactly one transaction. Each transaction carries an opaque token,
an author string, and a timestamp.
Changes live inside transactions. Each change records what happened to a single model object: an insertion, an update (with the list of modified attributes), or a deletion.
Tokens are opaque markers that let you say “give me everything that happened after this point.” You persist the last-processed token so you can resume without re-scanning the entire history.
Note: Persistent history tracking is enabled by default when you use a
DefaultStore(SQLite-backed)ModelContainer. If you use a custom data store, you are responsible for implementing history support yourself.
Fetching History with HistoryDescriptor
The entry point is
ModelContext.fetchHistory(_:).
You build a HistoryDescriptor that specifies which slice of history you want, similar to how FetchDescriptor works
for model objects.
import SwiftData
func fetchRecentHistory(
context: ModelContext,
after token: DefaultHistoryToken?
) throws -> [DefaultHistoryTransaction] {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token {
descriptor.predicate = #Predicate { transaction in
transaction.token > token
}
}
let transactions = try context.fetchHistory(descriptor)
return transactions
}
HistoryDescriptor is generic over the type you want back. Use DefaultHistoryTransaction to get full transaction
objects with their nested changes. The predicate filters transactions by their token, effectively saying “everything
newer than the last one I processed.”
Apple Docs:
HistoryDescriptor— SwiftData
Setting the Transaction Author
When multiple writers hit the same store, you need to know who made each change. Set the transactionAuthor property
on your ModelContext before saving:
let backgroundContext = ModelContext(container)
backgroundContext.transactionAuthor = "BackgroundImporter"
backgroundContext.insert(
PixarMovie(title: "Elio", releaseYear: 2025)
)
try backgroundContext.save()
CloudKit sync operations use the system-provided author "NSCloudKitMirroringDelegate". Your UI context might use
"MainApp". This distinction becomes critical when you process history — you typically want to skip transactions
authored by the current context and only react to external changes.
Processing Transactions and Changes
Once you have a list of transactions, iterate through them and inspect each change. Every DefaultHistoryChange is
associated with a specific PersistentModel type and tells you whether it was an insert, update, or delete.
func processHistory(
transactions: [DefaultHistoryTransaction],
context: ModelContext
) throws -> DefaultHistoryToken? {
var lastToken: DefaultHistoryToken?
for transaction in transactions {
// Skip changes we made ourselves
if transaction.author == "MainApp" { continue }
let movieChanges = transaction.changes.compactMap {
$0 as? DefaultHistoryChange<PixarMovie>
}
for change in movieChanges {
switch change {
case .insert(let model):
print("Inserted: \(model.persistentModelID)")
case .update(let model):
print("Updated: \(model.persistentModelID)")
case .delete(let modelID):
print("Deleted: \(modelID)")
}
}
lastToken = transaction.token
}
return lastToken
}
A few things worth noting:
- Inserts and updates give you a reference to the model object itself (or its persistent ID), so you can fetch the current state if needed.
- Deletes only give you the
PersistentIdentifier, because the object no longer exists in the store. This is why you should never hold strong references to model objects across context boundaries. - The
changesarray on a transaction contains all model types that changed in that save. UsecompactMapto filter down to the types you care about.
Persisting the Last Token
You need to store the last-processed token between app launches so you do not reprocess the entire history on every cold
start. UserDefaults works for simple cases; for production apps, a dedicated metadata record is safer.
final class HistoryTokenStore {
private static let key = "lastHistoryToken"
static func save(_ token: DefaultHistoryToken) {
let data = try? JSONEncoder().encode(token)
UserDefaults.standard.set(data, forKey: key)
}
static func load() -> DefaultHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: key) else {
return nil
}
return try? JSONDecoder().decode(
DefaultHistoryToken.self, from: data
)
}
}
Tip:
DefaultHistoryTokenconforms toCodable, making serialization straightforward. Store it wherever makes sense for your architecture —UserDefaults, a plist, or even a separate SwiftData model.
Responding to Remote Store Changes
Fetching history on demand is useful, but production apps need to react when changes arrive. The
ModelContainer.DefaultStore posts a notification through ModelContext whenever the underlying store is modified by
another process or context.
The recommended pattern is to observe .modelContextDidSave from NotificationCenter and then fetch history to
determine what changed:
import SwiftData
import Combine
@Observable
final class PixarMovieSyncMonitor {
private var cancellable: AnyCancellable?
private var lastToken: DefaultHistoryToken?
private let container: ModelContainer
init(container: ModelContainer) {
self.container = container
self.lastToken = HistoryTokenStore.load()
cancellable = NotificationCenter.default.publisher(
for: ModelContext.didSave
)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.processNewHistory()
}
}
private func processNewHistory() {
let context = ModelContext(container)
do {
let transactions = try fetchRecentHistory(
context: context,
after: lastToken
)
guard !transactions.isEmpty else { return }
let newToken = try processHistory(
transactions: transactions,
context: context
)
if let newToken {
lastToken = newToken
HistoryTokenStore.save(newToken)
}
} catch {
print("History processing failed: \(error)")
}
}
}
This monitor sits at the app level and reacts to every save — local background contexts, CloudKit sync merges, and
extension writes. By filtering on transaction.author, you avoid redundant processing of your own changes.
Warning: Do not create a new
ModelContexton every notification in a tight loop. If CloudKit merges dozens of records in quick succession, each merge triggers a notification. Consider debouncing or coalescing notifications before processing.
Advanced Usage
Filtering History by Model Type
When your schema has many model types but you only care about changes to one or two, you can optimize by filtering
transactions early. Rather than fetching all transactions and then using compactMap, you can inspect the changes
property selectively:
func fetchMovieChangesOnly(
context: ModelContext,
after token: DefaultHistoryToken?
) throws -> [(DefaultHistoryChange<PixarMovie>, Date)] {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token {
descriptor.predicate = #Predicate { transaction in
transaction.token > token
}
}
let transactions = try context.fetchHistory(descriptor)
var results: [(DefaultHistoryChange<PixarMovie>, Date)] = []
for transaction in transactions {
let movieChanges = transaction.changes.compactMap {
$0 as? DefaultHistoryChange<PixarMovie>
}
for change in movieChanges {
results.append((change, transaction.timestamp))
}
}
return results
}
Pairing each change with the transaction’s timestamp is useful for building “recently changed” UI or for conflict
resolution logic where you need to know the chronological order of edits.
History Across App Groups
If your app and a widget extension share the same ModelContainer through an app group, both processes write to the
same SQLite store. Persistent history becomes essential here because the extension may insert data while the main app is
suspended.
let appGroupURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier:
"group.com.pixar.moviecatalog"
)!.appending(path: "MovieCatalog.sqlite")
let config = ModelConfiguration(
url: appGroupURL
)
let container = try ModelContainer(
for: PixarMovie.self,
configurations: config
)
When the main app resumes, it fetches history since its last token and discovers the extension’s inserts. Without history tracking, you would need to refetch every record and diff against your in-memory state — slow and error-prone.
Handling Tombstones for Deleted Objects
Deletes are the trickiest change type because the object is gone by the time you process the transaction. If your UI
holds a reference to a PersistentIdentifier (for example, in a navigation path or selection state), you must
proactively remove it:
case .delete(let modelID):
// Remove from any navigation or selection state
if selectedMovieID == modelID {
selectedMovieID = nil
}
// Remove from any local cache
movieCache.removeValue(forKey: modelID)
Tip: This is one of the strongest arguments for using
PersistentIdentifierin your navigation state rather than holding a direct reference to the model object. Identifiers remain valid for comparison even after the object is deleted; the object reference does not.
Performance Considerations
Persistent history is append-only. Transactions accumulate in the store’s history tables indefinitely unless you purge them. For a high-write app (imagine logging every user interaction with every Pixar short film in the catalog), the history can grow to tens of thousands of transactions within weeks.
Purging Old History
Once every process that reads history has advanced past a given token, those transactions are safe to delete. Use
ModelContext.deleteHistory(_:)
to prune:
func purgeProcessedHistory(
context: ModelContext,
before token: DefaultHistoryToken
) throws {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.predicate = #Predicate { transaction in
transaction.token <= token
}
try context.deleteHistory(descriptor)
}
A good cadence is to purge on app launch, after you have confirmed that all contexts (main app, extensions, background services) have processed up to their respective tokens. If you are aggressive about purging, keep a small buffer — purge transactions older than 7 days, not “everything before the latest token” — to account for extensions that may not have launched recently.
Memory Impact of Large Transaction Sets
fetchHistory loads transactions into memory. If the app has been offline for weeks and CloudKit dumps thousands of
changes on reconnect, fetching the entire backlog at once can spike memory. Process in batches:
func processHistoryInBatches(
context: ModelContext,
after token: DefaultHistoryToken?,
batchSize: Int = 100
) throws -> DefaultHistoryToken? {
var currentToken = token
var processedAny = true
while processedAny {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.fetchLimit = batchSize
if let currentToken {
descriptor.predicate = #Predicate { transaction in
transaction.token > currentToken
}
}
let transactions = try context.fetchHistory(descriptor)
processedAny = !transactions.isEmpty
if let lastToken = try processHistory(
transactions: transactions,
context: context
) {
currentToken = lastToken
}
}
return currentToken
}
Setting fetchLimit on the descriptor caps how many transactions load per pass. This keeps your memory ceiling
predictable regardless of how far behind you are.
Apple Docs:
ModelContext— SwiftData
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| CloudKit sync enabled | Use it. You have no reliable alternative for detecting remote changes. |
| App group shared store | Use it. Multiple processes writing to one store is the textbook case. |
| Single-context, no sync | Skip it. @Query handles reactivity automatically in this scenario. |
| High-frequency writes | Use it, but purge aggressively. History tables grow fast. |
Custom DataStore backend | Not available out of the box. Implement your own history mechanism. |
If you are unsure whether you need persistent history, ask yourself: “Does anything write to this store outside my main
ModelContext?” If yes, you need it.
Summary
- SwiftData persistent history, introduced in iOS 18, gives you native Swift types (
HistoryDescriptor,DefaultHistoryTransaction,DefaultHistoryChange,DefaultHistoryToken) to track every mutation to your store. - Set
transactionAuthoron eachModelContextto distinguish between local changes, background imports, and CloudKit sync operations. - Persist your last-processed
DefaultHistoryTokenso you never reprocess old transactions on relaunch. - Observe
ModelContext.didSavenotifications and fetch history to react to external changes in real time. - Purge old history regularly with
deleteHistory(_:)to prevent unbounded growth of the history tables.
If your app shares a store across processes or syncs with CloudKit, persistent history is not optional — it is infrastructure. For the next step, explore SwiftData + CloudKit Sync to see how these pieces fit together in a multi-device architecture, or dive into SwiftData Class Inheritance to learn how model hierarchies interact with change tracking.