Chapter 12: Threading & Background Contexts

The Golden Rule of Core Data Threading

The "Why" Behind the Rule

The Queue-Based Concurrency Model

  1. mainQueueConcurrencyType: Tied to the main thread. Used strictly for fetching data that drives your UI and responding to user input. The viewContext of an NSPersistentContainer is always of this type.
  2. privateQueueConcurrencyType: Tied to a private background serial queue managed entirely by Core Data. Used for heavy lifting, saving, importing, processing, and exporting.
import CoreData

class ThreadingExample {
    func execute(context: NSManagedObjectContext, fetchRequest: NSFetchRequest) {
        // Asynchronous execution (Returns immediately, executes block when queue is ready)
        context.perform {
            // Safe to interact with context and its objects here
            let newExpense = Expense(context: context)
            newExpense.amount = 100.0
        }

        // Synchronous execution (Blocks the current calling thread until the block finishes)
        context.performAndWait {
            // Safe to interact with context and its objects here
            let count = try? context.count(for: fetchRequest)
        }
    }
}

[!WARNING] Be extremely careful with performAndWait. If you call viewContext.performAndWait from the main thread, it is perfectly safe. However, if you call privateContext.performAndWait from the main thread, the main thread will completely freeze until the private context finishes its work. Never block the main thread with heavy background database operations.

Passing Objects Across Contexts: The NSManagedObjectID

sequenceDiagram participant MainThread as Main Thread (View Context) participant BGThread as Background Thread (Private Context) MainThread->>BGThread: Start Background Task (e.g., Download Image) Note over BGThread: Fetch/Create Objects Note over BGThread: Save Context BGThread-->>MainThread: Return Array of NSManagedObjectID Note over MainThread: context.object(with: objectID) Note over MainThread: Update UI
import CoreData

class BackgroundProcessor {
    func processExpenseInBackground(expense: Expense, in viewContext: NSManagedObjectContext, backgroundContext: NSManagedObjectContext) {
        // 1. Get the thread-safe ID while on the thread where the object lives
        let expenseID = expense.objectID
        
        backgroundContext.perform {
            // 2. Fetch the corresponding object in the background context
            // .object(with:) returns a fault if not loaded, or the loaded object if in cache.
            // It does NOT hit the database immediately if the object is already faulted.
            let backgroundExpense = backgroundContext.object(with: expenseID) as! Expense
            
            // 3. Perform heavy work (e.g., generating a PDF receipt)
            backgroundExpense.isProcessed = true
            backgroundExpense.receiptData = self.generateHeavyPDF(for: backgroundExpense)
            
            // 4. Save the background context
            try? backgroundContext.save()
        }
    }
    
    private func generateHeavyPDF(for expense: Expense) -> Data {
        return Data() // Dummy implementation
    }
}

The Edge Case: Temporary vs. Permanent IDs

  1. Save the context first (which automatically converts temporary IDs to permanent ones).
  2. Manually request permanent IDs using obtainPermanentIDs(for:).
import CoreData

class PermanentIDExample {
    func obtainID(backgroundContext: NSManagedObjectContext) {
        let newExpense = Expense(context: backgroundContext)
        newExpense.amount = 50.0

        // Convert to permanent ID BEFORE passing to another thread without saving
        try? backgroundContext.obtainPermanentIDs(for: [newExpense])

        let safeID = newExpense.objectID 
        // Now you can pass safeID to the viewContext
    }
}

Setting Up Background Contexts

1. newBackgroundContext()

import CoreData

class ContextSetupExample {
    let persistentContainer: NSPersistentContainer
    let backgroundContext: NSManagedObjectContext
    
    init(persistentContainer: NSPersistentContainer) {
        self.persistentContainer = persistentContainer
        
        let backgroundContext = persistentContainer.newBackgroundContext()
        // Optional: Assign a transaction author to trace saves in persistent history
        backgroundContext.transactionAuthor = "SyncEngine" 
        // Keep a reference to backgroundContext and use it repeatedly
        self.backgroundContext = backgroundContext
    }
}

2. performBackgroundTask(_:)

import CoreData

class BackgroundTaskExample {
    func executeTask(persistentContainer: NSPersistentContainer) {
        persistentContainer.performBackgroundTask { context in
            // 'context' is a temporary private queue context
            // We are automatically running on its background queue
            
            // Do heavy work...
            
            try? context.save()
        } // Context is deallocated here
    }
}

The Context Hierarchy: Sibling vs. Nested

flowchart TD UI[SwiftUI Views] --> VM[ViewModels] VM --> VC["View Context
Main Queue"] subtask[performBackgroundTask] --> BC1[Temporary Background Context\nPrivate Queue] persistedTask[newBackgroundContext] --> BC2[Persistent Background Context\nPrivate Queue] VC <--> PSC[(NSPersistentStoreCoordinator)] BC1 <--> PSC BC2 <--> PSC PSC <--> SQLite[(SQLite Store)]

Building the Background Import Feature

{
  "expenses": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "amount": 42.50,
      "date": "2023-08-15T10:30:00Z",
      "note": "Office Supplies",
      "categoryName": "Business"
    },
    ...
  ]
}

Defining the Import Service

import Foundation
import CoreData

struct ExpenseDTO: Decodable {
    let id: String
    let amount: Double
    let date: Date
    let note: String
    let categoryName: String
}

struct ImportPayload: Decodable {
    let expenses: [ExpenseDTO]
}

class ExpenseImportService {
    private let container: NSPersistentContainer
    
    init(container: NSPersistentContainer) {
        self.container = container
    }
    
    func importExpenses(from url: URL, completion: @escaping (Result<Int, Error>) -> Void) {
        // 1. Read JSON file off the main thread using global dispatch queue
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else { return }
            
            do {
                let data = try Data(contentsOf: url)
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
                let payload = try decoder.decode(ImportPayload.self, from: data)
                
                // 2. Process Core Data insertion on a background context
                self.processImport(dtos: payload.expenses, completion: completion)
                
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }
    
    private func processImport(dtos: [ExpenseDTO], completion: @escaping (Result<Int, Error>) -> Void) {
        // 3. Create a temporary background context
        container.performBackgroundTask { context in
            // Set a transaction author for debugging and history tracking
            context.transactionAuthor = "Batch_Importer"
            
            // A merge policy is critical when importing potentially duplicate data.
            context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
            
            do {
                // Pre-fetch categories to optimize performance and prevent fetching per-expense
                let categoryNames = Set(dtos.map { $0.categoryName })
                let existingCategories = try self.fetchCategories(names: categoryNames, in: context)
                var categoryCache = Dictionary(uniqueKeysWithValues: existingCategories.map { ($0.name ?? "", $0) })
                
                var importedCount = 0
                let batchSize = 1000 // Save and flush memory every 1,000 records
                
                for (index, dto) in dtos.enumerated() {
                    // 4. Use autoreleasepool to prevent memory buildup inside large loops
                    autoreleasepool {
                        // 5. Resolve Category (O(1) dictionary lookup instead of O(N) database fetch)
                        let category: Category
                        if let cached = categoryCache[dto.categoryName] {
                            category = cached
                        } else {
                            category = Category(context: context)
                            category.id = UUID()
                            category.name = dto.categoryName
                            categoryCache[dto.categoryName] = category
                        }
                        
                        // 6. Create Expense
                        let expense = Expense(context: context)
                        expense.id = UUID(uuidString: dto.id) ?? UUID()
                        expense.amount = NSDecimalNumber(value: dto.amount)
                        expense.date = dto.date
                        expense.note = dto.note
                        expense.category = category
                        
                        importedCount += 1
                    }
                    
                    // 7. Batch Save and memory reset
                    if index > 0 && index % batchSize == 0 {
                        if context.hasChanges {
                            try context.save()
                            // reset() clears the context's memory cache, turning all objects back into faults.
                            // This ensures our RAM usage stays flat even when importing millions of rows.
                            context.reset()
                            
                            // Because we reset the context, our categoryCache now holds invalid objects!
                            // We must refetch them into the fresh context to continue seamlessly.
                            let reFetchedCategories = try self.fetchCategories(names: categoryNames, in: context)
                            categoryCache = Dictionary(uniqueKeysWithValues: reFetchedCategories.map { ($0.name ?? "", $0) })
                        }
                    }
                }
                
                // 8. Final save for any remaining objects less than the batch size
                if context.hasChanges {
                    try context.save()
                }
                
                // 9. Call completion on main thread
                DispatchQueue.main.async {
                    completion(.success(importedCount))
                }
                
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }
    
    private func fetchCategories(names: Set<String>, in context: NSManagedObjectContext) throws -> [Category] {
        let request: NSFetchRequest<Category> = Category.fetchRequest()
        request.predicate = NSPredicate(format: "name IN %@", names)
        return try context.fetch(request)
    }
}

[!TIP] Batch Insert Requests vs Context Insertion In iOS 13, Apple introduced NSBatchInsertRequest. It writes directly to the SQLite store, bypassing the object graph (and thus, memory) entirely. If you are inserting 50,000 rows that do not require complex relationship mapping (like our Category logic above), NSBatchInsertRequest is massively faster and uses virtually zero RAM. However, because our expenses require a relationship mapping to Category, processing them through a background NSManagedObjectContext as shown above is the correct and most flexible approach.

Analyzing the Import Logic

  1. Pre-fetching: Fetching is the most expensive operation in Core Data. Instead of executing a fetch request 50,000 times, we extract all unique category names, execute a single IN query to load them all, and build a local Swift Dictionary (the categoryCache). This drops the time complexity of category resolution from O(N) database queries to O(1) dictionary lookups.
  2. autoreleasepool: Swift heavily relies on ARC. Within a tight for loop creating thousands of Core Data objects, memory might not be freed until the loop finishes, causing a massive memory spike. autoreleasepool forces ARC to release temporary objects immediately at the end of each iteration.
  3. Batch Saving & context.reset(): We save every 1,000 records. Calling context.reset() clears the context's internal registry, dropping memory usage back to zero. We then carefully rebuild our categoryCache to ensure we don't attempt to use invalidated objects in the next batch.

Merging Changes Back to the View Context

The Modern Way: automaticallyMergesChangesFromParent

import Foundation
import CoreData

class CoreDataStack {
    static let shared = CoreDataStack()
    
    let persistentContainer: NSPersistentContainer
    
    var viewContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }
    
    private init() {
        persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
        
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        
        // ✨ The Magic Line ✨
        persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
        
        // Ensure UI doesn't crash when merging conflicting data
        persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

The Classic Way: Manual Notifications

import Foundation
import CoreData

class ContextObserverExample {
    let viewContext: NSManagedObjectContext
    
    init(viewContext: NSManagedObjectContext) {
        self.viewContext = viewContext
        
        // 1. Add an observer for context saves
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(contextDidSave(_:)),
            name: .NSManagedObjectContextDidSave,
            object: nil
        )
    }

    @objc func contextDidSave(_ notification: Notification) {
        let savedContext = notification.object as! NSManagedObjectContext
        
        // Ignore saves from the viewContext itself
        guard savedContext !== viewContext else { return }
        
        // Ignore saves from contexts associated with different persistent store coordinators
        guard savedContext.persistentStoreCoordinator === viewContext.persistentStoreCoordinator else { return }
        
        // 2. Perform the merge asynchronously on the main thread
        viewContext.perform { [weak self] in
            self?.viewContext.mergeChanges(fromContextDidSave: notification)
        }
    }
}
sequenceDiagram participant BG as Background Context participant NC as Notification Center participant VC as View Context (automaticallyMerges...) participant FRC as NSFetchedResultsController participant UI as SwiftUI View BG->>BG: Process 10,000 JSON rows BG->>BG: try save() BG->>NC: Post NSManagedObjectContextDidSave NC->>VC: Intercept Notification VC->>VC: Merge inserted/updated/deleted objects VC->>FRC: contextDidChange FRC->>FRC: Recalculate sections/rows FRC->>UI: Observation tracks changes UI->>UI: Rerender with new data

Handling Conflicts: Merge Policies

  1. The user opens the edit screen for "Lunch Expense" (amount: $15) on the main thread (viewContext).
  2. A background import starts running. It pulls a JSON payload where the server says "Lunch Expense" was actually $20.
  3. The background context saves the $20 amount to the SQLite store.
  4. The user edits the amount to $18 on the UI and taps "Save". The viewContext attempts to save to the SQLite store.
  • NSErrorMergePolicy (Default): Fails the save if a conflict is detected.
  • NSMergeByPropertyObjectTrumpMergePolicy: The context that is currently trying to save wins. Its properties overwrite the store's properties.
  • NSMergeByPropertyStoreTrumpMergePolicy: The data currently in the persistent store wins. The context's changes are discarded.
  • NSOverwriteMergePolicy: Overwrites all properties in the store with the context's properties, regardless of whether they were modified or not.
  • NSRollbackMergePolicy: Discards all changes in the context entirely.

Best Practice Configuration:

import CoreData

class MergePolicyExample {
    func configureMergePolicies(viewContext: NSManagedObjectContext, backgroundContext: NSManagedObjectContext) {
        // For the View Context (Main Thread)
        // If the user made a change in the UI, respect their choice over background syncs.
        viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

        // For the Background Context (Private Queue)
        // If the background is importing old data, and the store already has newer data 
        // (perhaps saved by a recent UI edit), let the store win.
        backgroundContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
    }
}

Advanced Feature: Query Generations

import CoreData

class QueryGenerationExample {
    func pinQueryGeneration(viewContext: NSManagedObjectContext) {
        // In your CoreDataStack init:
        do {
            // Pin the viewContext to the current generation. 
            // It will safely ignore in-flight background writes.
            try viewContext.setQueryGenerationFrom(.current)
        } catch {
            print("Failed to pin viewContext query generation: \(error)")
        }
    }
}

Integrating with MVVM Architecture and SwiftUI

The ViewModel

import Foundation
import CoreData
import Observation

@Observable class ExpenseListViewModel: NSObject {
    var expenses: [ExpenseViewModel] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    
    private let coreDataStack: CoreDataStack
    private let importService: ExpenseImportService
    private var fetchedResultsController: NSFetchedResultsController<Expense>!
    
    init(coreDataStack: CoreDataStack = .shared) {
        self.coreDataStack = coreDataStack
        self.importService = ExpenseImportService(container: coreDataStack.persistentContainer)
        super.init()
        setupFRC()
    }
    
    private func setupFRC() {
        let request: NSFetchRequest<Expense> = Expense.fetchRequest()
        // Sorting is required for NSFetchedResultsController
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
        // Batch size limits how many objects are faulted into memory at once
        request.fetchBatchSize = 20 
        
        fetchedResultsController = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: coreDataStack.viewContext,
            sectionNameKeyPath: nil,
            cacheName: nil
        )
        fetchedResultsController.delegate = self
        
        do {
            try fetchedResultsController.performFetch()
            updateExpensesFromFRC()
        } catch {
            print("Fetch failed: \(error)")
        }
    }
    
    func importData(from url: URL) {
        isLoading = true
        errorMessage = nil
        
        // The service handles background threading internally
        importService.importExpenses(from: url) { [weak self] result in
            // This completion is guaranteed to return on the main thread 
            // by the service we wrote earlier.
            guard let self = self else { return }
            self.isLoading = false
            
            switch result {
            case .success(let count):
                print("Successfully imported \(count) expenses.")
                // Note: We don't need to manually refetch here!
                // because automaticallyMergesChangesFromParent is true,
                // the viewContext merged the changes, and the FRC delegate will fire.
            case .failure(let error):
                self.errorMessage = error.localizedDescription
            }
        }
    }
    
    private func updateExpensesFromFRC() {
        guard let fetchedObjects = fetchedResultsController.fetchedObjects else { return }
        // Map Core Data objects to immutable struct ViewModels for the UI
        self.expenses = fetchedObjects.map { ExpenseViewModel(expense: $0) }
    }
}

// MARK: - NSFetchedResultsControllerDelegate
extension ExpenseListViewModel: NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        // This fires automatically when the background context saves and the view context merges!
        updateExpensesFromFRC()
    }
}

// A simple immutable struct representing an expense for the UI
struct ExpenseViewModel: Identifiable {
    let id: UUID
    let amountText: String
    let categoryName: String
    let dateText: String
    
    init(expense: Expense) {
        self.id = expense.id ?? UUID()
        self.amountText = "$\(expense.amount ?? 0)"
        self.categoryName = expense.category?.name ?? "Uncategorized"
        
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        self.dateText = formatter.string(from: expense.date ?? Date())
    }
}

The SwiftUI View

import SwiftUI

struct ExpenseListView: View {
    @State private var viewModel = ExpenseListViewModel()
    
    var body: some View {
        NavigationView {
            ZStack {
                List(viewModel.expenses) { expense in
                    HStack {
                        VStack(alignment: .leading) {
                            Text(expense.categoryName)
                                .font(.headline)
                            Text(expense.dateText)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                        Spacer()
                        Text(expense.amountText)
                            .font(.title3)
                            .bold()
                    }
                }
                
                if viewModel.isLoading {
                    ProgressView("Importing Data...")
                        .padding()
                        .background(Color(.systemBackground))
                        .cornerRadius(10)
                        .shadow(radius: 10)
                }
            }
            .navigationTitle("Expenses")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: triggerImport) {
                        Image(systemName: "square.and.arrow.down")
                    }
                    .disabled(viewModel.isLoading)
                }
            }
            .alert(isPresented: Binding<Bool>(
                get: { viewModel.errorMessage != nil },
                set: { _ in viewModel.errorMessage = nil }
            )) {
                Alert(
                    title: Text("Import Failed"),
                    message: Text(viewModel.errorMessage ?? "Unknown Error"),
                    dismissButton: .default(Text("OK"))
                )
            }
        }
    }
    
    private func triggerImport() {
        // Simulate picking a JSON file url
        guard let url = Bundle.main.url(forResource: "expenses_export", withExtension: "json") else {
            return
        }
        viewModel.importData(from: url)
    }
}

Summary & Best Practices

  1. Never pass Managed Objects across threads: Use NSManagedObjectID instead, and fetch the object on the destination context. Beware of temporary IDs!
  2. Always use perform or performAndWait: When interacting with a context or its objects, do it inside these blocks to guarantee thread safety. Avoid blocking the main thread with privateContext.performAndWait.
  3. Use performBackgroundTask for heavy lifting: JSON parsing, importing, and data generation should happen on a background queue.
  4. Manage Memory on Massive Imports: Use autoreleasepool inside loops, batch your saves, and use context.reset() to keep RAM consumption flat.
  5. Enable automaticallyMergesChangesFromParent: Let Core Data handle synchronizing the persistent store's changes back to the main thread's viewContext.
  6. Set Merge Policies: Resolve conflicts gracefully using NSMergeByPropertyObjectTrumpMergePolicy on your view context to ensure UI edits aren't overwritten by background syncs.
  7. Use Query Generations: Pin your viewContext to .current to avoid UI stutter and inconsistent reads during heavy background writes.
  8. Abstract the Chaos via MVVM: Keep your SwiftUI views clean. Let your ViewModels coordinate between your Background Services and the NSFetchedResultsController.