Chapter 12: Threading & Background Contexts
The Golden Rule of Core Data Threading
The "Why" Behind the Rule
The Queue-Based Concurrency Model
mainQueueConcurrencyType: Tied to the main thread. Used strictly for fetching data that drives your UI and responding to user input. TheviewContextof anNSPersistentContaineris always of this type.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 callviewContext.performAndWaitfrom the main thread, it is perfectly safe. However, if you callprivateContext.performAndWaitfrom 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
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
- Save the context first (which automatically converts temporary IDs to permanent ones).
- 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
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 ourCategorylogic above),NSBatchInsertRequestis massively faster and uses virtually zero RAM. However, because our expenses require a relationship mapping toCategory, processing them through a backgroundNSManagedObjectContextas shown above is the correct and most flexible approach.
Analyzing the Import Logic
- 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
INquery to load them all, and build a local SwiftDictionary(thecategoryCache). This drops the time complexity of category resolution from O(N) database queries to O(1) dictionary lookups. autoreleasepool: Swift heavily relies on ARC. Within a tightforloop creating thousands of Core Data objects, memory might not be freed until the loop finishes, causing a massive memory spike.autoreleasepoolforces ARC to release temporary objects immediately at the end of each iteration.- Batch Saving &
context.reset(): We save every 1,000 records. Callingcontext.reset()clears the context's internal registry, dropping memory usage back to zero. We then carefully rebuild ourcategoryCacheto 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)
}
}
}
Handling Conflicts: Merge Policies
- The user opens the edit screen for "Lunch Expense" (amount: $15) on the main thread (
viewContext). - A background import starts running. It pulls a JSON payload where the server says "Lunch Expense" was actually $20.
- The background context saves the $20 amount to the SQLite store.
- The user edits the amount to $18 on the UI and taps "Save". The
viewContextattempts 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
- Never pass Managed Objects across threads: Use
NSManagedObjectIDinstead, and fetch the object on the destination context. Beware of temporary IDs! - Always use
performorperformAndWait: When interacting with a context or its objects, do it inside these blocks to guarantee thread safety. Avoid blocking the main thread withprivateContext.performAndWait. - Use
performBackgroundTaskfor heavy lifting: JSON parsing, importing, and data generation should happen on a background queue. - Manage Memory on Massive Imports: Use
autoreleasepoolinside loops, batch your saves, and usecontext.reset()to keep RAM consumption flat. - Enable
automaticallyMergesChangesFromParent: Let Core Data handle synchronizing the persistent store's changes back to the main thread'sviewContext. - Set Merge Policies: Resolve conflicts gracefully using
NSMergeByPropertyObjectTrumpMergePolicyon your view context to ensure UI edits aren't overwritten by background syncs. - Use Query Generations: Pin your
viewContextto.currentto avoid UI stutter and inconsistent reads during heavy background writes. - Abstract the Chaos via MVVM: Keep your SwiftUI views clean. Let your ViewModels coordinate between your Background Services and the
NSFetchedResultsController.