Chapter 7: Updating and Deleting Data
- How Core Data tracks modifications within the
NSManagedObjectContextand the memory implications of updates. - Concurrency and Merge Policies: Handling conflicts when two threads update the same record.
- Safely modifying existing records using the Repository pattern.
- High-performance, memory-efficient bulk updates using
NSBatchUpdateRequest. - Building a SwiftUI Edit View that uses temporary state before committing changes.
- Intercepting deletions with
prepareForDeletion()to clean up external assets. - Implementing intuitive swipe-to-delete functionality in SwiftUI
Lists, complete with error handling. - Navigating Core Data's relational Delete Rules (Nullify, Cascade, Deny) and validation errors.
- Advanced techniques like
NSBatchDeleteRequestfor clearing data without freezing the UI.
The Mechanics of Modification
The Context as a Transactional Scratchpad
Object added to updatedObjects
context.hasChanges = true Repo->>MOC: context.save() MOC->>SQLite: BEGIN TRANSACTION MOC->>SQLite: UPDATE ZEXPENSE SET ZAMOUNT = 15.0 ... MOC->>SQLite: COMMIT TRANSACTION SQLite-->>MOC: Acknowledge Save MOC-->>Repo: Save Successful Repo-->>VM: Success VM-->>App: Dismiss Edit View
Faulting and Memory Implications
The MVVM Conundrum: Avoiding Direct Object Binding
- Thread Confinement: Core Data objects (
NSManagedObject) and their contexts are strictly bound to the queue they were created on. Passing anNSManagedObjectfetched on a background context directly to a SwiftUI view (which operates on the main thread) is a recipe for disaster. Accessing a property from the wrong thread causes unpredictable data corruption and immediate, hard-to-trace crashes. - Separation of Concerns: Your UI layer should be entirely decoupled from your persistence framework. Views should rely on plain, simple Swift structs. If you ever migrate away from Core Data to SwiftData, Realm, or a remote API, your views shouldn't need a rewrite.
- Cancellation and Rollbacks: If a user is editing an expense and hits "Cancel", rolling back changes on a live
NSManagedObjectis tedious. You would need to callcontext.refresh(object, mergeChanges: false)orcontext.rollback(), which might inadvertently revert other unrelated changes sitting in the context scratchpad.
Updating Data: The Repository Pattern
Resolving Conflicts: Merge Policies
import CoreData
class CoreDataStack {
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "ExpenseModel")
// Example of configuring the viewContext during setup
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
NSMergeByPropertyObjectTrumpMergePolicy: In-memory changes trump database changes (UI wins).NSMergeByPropertyStoreTrumpMergePolicy: Database changes trump in-memory changes (Disk wins).NSErrorMergePolicy: Default. Throws an error.NSOverwriteMergePolicy: Overwrites all properties, regardless of which ones changed.
Defining the Update Signature in the Repository
import CoreData
import Foundation
@objc(ExpenseEntity)
public class ExpenseEntity: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var amount: Double
@NSManaged public var timestamp: Date?
@NSManaged public var receiptFilePath: String?
}
enum CoreDataError: Error, LocalizedError {
case objectNotFound
case saveFailed(Error)
var errorDescription: String? {
switch self {
case .objectNotFound:
return "The requested record could not be found. It may have been deleted."
case .saveFailed(let error):
return "Failed to save changes: \(error.localizedDescription)"
}
}
}
class ExpenseRepository {
private let container: NSPersistentContainer
// The main context for UI operations
private var viewContext: NSManagedObjectContext {
return container.viewContext
}
init(container: NSPersistentContainer) {
self.container = container
// Ensure UI updates win in a conflict
self.container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
/// Updates an existing expense in Core Data safely.
/// - Parameters:
/// - id: The NSManagedObjectID of the expense to update.
/// - title: The new title (optional, pass nil to leave unchanged).
/// - amount: The new amount (optional).
/// - date: The new date (optional).
func updateExpense(id: NSManagedObjectID, title: String?, amount: Double?, date: Date?) throws {
// 1. We must declare an error variable outside the performAndWait block
// to catch throws from within the closure.
var outError: Error?
// 2. Ensure we are operating on the correct queue for the viewContext
viewContext.performAndWait {
do {
// 3. Safely fetch the existing object
let expense = try viewContext.existingObject(with: id) as! ExpenseEntity
// 4. Apply updates if provided
if let title = title {
expense.title = title
}
if let amount = amount {
expense.amount = amount
}
if let date = date {
expense.timestamp = date
}
// 5. Save the context if there are actual changes
if viewContext.hasChanges {
try viewContext.save()
print("Successfully updated expense.")
}
} catch let error as NSError where error.code == NSManagedObjectReferentialIntegrityError {
// Specific handling if the object was deleted elsewhere
outError = CoreDataError.objectNotFound
} catch {
outError = CoreDataError.saveFailed(error)
}
}
// 6. Rethrow any caught errors to the caller
if let error = outError {
throw error
}
}
}
object(with:) vs existingObject(with:)
context.object(with: id): This method always returns an object immediately, even if the object doesn't actually exist in the database anymore. It returns a "fault." It assumes you know what you are doing. If you try to access properties on a non-existent fault, Core Data attempts to fetch the missing data, fails, and your app crashes instantly with anNSObjectInaccessibleException.context.existingObject(with: id): This method blocks and checks the SQLite store to guarantee that the object exists. If it exists, it returns it. If it doesn't, it safely throws an error that you can catch.
[!CAUTION] For updates and deletes tied to user actions, always use
existingObject(with:). A user might tap "Edit" on a record just as a background sync process deletes it.existingObjectprevents a crash in this exact race condition.
High-Performance Updates: NSBatchUpdateRequest
import CoreData
import Foundation
@objc(ExpenseEntity)
public class ExpenseEntity: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var amount: Double
@NSManaged public var timestamp: Date?
@NSManaged public var receiptFilePath: String?
}
class ExpenseRepository {
private let container: NSPersistentContainer
var viewContext: NSManagedObjectContext {
return container.viewContext
}
init(container: NSPersistentContainer) {
self.container = container
}
func applyTaxToAllExpenses(taxMultiplier: Double) throws {
// 1. Create a batch update request for the Entity
let batchUpdate = NSBatchUpdateRequest(entityName: "ExpenseEntity")
// 2. Define the properties to update. We use NSExpression to multiply the existing value.
batchUpdate.propertiesToUpdate = [
"amount": NSExpression(forFunction: "multiply:by:", arguments: [
NSExpression(forKeyPath: "amount"),
NSExpression(forConstantValue: taxMultiplier)
])
]
// 3. Request that Core Data returns the Object IDs of the updated rows.
batchUpdate.resultType = .updatedObjectIDsResultType
// 4. Execute on a background context
let backgroundContext = container.newBackgroundContext()
var outError: Error?
backgroundContext.performAndWait {
do {
let result = try backgroundContext.execute(batchUpdate) as? NSBatchUpdateResult
guard let objectIDs = result?.result as? [NSManagedObjectID] else { return }
// 5. Merge changes into the view context so the UI refreshes!
let changes = [NSUpdatedObjectsKey: objectIDs]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [self.viewContext]
)
} catch {
outError = error
}
}
if let error = outError { throw error }
}
}
[!IMPORTANT] Because batch requests bypass the context, validation rules (like min/max values defined in the data model) are ignored. You must ensure your update logic does not violate your own data integrity rules. Additionally, always remember to call
NSManagedObjectContext.mergeChangesto alert the UI contexts that the underlying database has changed!
Building the Edit View in SwiftUI
The View Model
import Observation
import CoreData
import Foundation
struct ExpenseModel: Identifiable {
let id: NSManagedObjectID
let title: String
let amount: Double
let date: Date
}
class ExpenseRepository {
func updateExpense(id: NSManagedObjectID, title: String?, amount: Double?, date: Date?) throws {
// Implementation omitted for brevity
}
}
@Observable class EditExpenseViewModel {
private let repository: ExpenseRepository
let expenseId: NSManagedObjectID
// Temporary state for the UI to bind to
var title: String
var amount: String
var date: Date
// Error handling state
var showErrorAlert: Bool = false
var errorMessage: String = ""
init(repository: ExpenseRepository, expenseModel: ExpenseModel) {
self.repository = repository
self.expenseId = expenseModel.id
self.title = expenseModel.title
// Convert Double back to String for the TextField editing
self.amount = String(format: "%.2f", expenseModel.amount)
self.date = expenseModel.date
}
func saveChanges(completion: @escaping (Bool) -> Void) {
// Validate input in the ViewModel
guard let amountValue = Double(amount), !title.trimmingCharacters(in: .whitespaces).isEmpty else {
self.errorMessage = "Please enter a valid title and numeric amount."
self.showErrorAlert = true
completion(false)
return
}
do {
// Forward validated data to repository
try repository.updateExpense(
id: expenseId,
title: title,
amount: amountValue,
date: date
)
completion(true)
} catch {
self.errorMessage = error.localizedDescription
self.showErrorAlert = true
completion(false)
}
}
}
The SwiftUI View
import SwiftUI
import Observation
import CoreData
struct ExpenseModel: Identifiable {
let id: NSManagedObjectID
let title: String
let amount: Double
let date: Date
}
class ExpenseRepository {
func updateExpense(id: NSManagedObjectID, title: String?, amount: Double?, date: Date?) throws { }
}
@Observable class EditExpenseViewModel {
private let repository: ExpenseRepository
let expenseId: NSManagedObjectID
var title: String
var amount: String
var date: Date
var showErrorAlert: Bool = false
var errorMessage: String = ""
init(repository: ExpenseRepository, expenseModel: ExpenseModel) {
self.repository = repository
self.expenseId = expenseModel.id
self.title = expenseModel.title
self.amount = String(format: "%.2f", expenseModel.amount)
self.date = expenseModel.date
}
func saveChanges(completion: @escaping (Bool) -> Void) {
completion(true)
}
}
struct EditExpenseView: View {
@State var viewModel: EditExpenseViewModel
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
Form {
Section(header: Text("Expense Details")) {
TextField("Title", text: $viewModel.title)
TextField("Amount", text: $viewModel.amount)
.keyboardType(.decimalPad)
DatePicker("Date", selection: $viewModel.date, displayedComponents: .date)
}
}
.navigationTitle("Edit Expense")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
viewModel.saveChanges { success in
if success {
dismiss()
}
}
}
// Disable save if the form is in an obviously invalid state
.disabled(viewModel.title.isEmpty || viewModel.amount.isEmpty)
}
}
.alert(isPresented: $viewModel.showErrorAlert) {
Alert(
title: Text("Update Failed"),
message: Text(viewModel.errorMessage),
dismissButton: .default(Text("OK"))
)
}
}
}
}
The Reactive Update Loop
Deleting Data: A Two-Step Process
Intercepting Deletions: prepareForDeletion()
// ExpenseEntity+CoreDataClass.swift
import CoreData
import Foundation
@objc(ExpenseEntity)
public class ExpenseEntity: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var amount: Double
@NSManaged public var timestamp: Date?
@NSManaged public var receiptFilePath: String?
public override func prepareForDeletion() {
super.prepareForDeletion()
// Clean up external resources before the object is permanently destroyed
if let receiptPath = self.receiptFilePath {
let fileManager = FileManager.default
let url = URL(fileURLWithPath: receiptPath)
do {
try fileManager.removeItem(at: url)
print("Successfully deleted attached receipt file.")
} catch {
print("Failed to delete receipt file: \(error)")
}
}
}
}
[!NOTE]
prepareForDeletion()is called by the context when you callcontext.delete(object). At this point, the object is still alive, and its properties are still accessible, making it the perfect place for cleanup logic.
The Repository Pattern for Deletions
import CoreData
import Foundation
@objc(ExpenseEntity)
public class ExpenseEntity: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var amount: Double
@NSManaged public var timestamp: Date?
@NSManaged public var receiptFilePath: String?
}
enum CoreDataError: Error, LocalizedError {
case objectNotFound
case saveFailed(Error)
}
class ExpenseRepository {
private let container: NSPersistentContainer
private var viewContext: NSManagedObjectContext {
return container.viewContext
}
init(container: NSPersistentContainer) {
self.container = container
}
/// Deletes an expense from Core Data.
/// - Parameter id: The NSManagedObjectID of the expense to delete.
func deleteExpense(id: NSManagedObjectID) throws {
var outError: Error?
viewContext.performAndWait {
do {
// Safely fetch the existing object using existingObject(with:)
let expense = try viewContext.existingObject(with: id)
// Mark the object for deletion. This triggers prepareForDeletion()
viewContext.delete(expense)
// Commit the deletion to SQLite
if viewContext.hasChanges {
try viewContext.save()
print("Successfully deleted expense.")
}
} catch {
outError = CoreDataError.saveFailed(error)
}
}
if let error = outError { throw error }
}
}
Implementing Swipe-to-Delete in SwiftUI
Step 1: Handling the Deletion in the List ViewModel
import Observation
import CoreData
import Foundation
struct ExpenseModel: Identifiable {
let id: NSManagedObjectID
let title: String
let amount: Double
let date: Date
}
class ExpenseRepository {
func deleteExpense(id: NSManagedObjectID) throws { }
}
@Observable class ExpenseListViewModel: NSObject {
var expenses: [ExpenseModel] = []
// UI Error state for deletions
var showDeleteError: Bool = false
var deleteErrorMessage: String = ""
private let repository: ExpenseRepository
// ... FRC setup code from previous chapters ...
init(repository: ExpenseRepository) {
self.repository = repository
}
func deleteExpenses(at offsets: IndexSet) {
// Map the offsets to the models safely
let expensesToDelete = offsets.map { expenses[$0] }
for expenseModel in expensesToDelete {
do {
try repository.deleteExpense(id: expenseModel.id)
} catch {
// Surface the error to the UI
self.deleteErrorMessage = "Could not delete '\(expenseModel.title)': \(error.localizedDescription)"
self.showDeleteError = true
}
}
}
}
Step 2: Attaching to the SwiftUI List
[!WARNING] The
.onDeletemodifier must be attached to theForEachview, not theListview itself. Attaching it to the List will not compile or provide the swipe gesture.
import SwiftUI
import CoreData
import Observation
struct ExpenseModel: Identifiable {
let id: NSManagedObjectID
let title: String
let amount: Double
let date: Date
}
struct ExpenseRowView: View {
let expense: ExpenseModel
var body: some View {
Text(expense.title)
}
}
@Observable class ExpenseListViewModel: NSObject {
var expenses: [ExpenseModel] = []
var showDeleteError: Bool = false
var deleteErrorMessage: String = ""
func deleteExpenses(at offsets: IndexSet) { }
}
struct ExpenseListView: View {
@State var viewModel: ExpenseListViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.expenses) { expense in
ExpenseRowView(expense: expense)
}
.onDelete(perform: viewModel.deleteExpenses)
}
.navigationTitle("My Expenses")
.toolbar {
// Adds an Edit button to the navigation bar for multi-select deletion
EditButton()
}
.alert(isPresented: $viewModel.showDeleteError) {
Alert(
title: Text("Deletion Error"),
message: Text(viewModel.deleteErrorMessage),
dismissButton: .default(Text("OK"))
)
}
}
}
}
Core Data Relationships: Delete Rules
1. Nullify (The Default)
- Example: If you delete the "Food" Category, all Expenses that were categorized as "Food" will have their
categoryrelationship set tonil. They become "Uncategorized." - Use Case: Best when child objects can exist independently of their parents. For our Expense Tracker, Nullify is likely the best choice for the
Category -> Expensesrelationship. Users shouldn't lose their financial history just because they reorganized categories!
2. Cascade
- Example: If you delete the "Food" Category, every single Expense tied to "Food" is permanently deleted from the database.
- Performance Impact: Core Data must load every single destination object into memory (firing their faults) to call
prepareForDeletion()and trigger potential further cascading deletes. Cascading a deletion with thousands of children can cause severe UI stutter. - Use Case: Best for strict parent-child dependencies (e.g., deleting a "Document" should delete all its "Pages").
3. Deny
- Example: If "Food" has at least one Expense, attempting to delete "Food" will fail. You must delete or re-categorize all associated Expenses first.
- Use Case: Best for preventing accidental data loss when strict data integrity is required.
import CoreData
import Foundation
class CoreDataStack {
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "ExpenseModel")
}
func save() {
let viewContext = container.viewContext
// Example of catching a Deny error
do {
try viewContext.save()
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain && error.code == NSValidationRelationshipDeniedError {
print("Cannot delete category because it still contains expenses!")
}
}
}
}
4. No Action
- Use Case: Extremely rare. Avoid unless you are managing inverse relationships entirely manually for extreme, low-level performance optimizations.
DELETED"] -.->|"category = nil"| B1("Expense: Pizza") A1 -.->|"category = nil"| C1("Expense: Burger") end subgraph CascadeExample___Cascade_Example__ ["CascadeExample ["Cascade Example"]"] A2["Category: Food
DELETED"] ==>|"DELETE FIRED"| B2("Expense: Pizza
DELETED") A2 ==>|"DELETE FIRED"| C2("Expense: Burger
DELETED") end
High-Performance Deletions: NSBatchDeleteRequest
Implementing a Batch Delete
import CoreData
import Foundation
@objc(ExpenseEntity)
public class ExpenseEntity: NSManagedObject {
@NSManaged public var title: String?
@NSManaged public var amount: Double
@NSManaged public var timestamp: Date?
@NSManaged public var receiptFilePath: String?
}
class ExpenseRepository {
private let container: NSPersistentContainer
private var viewContext: NSManagedObjectContext {
return container.viewContext
}
init(container: NSPersistentContainer) {
self.container = container
}
/// Instantly deletes all expenses from the database without loading them into memory.
func deleteAllExpenses() throws {
// 1. Create a fetch request for the entities you want to delete.
let fetchRequest: NSFetchRequest = ExpenseEntity.fetchRequest()
// Optional: Add a predicate to only delete specific records!
// fetchRequest.predicate = NSPredicate(format: "amount < 5.0")
// 2. Create the batch delete request
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
// 3. Configure the request to return the IDs of the deleted objects.
// We absolutely need these IDs to clean up our in-memory contexts.
batchDeleteRequest.resultType = .resultTypeObjectIDs
// 4. Execute the request on a background context
let backgroundContext = container.newBackgroundContext()
var outError: Error?
backgroundContext.performAndWait {
do {
let result = try backgroundContext.execute(batchDeleteRequest) as? NSBatchDeleteResult
let deletedObjectIDs = result?.result as? [NSManagedObjectID] ?? []
// 5. Merge the changes back into the view context
// Since the deletion bypassed the viewContext, the viewContext
// still thinks these objects exist. We must explicitly tell it they are gone.
let changes = [NSDeletedObjectsKey: deletedObjectIDs]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [self.viewContext]
)
print("Successfully batch deleted \(deletedObjectIDs.count) expenses.")
} catch {
outError = error
}
}
if let error = outError { throw error }
}
}
[!CAUTION] Batch Deletes bypass Core Data entirely at the object level. This means:
prepareForDeletion()is never called. Any external files (like receipt images) will be orphaned.- Delete Rules are completely ignored. If you do a batch delete on Categories, it will NOT Cascade or Nullify the relationships on Expenses. Your SQLite database could end up in an invalid state with foreign keys pointing nowhere. You must write secondary batch requests to manage relational integrity manually when using this tool.
Summary
- The Context is a Scratchpad: Changes are tracked in memory (firing faults) and only persisted when
context.save()is called. - Safe Concurrency: We instituted a Merge Policy (
NSMergeByPropertyObjectTrumpMergePolicy) to handle data conflicts and safely usedexistingObject(with:)to prevent ghost-record crashes. - SwiftUI Integration: We bound an Edit View to temporary
@Publishedproperties, ensuring the source of truth in Core Data remains untouched and thread-safe until the user explicitly saves. - Lifecycle Hooks: We intercepted deletion commands using
prepareForDeletion()to clean up file-system resources. - Delete Rules: We explored how Core Data manages relationships when parent objects are deleted (Nullify, Cascade, Deny) and how to catch relationship validation errors.
- High-Performance Batching: We looked at
NSBatchUpdateRequestandNSBatchDeleteRequestfor bulk data manipulation directly via SQL, bypassing the memory overhead of the object graph.