Persistence and networking · advanced
Core Data essentials
The persistence framework that older iOS codebases still run on. What each piece of the stack owns, the threading rules that decide interviews, and how Core Data and SwiftData fit together.
By the end you will be able to
- Name the four pieces of the Core Data stack and say what each one owns
- Apply the three threading rules — do the work inside perform blocks, and pass object IDs between contexts
- Recognise the four classic crashes from their symptoms, and relate Core Data to SwiftData honestly
SwiftData exists and is nicer to use. Why do senior interviews still ask about Core Data in 2026?
The stack: four pieces, one job each
NSManagedObjectModel the schema — which records exist and what fields
│ they have (the .xcdatamodeld file in your project)
NSPersistentStoreCoordinator owns the store file on disk and mediates access
│
NSManagedObjectContext the in-memory workspace where ALL your work happens
│
NSManagedObject one record, alive only inside its own context
Four names, so here is each one in plain words.
- The managed object model is your schema. It lists the kinds of record your app stores (Core Data calls them entities), the fields on each one (attributes), and how records point at each other (relationships). You draw it in Xcode's model editor, in a file whose extension is
.xcdatamodeld. It is not Swift source. - The persistent store is the file on disk. In nearly every app it is a SQLite database. The persistent store coordinator owns that file: everything that reaches the disk goes through it.
- A managed object context is an in-memory workspace. You fetch records into it, change them there, and it remembers every change you made.
- A managed object is one record, in memory, as an object you can read and write. It is an instance of
NSManagedObjector a subclass of it.
One sentence from that list carries the rest of the lesson: every managed object belongs to exactly one context, and it is only usable there.
NSPersistentContainer wraps the model and the coordinator, opens the store file for you, 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)") } // a shipping app reports it, not crashes
}
let viewContext = container.viewContext // the context owned by the main queue
loadPersistentStores opens the SQLite file, creating it on first launch and migrating it if the schema has changed since the user's last version. viewContext is the one context the container makes for you, and it belongs to the main queue — it is the context your screens read from.
The four pieces map almost one to one onto SwiftData, which you met in D2-02. ModelContainer is the container; ModelContext is the context. That is not a coincidence. SwiftData is built on this stack, so the concepts carry in both directions.
What does not carry is the ergonomics. Three differences, and you meet all three on day one:
- The schema lives in a model editor file, not in your Swift code.
- Model classes are Objective-C classes, and their properties are declared
@NSManaged, meaning Core Data supplies the storage at runtime rather than the class storing the value itself. - Safety is your job, not the compiler's. Nothing in the code below stops you from breaking the threading rules. You find out in production.
The context is a scratchpad
A context is a scratchpad in this exact sense: you fetch objects into it and change them there, and nothing reaches the database until you call save().
A fetch request is a query. You say which entity you want, how to filter it, and how to sort it, and Core Data turns that into a query against the store file.
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 memory only
try context.save() // now it is written to the store file
Look at the predicate, because it is a string rather than Swift code. %@ is a placeholder: the value you pass after the format string is substituted in where it stands. CONTAINS[cd] means "contains, ignoring case and accents", so a search for cafe still finds "Café". The store evaluates that filter, exactly as SwiftData's #Predicate does. The difference is when you find out about a mistake. A typo in this format string compiles happily and fails at runtime, in front of a user. SwiftData's #Predicate macro, which the compiler checks, is the direct answer to this API.
Two behaviours make large datasets workable, and both confuse people meeting Core Data for the first time.
Faulting. Objects come back from a fetch as faults. A fault is a placeholder object: it knows which record it stands for, but it has not loaded that record's values yet. The values load the first time your code reads one of them, and Core Data calls that moment firing the fault.
This is a library's card catalogue, not its shelves. Flipping through ten thousand index cards is cheap, because a card is not a book. You pay the real cost only when you take a card to the shelf and open the book it points to. And if that book was withdrawn from the library after the card was printed, the card leads nowhere. That is precisely the crash whose message begins "Core Data could not fulfill a fault". We come back to it below.
Batch fetching. Faulting still records every matching row up front. Set request.fetchBatchSize = 50 and the array you get back becomes a proxy. The query is still evaluated in full and every matching record identified. But the values are loaded fifty rows at a time, as your code walks the array. This is the setting that makes a 50,000-row list scroll.
Contexts also track every change you make to the objects they own. That tracking is what makes undo, change notifications, merging between contexts and conflict detection possible at all.
The threading rules — the interview core
Three rules. They are the reason this lesson exists.
- Contexts and managed objects are not thread-safe. Each context belongs to one queue. Its objects belong to it.
- Touch a context only from inside its own
performorperformAndWaitblock.viewContextruns on the main queue. Background contexts, made withcontainer.newBackgroundContext(), each run on a private queue of their own. - Never pass a managed object from one context to another. Pass its
NSManagedObjectIDinstead. An object ID is a plain identifier value, so it is safe to hold on any queue. On the other side, turn it back into an object withcontext.object(with: id).
Rule 2 names two methods, so here is what each one does. perform takes a block of code and runs it on the context's own queue, returning to you immediately. performAndWait runs the block on that same queue but does not return until the block has finished. Either way, the code inside the block is running on the queue that owns the context, which is the guarantee rule 1 asks for.
// The canonical import shape: heavy work off the main queue, 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 } // IDs, not objects
DispatchQueue.main.async {
let visible = ids.map { viewContext.object(with: $0) } // rebuilt on the main queue
updateUI(with: visible)
}
}
Two details in that block are easy to miss. The first: the save comes before the IDs are collected, and it has to. A newly inserted object carries a temporary ID until its context is saved, and a temporary ID is worthless to any other context. The second: the handoff runs inside DispatchQueue.main.async, which is the main queue — the queue that owns viewContext. Writing that as viewContext.perform { … } says the same thing more explicitly, and is the better habit.
Now break rule 3, and watch it become a real production incident. Follow the sequence:
- A background context imports 500 notes from the server. That work runs on the context's private queue, which is correct so far.
- The code keeps one of the imported
Noteobjects in a property somewhere shared — a cache, a singleton, a view model. Nothing complains. It is an ordinary Swift reference to an ordinary object. - A minute later the user opens a screen, and the main thread reads
note.titlefrom that stored reference. - Reading
titlefires the fault. Firing a fault is not a plain memory read: the object has to go back to its context for the missing values. Its context is the background one, and the code asking is on the main queue. - Apple's own description of what happens next is "corruption of the data and termination of the app". Which of the two you get is luck, and it depends on what the background context happens to be doing at that instant.
The result is a crash report you cannot reproduce, in a screen that works every single time you test it, for a fraction of a percent of users. Nothing in step 2 looked like a mistake. That is why this bug ships.
There is a development-time cure, and every Core Data engineer knows it by name: launch the app with the argument -com.apple.CoreData.ConcurrencyDebug 1. It turns Core Data's multithreading assertions on. A violation then stops the app in the debugger at the moment it happens, instead of quietly corrupting something. The symbol it stops on is worth recognising in a stack trace: Multithreading_Violation_AllThatIsLeftToUsIsHonor.
The interpreter running this lesson cannot run Core Data itself. What it can run is a model of the two rules that matter: a context has an owning queue, and an object remembers the queue it was created on.
Four lines come out, and each one is an exam answer:
fetched note-1 into background context— the import ran on the queue that owns the background context. Legal.CRASH: context owned by 'background' touched from 'main'— rule 2 broken. The main queue reached into a context it does not own.CRASH: object owned by 'background' used on 'main'— rule 3 broken. This time the object itself crossed the boundary.re-fetched note-1 on main— the correct handoff. Only the stringnote-1crossed, and the main context built its own object from it.
If you can narrate those four lines out loud, you can answer the Core Data threading question in an interview.
One line finishes the import shape. Changes saved on a background context do not appear in the main context by themselves; you ask for that with viewContext.automaticallyMergesChangesFromParent = true. Set it once and main-queue objects refresh whenever a background save lands. Its absence is the bug report that reads "the import worked, but the list only updated after I relaunched the app". The traffic in the other direction is already handled for you. Apple's documentation says a context from newBackgroundContext() "is set to consume NSManagedObjectContextDidSave broadcasts automatically". viewContext is not, and that is why you set the flag on the view context yourself.
That property name mentions a parent, which is a piece of Core Data you should be able to explain. Every context saves into something above it. Usually that is the persistent store coordinator, which is what viewContext and every newBackgroundContext() context get. But a context can instead have another context as its parent, and then it is a child context. Saving a child writes nothing to disk. It pushes the changes up into the parent, one level. Nothing reaches the store file until the root context is saved in its turn — the root being the context whose parent is the coordinator.
Child contexts have one honest use: a screen that edits something and can be cancelled. Give the edit screen a child of viewContext. The user changes whatever they like in the child. Saving the child moves those edits up into the main context. Throwing the child away means the main context never saw them, so "cancel" costs you no undo code at all. What a child context is not is a way to make saving faster. The parent still has to do the work, on the parent's queue, so a chain of contexts moves the cost around rather than removing it.
The four classic crashes
Recognising these from a stack trace is what separates an engineer who has shipped Core Data from one who has read about it.
- Concurrency violation. One of rules 1–3 was broken. Symptom: rare crashes with no pattern, often in code that has not changed for months. Find it by running with the ConcurrencyDebug argument above, which converts "rare and unreproducible" into "immediate and obvious".
- Fault fired on a deleted object. You held on to a managed object, the row behind it was deleted somewhere else, and then something read one of its properties. The fault tries to load values that no longer exist and throws
NSObjectInaccessibleException— "Core Data could not fulfill a fault". The fix is not to cache managed objects across changes. Re-fetch instead, or observe. And when you must look an object up by ID and cannot be sure it still exists,existingObject(with:)is the safer call. It checks the store and throws a Swift error you can catch.object(with:)hands you a fault that fails later, somewhere else. - Save merge conflict. Two contexts changed the same row, so the second save finds the store already holding a different version of it. By default that save throws. Production code sets a policy on the context saying how to resolve it. The common choice is
NSMergeByPropertyObjectTrumpMergePolicy: resolve the conflict property by property, and where two versions disagree, the version in memory wins over the version already in the store. That is the same decision the conflict-strategy table in the sync lesson (D2-04) makes at the scale of a whole product — field-level merge, with a stated winner per field. - Migration failure at launch. Migration means changing the schema of a store that already holds users' data. Core Data infers the change for you when it can, which covers adding an entity, adding an attribute, or removing one. A rename can be inferred too, but only if you set the destination property's renaming identifier in the model editor to its old name. Without that, Core Data sees one attribute deleted and a different one added, and the old column's data goes with it. When the change cannot be inferred at all, you supply a mapping model of your own. Whatever you do, test the upgrade over an existing install rather than a fresh one. The discipline is the same as SwiftData's in D2-02, because it is the same engine underneath.
Core Data and SwiftData, honestly
Here is the relationship, stated the way an interviewer wants to hear it: SwiftData is a Swift-native API over Core Data's engine. Same store format on disk. Apple even documents opening one store from both APIs at once: a ModelContainer and an NSPersistentContainer over the same file. That is how a team migrates gradually instead of in one frightening release. The coexistence has conditions. The two schemas have to stay in step, persistent history tracking must be enabled on the store, and the Core Data classes and the SwiftData classes must not share names.
Three consequences worth carrying into the interview:
- The concepts transfer completely. Contexts, faulting, merge policies, migrations — you already know SwiftData's semantics, because they are these semantics.
- Migration can be incremental, in the same shape as the UIKit interop lesson (U5-02). New features are written against SwiftData models, older screens stay on the Core Data stack, and one store file sits underneath both.
- What SwiftData actually bought you is four things. Predicates the compiler checks. Macros in place of code generation and a model file. Observation built in. And threading enforced by the language rather than by your memory.
That last point is the honest summary. Core Data's threading model is not harder because it is more powerful. It is harder because it was designed before the tools that could check such rules — actors, Sendable — existed at all. You are learning the rules by hand because a decade of code was written before the guardrails arrived.
A teammate "optimises" a slow list. At launch, on a background queue, they fetch all 50,000 objects once, store the array in a singleton, and have the cells read from it. Name everything wrong with that.
Match each Core Data concept to its SwiftData counterpart, and then to the general principle underneath both: context ↔ ?, the objectID handoff ↔ ?, merge policy ↔ (which lesson's conflict table?), lightweight migration ↔ ?. Two sentences each. Connecting the old API, the new API and the principle is what separates "used it once" from senior fluency.
You can now:
- Draw the stack, and explain the context as a scratchpad whose objects arrive as faults
- State the three threading rules and apply them, with the debug flag as your enforcement
- Recognise the four classic crashes from their symptoms
- Place Core Data and SwiftData in one honest picture
Next up: back to the network, and the half of it this unit has not covered yet. D2-07 builds the request — methods, headers, and the status codes that each demand different behaviour.