Chapter 7: Updating and Deleting Data

  • How Core Data tracks modifications within the NSManagedObjectContext and 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 NSBatchDeleteRequest for clearing data without freezing the UI.

The Mechanics of Modification

The Context as a Transactional Scratchpad

sequenceDiagram participant App as SwiftUI View participant VM as ViewModel participant Repo as ExpenseRepository participant MOC as NSManagedObjectContext participant SQLite as Persistent Store App->>VM: User taps "Save Edit" VM->>Repo: updateExpense(id: 123, newAmount: 15.0) Repo->>MOC: Fetch Expense by ID MOC-->>Repo: Returns NSManagedObject (Fault) Repo->>MOC: expense.amount = 15.0 Note over MOC: Fault fires (loads data into memory)
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

  1. Thread Confinement: Core Data objects (NSManagedObject) and their contexts are strictly bound to the queue they were created on. Passing an NSManagedObject fetched 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.
  2. 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.
  3. Cancellation and Rollbacks: If a user is editing an expense and hits "Cancel", rolling back changes on a live NSManagedObject is tedious. You would need to call context.refresh(object, mergeChanges: false) or context.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:)

  1. 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 an NSObjectInaccessibleException.
  2. 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. existingObject prevents 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.mergeChanges to 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

graph TD A["User taps Save in EditExpenseView"] -->|"Data Validated"| B["EditExpenseViewModel calls Repository"] B --> C["Repository updates object & context.save()"] C -->|"Commits transaction"| D[(SQLite Database)] C -->|"Internal Notification"| E["NSFetchedResultsController detects save"] E --> F["FRC Delegate method triggered"] F -->|"Maps Entity to Struct"| G["ExpenseListViewModel updates @Observable state"] G -->|"State Change"| H["SwiftUI List Re-renders instantly"]

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 call context.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 .onDelete modifier must be attached to the ForEach view, not the List view 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 category relationship set to nil. 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 -> Expenses relationship. 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.
graph TD subgraph NullifyExample___Nullify_Example__ ["NullifyExample ["Nullify Example"]"] A1["Category: Food
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:

  1. prepareForDeletion() is never called. Any external files (like receipt images) will be orphaned.
  2. 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

  1. The Context is a Scratchpad: Changes are tracked in memory (firing faults) and only persisted when context.save() is called.
  2. Safe Concurrency: We instituted a Merge Policy (NSMergeByPropertyObjectTrumpMergePolicy) to handle data conflicts and safely used existingObject(with:) to prevent ghost-record crashes.
  3. SwiftUI Integration: We bound an Edit View to temporary @Published properties, ensuring the source of truth in Core Data remains untouched and thread-safe until the user explicitly saves.
  4. Lifecycle Hooks: We intercepted deletion commands using prepareForDeletion() to clean up file-system resources.
  5. Delete Rules: We explored how Core Data manages relationships when parent objects are deleted (Nullify, Cascade, Deny) and how to catch relationship validation errors.
  6. High-Performance Batching: We looked at NSBatchUpdateRequest and NSBatchDeleteRequest for bulk data manipulation directly via SQL, bypassing the memory overhead of the object graph.