A production-ready, thread-safe CoreData singleton for iOS and macOS. Drop in one file, get full CRUD, batch operations, async/await, Combine, and NSFetchedResultsController support — with zero third-party dependencies.
| Minimum | |
|---|---|
| iOS | 13.0+ |
| macOS | 10.15+ |
| Swift | 5.10+ (required for nonisolated(unsafe), SE-0412) — 6.0+ if building in Swift 6 language mode |
| Xcode | 15.3+ — 16+ if building in Swift 6 language mode |
| Dependencies | None |
The core CRUD API itself only ever needed iOS 13 / Swift 5.7. The floor above is higher only because of the Swift 6 concurrency annotations added to the file (
nonisolated(unsafe),@unchecked Sendable,@preconcurrency import,@Sendableclosures). If you strip those out and go back to a plainlazy varsingleton, the original iOS 13 / Swift 5.7 / Xcode 14 floor applies — but you'd be reintroducing thelazy varrace condition described below.
- Copy
CoreDataManager.swiftinto your Xcode project. - Ensure your
.xcdatamodeldfile is added to the target. - Open
CoreDataManager.swiftand update the model name if needed:
private let modelName = "Model" // ← match your .xcdatamodeld filenameThat's it. No SPM package, no CocoaPods, no setup boilerplate.
This file compiles cleanly under Swift 6 language mode. What changed to make that possible:
| Change | Why |
|---|---|
@preconcurrency import CoreData |
Suppresses non-Sendable warnings from CoreData types on SDKs that predate their Sendable annotations. |
final class CoreDataManager: @unchecked Sendable |
The type's state is either immutable after init or delegates thread-safety to NSManagedObjectContext's own serial queue. |
nonisolated(unsafe) static let shared |
static let init is race-free by language guarantee (dispatch-once semantics); paired with @unchecked Sendable above. |
persistentContainer and storeChangedPublisher are eager let, not lazy var |
lazy var initialization is not thread-safe against concurrent first access — this was a real bug independent of Swift 6, which strict concurrency happens to surface. |
@Sendable on closures passed to performBackgroundTask and performAndSaveAsync |
These closures run on the background context's private queue — a genuine isolation-domain crossing that the compiler must verify. |
The one rule that doesn't change with any of this: NSManagedObject is not Sendable and never will be — it's a mutable object tied to the context that created it. Never pass an NSManagedObject across a performBackgroundTask / async boundary. Pass its NSManagedObjectID (which is Sendable) instead, and re-fetch on the other side:
let objectID = user.objectID
manager.performBackgroundTask { ctx in
guard let user = try? ctx.existingObject(with: objectID) as? User else { return }
user.name = "Updated"
}If your project is still on Swift 5 mode, all of the above is inert —
@unchecked Sendableandnonisolated(unsafe)simply have no effect, and the code behaves exactly as before.
let manager = CoreDataManager.shared
// Create
manager.create(entityName: "User") { obj in
obj.setValue(UUID(), forKey: "id")
obj.setValue("Bhargav", forKey: "name")
}
manager.save()
// Read
let users = manager.fetch("User")
// Update
manager.update("User",
predicate: NSPredicate(format: "name == %@", "Bhargav")) { obj in
obj.setValue("Bhargav S.", forKey: "name")
}
// Delete
manager.deleteAll("User",
predicate: NSPredicate(format: "name == %@", "Bhargav S."))CoreDataManager exposes two context tiers:
viewContext — runs on the main thread. Use for all UI reads and light user-driven writes.
newBackgroundContext() — returns a private-queue context. Use for imports, migrations, or any work that would block the UI.
Both contexts share NSMergeByPropertyObjectTrumpMergePolicy and the view context automatically merges changes pushed from background saves.
// Save view context
manager.save() -> Bool
// Save any context
manager.save(context: ctx) -> Bool
// Run block on background context, save automatically, callback on main thread
manager.performBackgroundTask({ ctx in
// do work
}) { success in
print("Saved:", success)
}
// async/await (iOS 15+)
try await manager.saveAsync()
try await manager.performAndSaveAsync { ctx in
// do work
}// Untyped — returns NSManagedObject?
manager.create(entityName: "User") { obj in
obj.setValue("Bhargav", forKey: "name")
}
// Typed generic — returns your NSManagedObject subclass
manager.createObject(of: User.self) { user in
user.name = "Bhargav"
}
// In a specific context
manager.createObject(of: User.self, context: backgroundCtx) { user in
user.name = "Bhargav"
}// Basic fetch
let all = manager.fetch("User")
// With predicate and sort
let sorted = manager.fetch(
"User",
predicate: NSPredicate(format: "age > %d", 18),
sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
)
// Typed fetch
let users: [User] = manager.fetchObjects(of: User.self)
// Fetch by NSManagedObjectID
let obj = manager.fetchObject(withID: someObjectID)
// Count without loading objects
let total = manager.count("User")
let exists = manager.exists("User", predicate: NSPredicate(format: "name == %@", "Bhargav"))
// async/await (iOS 15+)
let users = await manager.fetchAsync(
of: User.self,
predicate: NSPredicate(format: "active == true"),
sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
)// In-memory update (loads objects into context)
manager.update("User",
predicate: NSPredicate(format: "active == false")) { obj in
obj.setValue(true, forKey: "active")
}
// Batch update — store-level, never loads objects (fast for large datasets)
manager.batchUpdate(
"User",
propertiesToUpdate: ["active": true],
predicate: NSPredicate(format: "lastSeen < %@", cutoffDate as CVarArg)
)When to use batch: Updating hundreds or thousands of rows. Changes are written directly to the SQLite store and merged back into the view context automatically.
// Single object
manager.delete(someObject)
// All matching objects
manager.deleteAll("User")
manager.deleteAll("User", predicate: NSPredicate(format: "active == false"))
// Batch delete — store-level, tombstones merged into view context automatically
manager.batchDelete("User")
manager.batchDelete("User", predicate: NSPredicate(format: "createdAt < %@", cutoffDate as CVarArg))Find-or-create in a single call. Inserts if not found, updates if found.
manager.upsert(
"User",
predicate: NSPredicate(format: "id == %@", userId as CVarArg)
) { obj in
obj.setValue(userId, forKey: "id")
obj.setValue("Bhargav", forKey: "name")
}For driving UITableView / UICollectionView:
let frc = manager.makeFetchedResultsController(
entityName: "User",
predicate: NSPredicate(format: "active == true"),
sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)],
sectionNameKeyPath: nil,
cacheName: "UserCache"
)
frc.delegate = self
try? frc.performFetch()Subscribe to any save event (main or background context):
manager.storeChangedPublisher
.sink { [weak self] in
self?.tableView.reloadData()
}
.store(in: &cancellables)Fires on the main thread every time any context is saved.
// Discard all unsaved changes in the view context
manager.resetContext()
// Wipe and rebuild the persistent store (logout / data reset flows)
// ⚠️ Irreversible — all data is permanently deleted
manager.destroyAndRebuildStore()Available only in DEBUG builds (#if DEBUG), stripped from release:
// Print all attribute values of an object
manager.debugPrint(someObject)
// Path to the SQLite file on disk
print(manager.storeFilePath ?? "No store found")| What | Where |
|---|---|
| UI reads and light writes | viewContext (main thread) |
| Imports, bulk writes | performBackgroundTask or performAndSaveAsync |
| Large dataset updates | batchUpdate / batchDelete — safe from any thread |
| Reactive UI updates | storeChangedPublisher |
save(), create(entityName:configure:), createObject(of:configure:), fetch(...), fetchObjects(of:...), fetchObject(withID:), count(...), exists(...), update(...), deleteAll(...), upsert(...), resetContext(), and makeFetchedResultsController(...) are all marked @MainActor, since they run directly on viewContext with no perform wrapper. Calling them from ordinary UI code (a SwiftUI view, a UIViewController) needs no changes — those are @MainActor-isolated by default already. Calling them from a plain background Task or a non-main actor now requires await:
// From a UIViewController / SwiftUI view — no change needed:
let users = manager.fetch("User")
// From a detached background Task — now requires await:
Task.detached {
let users = await manager.fetch("User") // hops to the main actor
}For genuinely background-thread work, use the context:-explicit overloads instead (fetch(_:context:), create(entityName:context:configure:), etc.), which stay nonisolated and are safe to call from any thread you're already confined to (e.g. inside performBackgroundTask), or use batchUpdate/batchDelete/fetchAsync/performAndSaveAsync, which handle the threading internally.
Never pass NSManagedObject instances across contexts. Use NSManagedObjectID instead:
let objectID = user.objectID
manager.performBackgroundTask { ctx in
guard let user = try? ctx.existingObject(with: objectID) as? User else { return }
user.name = "Updated"
}The container has NSPersistentHistoryTrackingKey and NSPersistentStoreRemoteChangeNotificationPostOptionKey enabled. When another writer (an App Extension, another process, or NSPersistentCloudKitContainer) changes the store, NSPersistentStoreCoordinator posts .NSPersistentStoreRemoteChange, which handleRemoteStoreChange(_:) observes.
That notification only carries a store UUID and history token — it does not carry the changed objects, so it can't be merged directly. Instead, handleRemoteStoreChange(_:) fetches everything that happened since the last known token via NSPersistentHistoryChangeRequest, merges each resulting transaction into viewContext, and advances the token. The token itself is persisted in UserDefaults (key: CoreDataManager.lastHistoryToken) so a relaunch picks up exactly where it left off rather than re-merging the entire history.
To opt into CloudKit sync, swap NSPersistentContainer for NSPersistentCloudKitContainer in the persistentContainer property and add the CloudKit capability in Xcode.
CoreDataManager.swift
├── Singleton & configuration
├── NSPersistentContainer setup
├── Context management (viewContext, newBackgroundContext)
├── Combine publisher
├── Save (sync + async + background)
├── Create (untyped + generic)
├── Fetch (predicate, sort, count, exists, async)
├── Update (in-memory + batch)
├── Delete (single, bulk, batch)
├── Upsert
├── NSFetchedResultsController factory
├── Store maintenance (reset, destroy)
├── Remote change notifications
└── Debug helpers (#if DEBUG)
MIT — free to use in personal and commercial projects.