Track SwiftData Changes With History

Most SwiftData fetches tell you what the store looks like right now. That is usually what a list screen needs, but it is not enough when your app needs to answer a slightly different question: what changed since the last time I checked?

That question shows up in practical places. A widget might mark a task complete while the main app is suspended. A background sync might import new records. A badge might need to appear only on items changed by another process. You can try to diff fresh fetch results against some old snapshot, but SwiftData history gives you a better model: fetch chronological transactions from the store, process the changes you care about, then save a token for next time.

SwiftData history is available on iOS 18, iPadOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2. It is available in stores that adopt HistoryProviding, including SwiftData’s DefaultStore. The examples below use the default store, whose history types are DefaultHistoryTransaction, DefaultHistoryToken, DefaultHistoryInsert, DefaultHistoryUpdate, and DefaultHistoryDelete.

The pieces

A history transaction represents a save boundary in the store. It has metadata like timestamp, author, token, and changes. Each change is an insert, update, or delete for a persistent model type.

The token is the bookmark. After you process transactions, save the last transaction’s DefaultHistoryToken. The next time your app wakes up, fetch transactions whose token is greater than the saved token.

Here is a small model for a task app:

import Foundation
import SwiftData

@Model
final class TaskItem {
    @Attribute(.preserveValueOnDeletion)
    var remoteID: String

    var title: String
    var isComplete: Bool
    var updatedAt: Date

    init(remoteID: String, title: String, isComplete: Bool = false) {
        self.remoteID = remoteID
        self.title = title
        self.isComplete = isComplete
        self.updatedAt = .now
    }
}

The preserveValueOnDeletion option matters because a deleted model is gone. If you need a stable server ID, booking reference, or other identity value after deletion, mark that property so SwiftData can keep it in the delete tombstone.

Mark the author

History is more useful when you can tell who made a change. ModelContext has an author property, so set it before saving from app code, widgets, intents, or import jobs.

enum TransactionAuthor {
    static let app = "app"
    static let widget = "widget"
    static let sync = "sync"
}

func completeTaskFromWidget(
    _ task: TaskItem,
    in context: ModelContext
) throws {
    context.author = TransactionAuthor.widget
    task.isComplete = true
    task.updatedAt = .now
    try context.save()
}

You do not have to filter by author, but it is a nice way to avoid responding to your own writes. For example, the main app may only want to show an unread badge for changes made by the widget or sync engine.

Fetch transactions after a token

HistoryDescriptor is the history equivalent of a fetch descriptor. You can give it a predicate and a fetch limit, then ask a ModelContext to fetch matching transactions.

import SwiftData

@available(iOS 18.0, macOS 15.0, *)
struct TaskHistoryReader {
    let container: ModelContainer

    func transactions(
        after token: DefaultHistoryToken?,
        author: String
    ) throws -> [DefaultHistoryTransaction] {
        var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
        descriptor.fetchLimit = 100

        if let token {
            descriptor.predicate = #Predicate { transaction in
                transaction.token > token && transaction.author == author
            }
        } else {
            descriptor.predicate = #Predicate { transaction in
                transaction.author == author
            }
        }

        let context = ModelContext(container)
        return try context.fetchHistory(descriptor)
    }
}

This function gives you the raw transaction stream. It does not assume every transaction is relevant to TaskItem. A single transaction can contain changes for several model types, so the next step is to inspect the changes.

Process only the changes you need

Each HistoryChange exposes the changed persistent identifier. You can switch on the concrete default history change types to handle inserts, updates, and deletes for one model.

@available(iOS 18.0, macOS 15.0, *)
func changedTaskIdentifiers(
    in transactions: [DefaultHistoryTransaction]
) -> (ids: Set<PersistentIdentifier>, token: DefaultHistoryToken?) {
    var ids = Set<PersistentIdentifier>()

    for transaction in transactions {
        for change in transaction.changes {
            switch change {
            case .insert(_ as DefaultHistoryInsert<TaskItem>):
                ids.insert(change.changedPersistentIdentifier)

            case .update(_ as DefaultHistoryUpdate<TaskItem>):
                ids.insert(change.changedPersistentIdentifier)

            case .delete(_ as DefaultHistoryDelete<TaskItem>):
                ids.remove(change.changedPersistentIdentifier)

            default:
                break
            }
        }
    }

    return (ids, transactions.last?.token)
}

That is enough for a badge or refresh queue. If you need the current task values, fetch the models by identifier after finding the relevant changes. If the change is a delete, use the tombstone instead of trying to fetch the deleted model.

@available(iOS 18.0, macOS 15.0, *)
func deletedRemoteIDs(
    in transactions: [DefaultHistoryTransaction]
) -> [String] {
    transactions.flatMap { transaction in
        transaction.changes.compactMap { change in
            guard case .delete(let payload) = change,
                  let deletion = payload as? DefaultHistoryDelete<TaskItem>,
                  let remoteID = deletion.tombstone[\.remoteID] as? String
            else {
                return nil
            }

            return remoteID
        }
    }
}

That small @Attribute(.preserveValueOnDeletion) annotation is what makes remoteID available here.

Store the token

DefaultHistoryToken is Codable, so a simple app can encode it to JSON. If an extension needs to share the same token with the main app, put this in an app group container or shared defaults suite.

@available(iOS 18.0, macOS 15.0, *)
struct HistoryTokenStore {
    private let key = "task-history-token"
    private let defaults: UserDefaults

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
    }

    func load() -> DefaultHistoryToken? {
        guard let data = defaults.data(forKey: key) else {
            return nil
        }

        return try? JSONDecoder().decode(DefaultHistoryToken.self, from: data)
    }

    func save(_ token: DefaultHistoryToken) {
        guard let data = try? JSONEncoder().encode(token) else {
            return
        }

        defaults.set(data, forKey: key)
    }

    func clear() {
        defaults.removeObject(forKey: key)
    }
}

Now the main flow is small: load the token, fetch transactions after it, process them, then save the new token.

@available(iOS 18.0, macOS 15.0, *)
func findTasksChangedByWidget(
    container: ModelContainer,
    tokenStore: HistoryTokenStore
) throws -> Set<PersistentIdentifier> {
    let reader = TaskHistoryReader(container: container)
    let token = tokenStore.load()

    let transactions = try reader.transactions(
        after: token,
        author: TransactionAuthor.widget
    )

    let result = changedTaskIdentifiers(in: transactions)

    if let token = result.token {
        tokenStore.save(token)
    }

    return result.ids
}

In a SwiftUI app, you might call this when the scene becomes active, update a set of unread identifiers, and clear an identifier after the user opens that task.

Clean up old history

History is stored next to your model data, so it uses disk space. Once every consumer has advanced past a token, you can delete older transactions.

@available(iOS 18.0, macOS 15.0, *)
func deleteHistory(
    before token: DefaultHistoryToken,
    in container: ModelContainer
) throws {
    let context = ModelContext(container)
    var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()

    descriptor.predicate = #Predicate { transaction in
        transaction.token < token
    }

    try context.deleteHistory(descriptor)
}

Be careful with this in apps that have multiple consumers. If the app, widget, and sync engine each keep their own token, only delete history that every consumer has already processed. If you try to fetch from a deleted part of the history stream, SwiftData throws a historyTokenExpired error. The practical recovery path is to discard that token, rebuild whatever derived state you need from current data, and start saving a fresh token.

The pattern

The useful pattern is not complicated:

  • Set context.author for writes from different parts of your app.
  • Fetch DefaultHistoryTransaction values with HistoryDescriptor.
  • Process only the model changes your feature cares about.
  • Save the latest DefaultHistoryToken.
  • Preserve deletion identity values with preserveValueOnDeletion.
  • Delete old history only after every consumer is past it.

That gives widgets, sync, badges, and background jobs a shared language for “what changed” without making every feature invent its own diffing system.

Further reading: