NSManagedObjectID and PersistentIdentifier: Mastering Data Identifiers in Core Data and SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Core Data and SwiftData are powerful data management frameworks designed by Apple for developers, capable of efficiently handling complex object relationships, hence known as object graph management frameworks. In these two frameworks, NSManagedObjectID and PersistentIdentifier serve similar functions and are both extremely important. This article will delve into their features, usage methods, and important considerations.

What are NSManagedObjectID and PersistentIdentifier?

In Core Data and SwiftData, NSManagedObjectID and PersistentIdentifier act as the “identity cards” for data objects, allowing the system to accurately locate the corresponding records in persistent storage. Their primary function is to assist applications in correctly identifying and managing data objects across different contexts and life cycles.

In Core Data, the objectID property of a managed object can be used to obtain its corresponding NSManagedObjectID:

Swift
let item = Item(context: viewContext)
item.timestamp = Date.now
try? context.save()
let id = item.objectID // NSManagedObjectID

In SwiftData, the corresponding PersistentIdentifier can be obtained through the data object’s id or persistentModelID property:

Swift
let item = Item(timestamp: Date.now)
modelContext.insert(item)
try? modelContext.save()
let id = item.persistentModelID // PersistentIdentifier

It is worth noting that in Core Data, the default id property of an NSManagedObject is actually an ObjectIdentifier, not an NSManagedObjectID. If necessary, this property can be redeclared through an extension to modify it to NSManagedObjectID:

Swift
public extension NSManagedObject {
  var id: NSManagedObjectID { objectID }
}

For simplicity in the following text, unless a specific distinction is necessary, I will use “identifier” as a collective term for NSManagedObjectID and PersistentIdentifier.

Temporary IDs and Permanent IDs

In Core Data and SwiftData, when a data object is newly created and not yet persisted, its identifier is in a temporary state. Temporary identifiers cannot be used across contexts, meaning they cannot retrieve corresponding data in another context.

In Core Data, NSManagedObjectID provides an isTemporaryID property to determine if an identifier is temporary:

Swift
let item = Item(context: viewContext)
item.timestamp = Date.now
// Data not saved
print(item.objectID.isTemporaryID) // true

However, in SwiftData, there is currently no similar property or method to directly determine the state of a PersistentIdentifier. Since SwiftData’s mainContext defaults to the autoSave feature (developers do not need to explicitly save data), identifiers may temporarily be unusable in other contexts after creating data objects. If this situation occurs, it can be avoided by manually saving explicitly.

The Relationship Between Identifiers and Persistent Data

A permanent ID (i.e., a persisted identifier) contains sufficient information for the framework to locate the corresponding data in the database. When we print a permanent ID, we can see its detailed contents:

Swift
print(item.objectID)
// 0xa264a2b105e2aeb2 <x-coredata://92940A15-4E32-4F7A-9DC7-E5A5AB22D81E/Item/p28>
  • x-coredata: A custom URI protocol used by Core Data, indicating that this is a Core Data URI.
  • 92940A15-4E32-4F7A-9DC7-E5A5AB22D81E: The unique identifier of a persistent store, typically a UUID, is used to identify the location of the store file (e.g., the corresponding database file). This identifier is generated when the database file is created and usually does not change unless the store’s metadata is manually modified (though such operations are extremely rare and not recommended in actual projects).
  • Item: The entity name corresponding to the data object, which corresponds to the table in SQLite that stores the data of that entity.
  • p28: Indicates the specific location of the data in that entity table. After the object is saved, Core Data generates a unique identifier for it.

For more on the data-saving mechanism, please refer to How Core Data Saves Data in SQLite.

For temporary IDs, an unsaved object will lack an identifier in the table, as shown below:

Swift
item.objectID.isTemporaryID // true, temporary ID
print(item.objectID)
// x-coredata:///Item/t6E5D1507-3E60-41F0-A5F7-C1F28DC63F402

SwiftData’s default implementation is still based on Core Data, so the format of PersistentIdentifier is very similar to that of NSManagedObjectID:

Swift
print(item.persistentModelID)
// SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://A07B3AB6-F28D-4F15-9B5D-9B12EB052BC6/Item/p1), implementation: SwiftData.PersistentIdentifierImplementation)

When a PersistentIdentifier is in a temporary state, it similarly lacks an identifier in the table:

Swift
// PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-swiftdata://Item/3BD56EA6-831B-4B24-9B2E-B201B922C91D), implementation: SwiftData.PersistentIdentifierImplementation)

Using the persistent storage ID + table name + identifier, one can uniquely locate the data corresponding to a particular permanent ID. Any change in any part will point to different data. Therefore, both NSManagedObjectID and PersistentIdentifier can only be used on the same device and cannot recognize data across devices.

Identifiers are Sendable

In both Core Data and SwiftData, data objects can only be used within specific contexts (threads); otherwise, concurrency issues are likely to occur, potentially leading to application crashes. Therefore, when passing data between different contexts, only their identifiers can be used.

PersistentIdentifier is a struct, inherently thread-safe, and is marked as Sendable:

Swift
public struct PersistentIdentifier : Hashable, Identifiable, Equatable, Comparable, Codable, Sendable

NSManagedObjectID, as a subclass of NSObject, did not have explicit thread safety annotations initially.

During Ask Apple 2022, Apple engineers confirmed it is thread-safe, and developers could use the @unchecked Sendable annotation. Starting with Xcode 16, the Core Data framework has officially annotated this, so developers no longer need to do it manually.

Swift
open class NSManagedObjectID : NSObject, NSCopying, @unchecked Sendable

Thus, identifiers in Core Data and SwiftData are crucial for ensuring safe concurrent operations.

Please read Concurrent Programming in SwiftData and Several Tips on Core Data Concurrency Programming to learn more about concurrency operations.

How to Retrieve Data Using Identifiers

Methods to retrieve data can be broadly divided into two categories: predicate-based queries and direct methods provided by context or ModelActor.

Predicate-Based

In Core Data, data can be retrieved by constructing predicates based on NSManagedObjectID:

Swift
// Retrieving by a single ID
let id = item.objectID
let predicate = NSPredicate(format: "SELF == %@", id)

// Retrieving in bulk
let ids = [item1.objectID, item2.objectID, item3.objectID]
let predicate = NSPredicate(format: "SELF IN %@", ids)

In SwiftData, predicates can be constructed in a similar manner:

Swift
// Retrieving by a single ID
let id = item.persistentModelID
let predicate = #Predicate<Item> {
  $0.persistentModelID == id
}

// Retrieving in bulk
let ids = [item1.persistentModelID, item2.persistentModelID]
let predicate = #Predicate<Item> { item in
    ids.contains(item.persistentModelID)
}

It’s important to note that in SwiftData, although the PersistentModel’s id property is also a PersistentIdentifier, only persistentModelID can be used for retrieval in predicates.

Retrieving a Single Data Entry Using Context

Core Data’s NSManagedObjectContext offers three different methods to retrieve data by NSManagedObjectID, differentiated as follows:

  • existingObject(with:)

    This method returns the specified object if it is already present in the context; otherwise, it retrieves and returns a fully instantiated object from the persistent store. Unlike object(with:), it does not return an uninitialized object. If the object is neither in the context nor in the store, an error is thrown. In other words, as long as the data exists, this method guarantees a fully initialized object.

    Swift
    func getItem(id: NSManagedObjectID) -> Item? {
      guard let item = try? viewContext.existingObject(with: id) as? Item else { return nil }
      return item
    }
  • registeredModel(for:)

    This method only returns objects that are registered in the current context (with the same identifier). If the object cannot be found, it returns nil, but this does not mean the data does not exist in the store, just that it is not registered in the current context.

  • object(with:)

    Even if an object is not registered, object(with:) still returns a placeholder object. When accessing this placeholder, the context attempts to load the data from the store. If the data does not exist, it could lead to a crash.

In SwiftData, similar methods are available, where model(for:) corresponds to object(with:), registeredModel(for:) has the same functionality, and existingObject(with:) is implemented through the subscript method of an actor instance built with the @ModelActor macro:

Swift
@ModelActor
actor DataHandler {
  func getItem(id: PersistentIdentifier) -> Item? {
    return self[id, as: Item.self]
  }
}

For more information on implementing @ModelActor and subscript methods in Core Data, refer to Core Data Reform: Achieving Elegant Concurrency Operations like SwiftData.

NSManagedObjectID Instances Are Only Valid Within the Same Coordinator

Although an NSManagedObjectID instance contains sufficient information to indicate the data post-persistence, it cannot retrieve the corresponding data when used with another NSPersistentStoreCoordinator instance, even if the same database file is used. In other words, an NSManagedObjectID instance cannot be used across coordinators.

This is because the NSManagedObjectID instance also includes private properties of the corresponding NSPersistentStore instance. The NSPersistentStoreCoordinator may rely on these private properties to retrieve data, rather than solely using the identifier of the persistent storage to locate it.

How to Persist Identifiers

To safely use identifiers across coordinators and achieve their persistence, you can generate a URI that only contains the “persistent storage ID + table name + identifier number” using the uriRepresentation method of NSManagedObjectID. The persisted URL can be used across coordinators, and even after an application cold start, it can restore the correct data using this URL.

Swift
let url = item.objectID.uriRepresentation()

For PersistentIdentifier, since it follows the Codable protocol, the most convenient way to persist it is to encode and save it:

Swift
let id = item.persistentModelID
let data = try! JSONEncoder().encode(id)

Persisting identifiers is useful in multiple scenarios. For example, when a user selects certain data, persisting that data’s identifier can restore the application to the state at exit upon a cold start. Or, when using the Core Spotlight framework, identifiers can be added to CSSearchableItemAttributeSet so that users can directly navigate to the corresponding data view after finding data through Spotlight.

For more information, refer to Showcasing Core Data in Applications with Spotlight.

How to Create Identifiers

In Core Data, although NSManagedObjectID does not have a public constructor, we can generate a corresponding NSManagedObjectID instance through a valid URL:

Swift
let container = persistenceController.container
if let objectID = container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: uri) {
  let item = getItem(id: objectID)
}

In iOS 18, Core Data introduced a new method that allows building an identifier directly from a string:

Swift
let id = coordinator.managedObjectID(for: "x-coredata://92940A15-4E32-4F7A-9DC7-E5A5AB22D81E/Item/p29")!
let item = try! viewContext.existingObject(with: id) as! Item

In SwiftData, the capabilities provided by the Codable protocol can be utilized to create a PersistentIdentifier through encoding data:

Swift
let id = item.persistentModelID
let data = try! JSONEncoder().encode(id) // Persisting ID

// Creating PersistentIdentifier from encoded data
func getItem(_ data: Data) -> Item? {
  let id = try! JSONDecoder().decode(PersistentIdentifier.self, from: data)
  return self[id, as: Item.self]
}

iOS 18 also introduced another method to construct PersistentIdentifier in SwiftData, demonstrating the components of an identifier:

Swift
let id = try! PersistentIdentifier.identifier(for: "A07B3AB6-F28D-4F15-9B5D-9B12EB052BC6", entityName: "Item", primaryKey: "p1")
print(id)

// PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-developer-provided://A07B3AB6-F28D-4F15-9B5D-9B12EB052BC6/Item/p1), implementation: SwiftData.GenericPersistentIdentifierImplementation<Swift.String>)

However, this method does not generate identifiers suitable for default storage (Core Data) and is mainly used for custom storage implementations. We can use this approach to create a method for constructing identifiers that support default storage:

Swift
struct PersistentIdentifierJSON: Codable {
  struct Implementation: Codable {
    var primaryKey: String
    var uriRepresentation: URL
    var isTemporary: Bool
    var storeIdentifier: String
    var entityName: String
  }

  var implementation: Implementation
}

extension PersistentIdentifier {
  public static func customIdentifier(for storeIdentifier: String, entityName: String, primaryKey: String) throws
    -> PersistentIdentifier
  {
    let uriRepresentation = URL(string: "x-coredata://\(storeIdentifier)/\(entityName)/\(primaryKey)")!
    let json = PersistentIdentifierJSON(
      implementation: .init(
        primaryKey: primaryKey,
        uriRepresentation: uriRepresentation,
        isTemporary: false,
        storeIdentifier: storeIdentifier,
        entityName: entityName)
    )
    let encoder = JSONEncoder()
    let data = try encoder.encode(json)
    let decoder = JSONDecoder()
    return try decoder.decode(PersistentIdentifier.self, from: data)
  }
}

let id = try! PersistentIdentifier.customIdentifier(for: "A07B3AB6-F28D-4F15-9B5D-9B12EB052BC6", entityName: "Item", primaryKey: "p1")
print(id)

// PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-coredata://A07B3AB6-F28D-4F15-9B5D-9B12EB052BC6/Item/p1), implementation: SwiftData.PersistentIdentifierImplementation)

This method easily allows building identifiers suitable for SwiftData based on the information contained in the URL provided by NSManagedObjectID, while also reducing the space taken up by PersistentIdentifier persistence.

Why Persistent Identifiers Become Invalid

A persistent identifier is primarily composed of: Persistent Store ID + Entity Name + Identifier. The following scenarios are the most common causes for a persistent identifier to become invalid:

  • Data has been deleted
  • The persistent store’s identifier has been modified (e.g., by changing its metadata)
  • The persistent store file was not migrated using the Coordinator’s migration method, but instead was recreated and the data was copied manually
  • Data underwent a non-lightweight migration, causing the corresponding identifier (i.e., the PK value) to change

To better ensure that data is correctly matched in scenarios such as selected data or Spotlight, developers can add a custom identifier property, such as a UUID.

Retrieving Identifiers to Reduce Memory Usage

In some scenarios, developers do not need immediate access to all retrieved data, or they may only need to use a small part of it. In such cases, opting to retrieve only the identifiers that meet search criteria can greatly reduce memory usage.

In Core Data, this can be achieved by setting the resultType to managedObjectIDResultType:

Swift
let request = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
request.predicate = NSPredicate(format: "timestamp >= %@", Date() as CVarArg)
request.resultType = .managedObjectIDResultType
let ids = try? viewContext.fetch(request) ?? []

SwiftData provides a fetchIdentifiers method for ModelContext to directly retrieve identifiers:

Swift
func getIDS(_ ids: [PersistentIdentifier]) throws -> [PersistentIdentifier] {
  let now = Date()
  let predicate = #Predicate<Item> {
    $0.timestamp > now
  }
  let request = FetchDescriptor(predicate: predicate)
  let ids = try modelContext.fetchIdentifiers(request)
  return ids
}

By using this approach, developers can flexibly obtain the necessary data while reducing memory consumption.

Conclusion

NSManagedObjectID and PersistentIdentifier are core concepts and tools in Core Data and SwiftData. A deep understanding and mastery of their use not only help developers better comprehend these frameworks but also effectively enhance the concurrency safety and performance of their code.

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