Persistence and networking · advanced

Core Data essentials

The persistence framework in every mature codebase — the stack, the threading rules that gate interviews, and how it coexists with SwiftData.

13 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Describe the Core Data stack and what each piece owns
  • State and apply the threading rules — perform blocks and objectID handoff
  • Diagnose the classic crashes and relate Core Data to SwiftData honestly
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

SwiftData exists and is nicer. Why do senior interviews still drill Core Data in 2026?

The stack: four pieces, one sentence each

NSManagedObjectModel      the schema (entities, attributes, relationships — the .xcdatamodeld)
        │
NSPersistentStoreCoordinator   owns the SQLite file, mediates access
        │
NSManagedObjectContext    a scratchpad of objects — where ALL your work happens
        │
NSManagedObject           a record, alive only within its context

NSPersistentContainer wraps the first two and hands you contexts — modern Core Data code starts there:

let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
    if let error { fatalError("store failed: \(error)") }   // handle properly in prod
}
let viewContext = container.viewContext                     // main-thread context

The pieces map to SwiftData almost one-to-one — ModelContainer/ModelContext are literally built on this stack — which is why the concepts transfer both ways. What does not transfer is the ergonomics: entities are defined in a model editor file, objects are @objc classes with @NSManaged properties, and — the big one — safety is your job, not the compiler's.

The scratchpad model

A context is an in-memory workspace: fetch objects into it, mutate them, and nothing touches the database until save():

let request = NSFetchRequest<Note>(entityName: "Note")
request.predicate = NSPredicate(format: "isArchived == NO AND title CONTAINS[cd] %@", query)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
let notes = try context.fetch(request)

notes.first?.title = "Renamed"      // changed in the scratchpad only
try context.save()                  // now it's real

Note the stringly-typed predicate — %@ placeholders, CONTAINS[cd] for case/diacritic-insensitive — evaluated in SQLite like #Predicate, but with typos surfacing at runtime. ([cd] and format-string mistakes are a rite of passage; SwiftData's macro checking is the direct response to this API.)

Two behaviours make large datasets workable and cause junior confusion: fetched objects arrive as faults — placeholders whose attribute data loads on first access: a library's card catalogue, not its shelves. Flipping through 10,000 index cards is cheap; the cost is paid only when you open a book — and a card whose book has been withdrawn is exactly the classic "could not fulfill a fault" crash. Contexts, meanwhile, track every change for undo, merging and conflict detection.

The threading rules — the interview core

Three rules, unforgiving, and the whole reason this lesson exists:

  1. Contexts and managed objects are not thread-safe. Each context belongs to one queue; its objects belong to it.
  2. Touch a context only inside its perform/performAndWait blockviewContext on the main queue, background contexts (container.newBackgroundContext()) on their own private queues.
  3. Never pass NSManagedObjects between contexts. Pass NSManagedObjectID — the thread-safe ticket — and re-fetch on the other side with context.object(with: id).
// The canonical import shape: heavy work off-main, IDs across the boundary.
let background = container.newBackgroundContext()
background.perform {
    let imported = importBigPayload(into: background)      // background-owned objects
    try? background.save()
    let ids = imported.map { $0.objectID }                 // tickets, not objects
    DispatchQueue.main.async {
        let visible = ids.map { viewContext.object(with: $0) }  // re-materialised safely
        updateUI(with: visible)
    }
}

Break rule 3 and you get the signature production crash: an object fetched on a background queue, stashed in a property, touched later by the main thread — works in every demo, crashes for 0.3% of users with a concurrency violation or a fault firing on a deallocated context. The debugging flag every Core Data engineer knows by name — -com.apple.CoreData.ConcurrencyDebug 1 — turns those latent violations into immediate crashes in development, which is exactly where you want them.

The threading contract as an executable model — queue ownership and the objectID handoff.

Four lines of output, and they are the interview: correct background work, the cross-queue touch (crash), the cross-context object (crash), and the objectID handoff that makes it safe. If you can narrate those four lines, you pass the Core Data round.

The last piece of the import shape: changes saved on a background context reach the UI via viewContext.automaticallyMergesChangesFromParent = true — one line, and main-thread objects refresh when background saves land. (Its absence is the "import worked but the list didn't update until relaunch" ticket.)

Where the bodies are buried

The classic crashes, named — recognising them from a stack trace is senior currency:

  • Concurrency violation — rules 1–3 broken; find with the ConcurrencyDebug flag.
  • Fault fired on deleted object — a held reference whose row was deleted elsewhere; touching it throws. Fix: don't cache managed objects across mutations; re-fetch or observe.
  • Save merge conflict — two contexts edited the same row; unresolved saves throw. Production sets a policy: NSMergeByPropertyObjectTrumpMergePolicy ("mine wins per property") is the common default — the conflict table from the sync lesson, in miniature.
  • Migration failure at launch — same class of bug as SwiftData's, same discipline: additive changes are lightweight; renames need mapping; always test upgrade over an existing install.

Core Data and SwiftData, honestly

The relationship worth stating precisely in an interview: SwiftData is a Swift-native API over Core Data's engine — same SQLite format, and a container can even be opened by both (ModelContainer and NSPersistentContainer on one store) during migration. The consequences:

  • Concepts transfer completely: contexts, faulting, merge policies, migrations — you already know SwiftData's semantics because they are these semantics.
  • Migration is incremental, per the interop lessons' pattern: new features on SwiftData models, legacy screens on the old stack, one store underneath.
  • What SwiftData actually bought: compile-checked predicates, macros instead of codegen and model files, Observation integration, and structurally enforced threading (ModelActor and Sendable rules make the crashes above hard to write rather than easy).

That last point is the honest summary for interviewers: Core Data's threading model isn't harder because it's more powerful — it's harder because it predates the tools (actors, Sendable) that make such rules compiler-checkable. You are learning its rules because a decade of code was written before the guardrails existed.

Check yourself

A teammate "optimises" a slow list by fetching all 50,000 objects once at launch on a background queue, storing the array in a singleton, and having cells read from it. Name everything wrong.

Enforce the perform rule

Write the code

Implement the queue guard that -ConcurrencyDebug 1 gives you: operations on a context must come from its owning queue, and the trace must show which rule each call breaks.

Solution unlocks after 3 attempts

Ship the handoff

Write the code

Implement the import pipeline's safe shape: background creates, IDs cross the boundary, the main side re-materialises — and direct adoption is refused.

Solution unlocks after 3 attempts
Connect itSaved on this device. Never graded.

Map each Core Data concept to its SwiftData counterpart and to the general principle behind both: context ↔ ?, objectID handoff ↔ ?, merge policy ↔ (which lesson's conflict table?), lightweight migration ↔ ?. Two sentences each — the exercise of connecting old API, new API and principle is exactly what distinguishes "used it once" from senior fluency.

Checkpoint

You can now:

  • Draw the stack and explain the scratchpad model with faulting
  • Recite and apply the three threading rules, with the debug flag as enforcement
  • Recognise the four classic crashes from their symptoms
  • Place Core Data and SwiftData in one honest picture

Next up: app lifecycle and background execution — what iOS lets you do when the user isn't looking.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.