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

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 changes array on a transaction contains all model types that changed in that save. Use compactMap to 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: DefaultHistoryToken conforms to Codable, 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 ModelContext on 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 PersistentIdentifier in 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)

ScenarioRecommendation
CloudKit sync enabledUse it. You have no reliable alternative for detecting remote changes.
App group shared storeUse it. Multiple processes writing to one store is the textbook case.
Single-context, no syncSkip it. @Query handles reactivity automatically in this scenario.
High-frequency writesUse it, but purge aggressively. History tables grow fast.
Custom DataStore backendNot 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 transactionAuthor on each ModelContext to distinguish between local changes, background imports, and CloudKit sync operations.
  • Persist your last-processed DefaultHistoryToken so you never reprocess old transactions on relaunch.
  • Observe ModelContext.didSave notifications 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.