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
:
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:
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
:
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
andPersistentIdentifier
.
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:
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:
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:
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
:
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:
// 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
:
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.
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
:
// 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:
// 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.Swiftfunc 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:
@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.
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:
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:
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:
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:
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:
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:
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
:
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:
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.