Mastering Data Tracking and Notifications in Core Data and SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Core Data and SwiftData, as powerful persistence frameworks in the Apple ecosystem, not only provide declarative data listening tools like @FetchRequest and @Query, but also have a complete set of data tracking and notification mechanisms built-in. Understanding and mastering these mechanisms is crucial for building robust data-driven applications. This article will take you through multi-layered solutions—from simple custom notifications to the powerful Persistent History Tracking and SwiftData History—to help you handle various complex data synchronization scenarios.

Custom Data Operation Notifications

Before delving into the complex mechanisms provided by the framework, let’s first look at a straightforward method: integrating custom notifications into your data operation code. This approach is simple yet practical. Let’s understand it through the following example:

Swift
extension Notification.Name {
    static let didInsertNewItem = Notification.Name("didInsertNewItem")
    static let willInsertNewItem = Notification.Name("willInsertNewItem")
}

func newItem(date: Date) throws -> PersistentIdentifier {
    // Send notification before creating a new item
    NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
  
    let newItem = Item(timestamp: date)
    modelContext.insert(newItem)
    try modelContext.save()
  
    // Send notification after creating a new item
    NotificationCenter.default.post(name: .didInsertNewItem, object: newItem)
    return newItem.persistentModelID
}

Although this notification mechanism requires developers to build it manually, it is highly flexible. We can customize notifications for specific operations and control the sending conditions. Moreover, this type of notification is not limited to a specific framework or even the same process. Developers can utilize the Darwin Notification Center or other cross-process mechanisms to enable communication between the main app and widgets.

However, this approach has obvious shortcomings. Only data operations that include the notification-sending code will trigger notifications. For instance, when iCloud synchronization is enabled, modifications synced from other devices through the cloud will not trigger this notification mechanism. Additionally, if the same type of data operation is scattered across multiple code locations, any omission in sending notifications will result in incomplete information.

Extending Managed Object Subclasses

In Core Data, extending managed object subclasses (NSManagedObject) allows developers to intervene at specific moments in an object’s lifecycle, such as sending notifications or processing property data.

For example, by overriding the willSave and didSave methods of the Item object (a managed object subclass), we can send notifications before and after the data is persisted:

Swift
extension Item {
    public override func willSave() {
        super.willSave()
        NotificationCenter.default.post(name: .willInsertNewItem, object: nil)
    }
    
    public override func didSave() {
        super.didSave()
        NotificationCenter.default.post(name: .didInsertNewItem, object: self)
    }
}

This is a powerful mechanism provided by Core Data, offering methods for developers to call at multiple stages of a managed object’s lifecycle.

awakeFromInsert

Called when a managed object is inserted into the managed object context. We can perform initialization operations or adjust data here. This method is called only once during the lifecycle of each object instance.

Swift
func newItem() {
    let item = Item(context: context)
    // Date not set
    try? context.save()
}

public override func awakeFromInsert() {
    super.awakeFromInsert()
    // Set the timestamp property to the current date
    self.setPrimitiveValue(Date.now, forKey: "timestamp")
}

awakeFromFetch

Called when a managed object transitions from a fault state to a non-fault state (data is loaded). We typically use this to dynamically provide values for transient properties.

image-20241031110159796

Swift
public override func awakeFromFetch() {
    super.awakeFromFetch()
    // Set the transient property 'visible' to true
    self.setPrimitiveValue(true, forKey: "visible")
}

Transient is a property type that can dirty a managed object instance but will not be persisted. You can read this article to learn more.

willSave

Called when the managed object is about to be persisted. We can adjust property data here.

Swift
public override func willSave() {
    super.willSave()
    let date = primitiveValue(forKey: "timestamp") as? Date
    let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
    // If the date is earlier than startDate, set timestamp to startDate
    if let date, date < startDate {
        setPrimitiveValue(startDate, forKey: "timestamp")
    }
}

Using primitiveValue(forKey:) and setPrimitiveValue(_:forKey:) can avoid triggering unnecessary KVO notifications. Note that setPrimitiveValue(_:forKey:) will bypass some of Core Data’s automatic management mechanisms, so ensure the operation does not affect data consistency.

didSave

Called after the managed object has been persisted. Besides sending a notification of successful save, we can also clean up data here, such as handling files related to the object. In the following code, the file corresponding to the object’s url property will be deleted:

Swift
func deleteFile(_ url: URL) {
   // Delete file    
}

public override func didSave() {
    super.didSave()
    // Check if it's a delete operation; if so, delete the associated file
    if self.isDeleted, let url = self.primitiveValue(forKey: "url") as? URL {
       deleteFile(url)
    }
}

validateForInsert, validateForUpdate, validateForDelete

Validate data before performing Insert, Update, or Delete operations. They are called before willSave. If validation fails, you can throw an error to prevent persistence.

Swift
enum MyError: Error {
  case dateError
}

public override func validateForInsert() throws {
    try super.validateForInsert() // Triggers validation rules set in the model editor
    let startDate = Calendar.current.date(from: DateComponents(year: 2020, month: 3, day: 11))!
    if let timestamp = primitiveValue(forKey: "timestamp") as? Date, timestamp < startDate {
        throw MyError.dateError
    }
}

func addItem(date: Date) {
    let newItem = Item(context: viewContext)
    newItem.timestamp = date

    do {
        try viewContext.save()
    } catch {
        if let error = error as? MyError, error == .dateError {
            print("Date Error")
            viewContext.undo()
        } else {
            print("Unexpected error: \(error)")
            // Other error handling
        }
    }
}

When you set validation rules for properties in the model editor (e.g., maximum length, minimum value), Core Data automatically checks these conditions during insert or update operations. In your custom validateForInsert method, calling super.validateForInsert() will execute these preset validation rules, ensuring compliance with the settings in the model editor.

willTurnIntoFault, didTurnIntoFault

Called when the managed object is about to turn into a fault and has turned into a fault. Generally, it’s not recommended to override these two methods.

prepareForDeletion

Called when the managed object is about to be deleted, typically used for cleaning up resources or manually managing relationships. Note that this method is triggered before validateForDelete. If the deletion operation fails due to validation failure, changes made in this method may be rolled back.

Advantages and Limitations

Extending managed object subclasses provides numerous lifecycle intervention points, allowing developers to control and respond precisely at various stages of an object’s lifecycle. Since this extension targets the type itself, it doesn’t limit specific calling methods. Even if there are different codes in multiple places in the project, as long as they operate on the same type of data, they can achieve consistent timing calls. Moreover, in the current Core Data with CloudKit, data synchronized from the network and persisted by NSPersistentCloudKitContainer will also invoke these methods.

However, because these operations are extensions of managed object subclasses, if you don’t build subclasses or don’t operate through specific types, these methods won’t be called. For example, during batch operations, specific managed object subclasses are usually not involved, so these lifecycle methods won’t be called.

Although SwiftData’s default storage implementation is based on Core Data, it doesn’t declare managed object subclasses for each entity. Therefore, SwiftData doesn’t provide similar functionality—these notification timings are unique to Core Data.

Notifications from Context

In both Core Data and SwiftData, developers perform most data operations within a context (NSManagedObjectContext or ModelContext). The context is a data operation area provided by the persistence framework, responsible for controlling data objects’ lifecycle, managing relationship graphs, tracking, and saving changes.

Beyond these common functionalities, the context is more complex than we might imagine. For example, when we save the same entity data in two persistent stores, the context merges data from two different sources and re-sorts them. In SwiftData versions iOS 18 and above, developers can implement custom stores without any sorting capabilities; sorting and filtering are mechanisms handled by the context.

Due to its significance, the context provides three different notifications to help developers understand data changes.

NSManagedObjectContextObjectsDidChange

This is a notification unique to the Core Data context. It is published when managed objects registered and managed within the context change. In other words, as long as the data is dirty (modified), even if it hasn’t been persisted, this notification will be sent. Operations that don’t dirty the data (e.g., data retrieval) won’t trigger this notification.

Swift
let item = Item(context: viewContext) // Just creation, won't send notification
item.timestamp = Date() // Data changes, marked as dirty; context will send notification

In the userInfo of this notification, it contains sets of inserted, updated, and deleted managed objects.

Swift
.onReceive(
    NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: nil))
{ notification in
    // The object is the context that sent the notification
    let context = notification.object as? NSManagedObjectContext
    if let userInfo = notification.userInfo {
        let insertedObjects = userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
        let updatedObjects = userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
        let deletedObjects = userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? []
    }
}

Since the notification contains the complete data of the managed objects, when we receive this notification in a thread different from the context, we must not directly operate on the properties of the managed objects, as this can cause thread safety issues and may lead to application crashes. The correct approach is to extract the object’s NSManagedObjectID and fetch the object again in the current thread’s context. However, in most cases, we’ll operate on the data after didChange.

Read NSManagedObjectID and PersistentIdentifier: Mastering Data Identifiers in Core Data and SwiftData to learn more about identifiers.

NSManagedObjectContextDidSave and ModelContext.didSave

Notifications published after the context completes a persistence operation. In SwiftData, the corresponding notification is named ModelContext.didSave.

Compared to NSManagedObjectContextObjectsDidChange, developers often use didSave for processing after persistence. In SwiftData, to avoid thread issues caused by improper cross-thread operations on managed object instances in Core Data, the notification only contains the PersistentIdentifier corresponding to the data.

Swift
NotificationCenter.default.publisher(for: ModelContext.didSave)
    .sink(receiveValue: { notification in
        if let userInfo = notification.userInfo {
            let inserted = (userInfo["inserted"] as? [PersistentIdentifier]) ?? []
            let deleted = (userInfo["deleted"] as? [PersistentIdentifier]) ?? []
            let updated = (userInfo["updated"] as? [PersistentIdentifier]) ?? []
        }
    })
    .store(in: &cancellables)

In iOS 18, the invocation timing of mainContext’s automatic save mechanism is unclear, and intervals are usually long. Therefore, developers should explicitly add save code; otherwise, they may not receive the didSave notification for an extended period.

When the automaticallyMergesChangesFromParent property of NSManagedObjectContext is set to true, it’s equivalent to enabling the context’s automatic response mechanism to this notification. The activated context will automatically merge data changes from the notification into the current context. SwiftData has this mechanism enabled by default.

For thread safety issues in persistence frameworks, please read Concurrent Programming in SwiftData and Several Tips on Core Data Concurrency Programming.

NSManagedObjectContextWillSave and ModelContext.willSave

Notifications published when the context is about to perform persistence. In both Core Data and SwiftData, the userInfo in the notification is empty. The only information developers can obtain when responding to this notification is which context is about to perform persistence.

Advantages and Limitations of Context Notifications

By responding to context notifications, developers can centrally handle various types of data operations at a single entry point, simplifying code logic.

However, for operations that don’t go through the context (e.g., batch data operations), this mechanism is ineffective. Additionally, this mechanism can only be used within the same process. In multi-process scenarios (like the main app and widgets), different processes cannot perceive each other’s changes.

Persistent History Tracking and SwiftData History

To overcome the limitations of context notifications, Core Data introduced the Persistent History Tracking (PHT) feature a few years ago. Starting from iOS 18, SwiftData also provides a similar mechanism: SwiftData History.

In simple terms, Persistent History Tracking (PHT) adds a log at the persistence layer (SQLite database), recording each transaction of data operations and the specific operations (add, delete, update) within the transaction. Since these operations occur at the database level, it doesn’t care which code or process performed the operation; it faithfully records everything.

In Core Data, we need to enable this feature as follows (this will add several tables to the current database):

Swift
let desc = container.persistentStoreDescriptions.first!
desc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

If you use Core Data with CloudKit, NSPersistentCloudKitContainer will automatically enable this feature. SwiftData’s database also has this feature enabled by default. For a database that has enabled this feature, a container that hasn’t enabled this option cannot load it.

After enabling the PHT feature in the database, new transactions won’t send notifications by default. Developers need to enable the option to send notifications for this persistent store so that other code can receive changes from this store.

Swift
desc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

Then, we only need to respond to the NSPersistentStoreRemoteChange notification in the Core Data project to know that a persistent store has changed.

Unlike context notifications, PHT or SwiftData History doesn’t include specific change information in the notification. Developers need to retrieve changes that occurred after a specific time from the database through code to obtain this data.

We can set names for contexts or transactions to understand the source of changes when retrieving information, allowing for targeted processing. Whether it’s PHT or SwiftData History, the change information developers get from transactions is represented by data identifiers. Therefore, similar to the context response mechanism, we need to build data filtering mechanisms for specific types and operations.

For a detailed implementation of PHT, I have a dedicated article: Using Persistent History Tracking in CoreData. SwiftData History’s mechanism is very similar to PHT, with slight differences in implementation details.

Compared to the other centralized notification mechanisms mentioned above, PHT or SwiftData requires an additional database retrieval after receiving the notification, so their response efficiency is relatively low. Also, storing these changes occupies more storage space (developers need to regularly clean up through code).

However, because the change information is stored in the database, this notification mechanism has the least limitations. It can be used for batch processing and across different processes. If you can accept a slight delay in response, PHT and SwiftData are the most comprehensive solutions.

Summary

There’s no perfect solution; each mechanism has its advantages and drawbacks.

Notification MechanismFlexibilityLimitationsPlatformsOther Features
Custom NotificationsHighly flexible; developers can freely decide sending conditions and contentOnly triggered through manual code; not suitable for external data (like cloud sync); other processes need similar custom notificationsCore Data, SwiftData, OthersSupports cross-process operations (e.g., Darwin notifications); offers flexible sending and receiving mechanisms
Managed Object Subclass ExtensionsAllows triggering at multiple stages of an object’s lifecycle (e.g., willSave, didSave)Only effective for specific managed object types; doesn’t support batch operations or scenarios without subclassesCore DataProvides precise control; suitable for complex scenarios requiring data state management and notifications before and after persistence
Context NotificationsCan centrally respond to various operation typesOnly applicable within the same process; cannot detect operations from other processesCore Data, SwiftDataProvides detailed information on object insertion, update, deletion; in multi-threaded environments, avoid directly using objects; need to convert NSManagedObjectID; pay attention to thread safety
PHT and SwiftData HistoryDoesn’t concern data source; supports cross-process; covers all data operations (including batch operations and multi-process synchronization)Requires additional database retrieval; impacts performanceCore Data, SwiftDataCan record specific transactions and operation history; a solution for data synchronization in multi-process and batch operations; suitable for complex applications needing local data operation tracking; lower response efficiency; need to regularly clean up history to avoid occupying space

When choosing a notification mechanism, developers should comprehensively consider flexibility, performance, and project requirements. Understanding the advantages and limitations of each mechanism will help implement efficient and reliable data synchronization and state management in applications.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed