Core Data: Change Tracking, Concurrency, and Migration

Understand how Core Data tracks and saves changes, how faulting keeps memory usage low, how to safely use multiple contexts and background work without violating Core Data's threading rules, how schema migrations (including lightweight migration) work, and how to keep Core Data fast, including with batch update/delete operations.

The previous lesson built the Core Data stack and covered how to model and query an object graph. This lesson covers everything that happens once you actually start changing that graph: how a fetched object can be a lightweight placeholder rather than a fully loaded instance, how a context knows what's changed and needs saving, what actually happens when you call save:, how changes made in one context reach another, and — critically — the hard rule that a managed object context is not safe to use from just any thread. It closes with two very practical, production-facing concerns: how Core Data migrates an existing store's schema forward as your model evolves across app versions, and how to keep Core Data fast as your data grows, including batch operations that bypass loading individual managed objects entirely for bulk changes.

Faults: Core Data's Lazy-Loading Mechanism

When a fetch request returns managed objects, Core Data does not necessarily load every attribute and relationship of every object into memory immediately. Instead, a returned object is often initially a fault — a lightweight placeholder that knows its entity type and its identity (which row it corresponds to in the store) but has not yet loaded its actual attribute values from the store. The object behaves, from your code's point of view, exactly like a fully realized instance — you access task.title the same way regardless — but the very first time you actually access one of its properties, Core Data quietly 'fires' the fault behind the scenes, fetching the real data from the store and populating the object, entirely transparently to your calling code.

This matters directly for how you reason about performance: fetching a thousand objects does not mean a thousand objects' worth of attribute data was immediately pulled into memory — it can mean a thousand lightweight fault placeholders were created, with the real cost deferred until (and unless) each one is actually touched. If your code fetches a large result set but only ever inspects a handful of the returned objects, faulting means you never pay the cost of loading the rest. Conversely, if you loop over every object in a large result set and touch an attribute on each one, you will fire every one of those faults, one at a time, which can itself become a performance concern addressed later in this lesson's performance section.

It's a common and subtle mistake to assume a managed object is fully loaded and safe to inspect freely purely because you hold a reference to it — a fault that hasn't been fired yet, if its underlying row has since been deleted from the store by another part of your code, can fail to fire (an error condition rather than silently loading garbage), which is one more reason a naive assumption of 'I have the object, so its data must already be present and valid' can go wrong in ways that only appear under real, changing data.

Change Tracking: How a Context Knows What to Save

A managed object context automatically tracks every insertion, modification, and deletion made to the objects it manages, without you having to record any of that yourself. Internally, the context keeps track of its inserted objects (newly created, not yet saved), updated objects (existing objects whose attribute or relationship values have changed since they were fetched or last saved), and deleted objects (marked for removal) — and this bookkeeping is exactly what save: consults to know precisely what needs to be written to the persistent store.

This automatic tracking is also what makes rollback meaningful: calling rollback on a context discards every unsaved insertion, update, and deletion it has been tracking, resetting managed objects back to their last-saved state (or removing them entirely, if they were inserted and never saved) — a clean way to discard a batch of in-progress edits the user decided not to keep, without you having to manually undo each change yourself.

Change tracking is also the foundation Core Data's optional undo-manager integration builds on, and it's what powers hasChanges, a quick boolean check on a context telling you whether there's anything pending that a save: call would actually need to write — useful for avoiding a pointless save call (and the disk I/O it would trigger) when nothing has actually changed since the last save.

Saving: Committing Changes to the Persistent Store

Calling save: on a managed object context ([context save:&error]) is what actually writes everything that context has been tracking — its inserted, updated, and deleted objects — down through the persistent store coordinator to the actual store file. Before that call, all of your changes exist only in memory, in that context; after a successful save, they're durably persisted (for the default SQLite store, this ultimately involves exactly the kind of transactional write covered in the SQLite lesson).

save: can fail, and the method's NSError ** out-parameter is not optional to check: a validation failure (an attribute that violates a constraint declared in the model, like a required string left empty), a store-level I/O error, or a conflict with another context's already-saved changes can all cause save: to return NO with a populated error describing what went wrong. Production code needs a real strategy here — at minimum, logging the error richly enough to diagnose it, and in many apps, presenting some recovery path to the user rather than silently losing their changes.

It's worth being precise about what 'saved' means across the stack: saving a context writes that context's changes to whatever it's directly connected to — for viewContext, that's the persistent store coordinator and, from there, the store file itself. But when a change originates in a different context (a background context, or a child context), saving that context does not automatically make the change visible in a different, separate context that's already holding onto its own (possibly now-stale) copy of the same objects — that's exactly the merging problem the next section addresses.

Merging: Propagating Changes Between Contexts

The moment your app uses more than one managed object context — the extremely common case of a private background context doing an import, alongside the main-queue viewContext driving your UI — you need a mechanism for one context's saved changes to reach the other, since each context maintains its own in-memory picture of the object graph and doesn't automatically know about another context's saves.

One standard mechanism is notification-based merging: every successful context save posts an NSManagedObjectContextDidSaveNotification, and another context can observe that notification and call mergeChangesFromContextDidSaveNotification: to fold those saved changes into its own state — updating, inserting, or removing objects it's holding to reflect what was just saved elsewhere. This is the classic pattern for propagating a background import's results into the main-queue context driving the UI.

A related, often simpler alternative is a parent/child context relationship: a child context created with initWithConcurrencyType: and its parentContext set to another context doesn't save directly to the persistent store coordinator at all — saving a child context pushes its changes up into its parent context (still in memory, not yet to the store), and the parent must itself be saved separately to actually reach the store. This pattern is useful for a scratch context whose changes you may want to discard entirely without ever touching the store, or for structuring a multi-step edit as a hierarchy of contexts.

Concurrency: A Context Is Not Thread-Safe

This is one of the single most important rules in all of Core Data, and violating it is a frequent source of subtle, hard-to-reproduce crashes in real apps: a managed object context is not safe to use directly from an arbitrary thread. Every context has a concurrency type set at creation — NSMainQueueConcurrencyType, meaning the context expects to be used on the main queue (this is what viewContext uses), or NSPrivateQueueConcurrencyType, meaning the context owns its own private serial dispatch queue and expects to be used only on that queue.

Directly calling methods on a context — fetching, inserting, saving — from a thread other than the one its concurrency type designates is undefined behavior and a real bug, even though it may appear to work during testing. The correct, safe way to operate on a context from 'the wrong thread' (or from code that doesn't know which thread it's currently running on) is performBlock:, which asynchronously schedules a block of work onto the context's own correct queue, or performBlockAndWait:, which does the same but blocks the calling thread until that block has finished running.

This concurrency model connects directly back to the GCD and concurrency modules earlier in this course: NSPrivateQueueConcurrencyType is, under the hood, backed by exactly the same kind of serial dispatch queue you learned to reason about there, and performBlock:/performBlockAndWait: are Core Data's own queue-dispatch primitives, playing the same role dispatch_async/dispatch_sync play for a raw GCD queue — the same mental model of 'a serial queue guarantees only one block runs against this resource at a time' applies here, just wrapped in Core Data's own API surface.

Background Contexts: The Import/Heavy-Work Pattern

A very common, well-established pattern is to keep the main-queue viewContext reserved for UI-facing reads and small edits, and to do any heavy or bulk work — importing a large batch of data from a network response, say — on a separate private-queue background context created with persistentContainer newBackgroundContext (or performBackgroundTask:, which hands you a ready-to-use background context inside a block already scheduled on its own queue).

The background context does its heavy work — inserting hundreds or thousands of objects, say — entirely on its own private queue, never blocking the main thread or the UI. Once that work is done, saving the background context (from within its own performBlock:, since it's the background context's own operation) writes those changes to the persistent store, and then the merging pattern from earlier in this lesson (notification-based merging, or a parent/child relationship) is what brings the results into viewContext so the UI can reflect them.

This pattern is the direct Core Data analogue of the general principle from this course's concurrency modules: keep the main thread free for UI work, and push anything slow or bulk onto a background queue. The specific mechanics differ — you're working with a background managed object context instead of a raw dispatch_async block operating on plain data — but the underlying motivation (a responsive UI that never blocks on slow work) is identical, and it's exactly why Core Data provides first-class support for background contexts rather than leaving you to bolt GCD onto viewContext directly, which the concurrency rule in the previous section explains is unsafe.

Migrations and Lightweight Migration: Evolving the Model Safely

Just as a SQLite schema needs to evolve across app versions, a Core Data model does too — and because the model describes the schema of an existing store file a user may already have on disk, opening that store with a newer model requires migrating the store's actual data to match the new model's shape, not just swapping in the new model and hoping for the best.

For simple, additive changes — adding a new attribute with a default value, adding a new entity, making an attribute optional, renaming an attribute or entity (when properly annotated with a 'renaming identifier' in the model) — Core Data can perform lightweight migration entirely automatically: you enable it when configuring your persistent store (NSPersistentContainer does this by default in modern code), and Core Data compares the store's existing model version against your app's current model version, infers a mapping between them, and migrates the store's data to match, with no additional code from you.

More substantial changes — splitting one entity into two, merging entities, restructuring relationships in ways too complex for Core Data to infer automatically — require a custom mapping model, an explicit description of how data from the old model's shape maps onto the new model's shape, which you author yourself (again, typically in Xcode's model editor) and which Core Data then uses to drive the migration instead of guessing. Recognizing which category a given schema change falls into — safe for lightweight migration, or requiring a custom mapping model — is a real, practical skill, and getting it wrong (assuming a complex restructuring will migrate automatically) is a common source of failed store loads in production.

Performance: Fetching Only What You Need, and Batch Operations

Core Data's convenience comes with real performance costs if used carelessly, and most of the practical performance work in a Core Data app comes down to a single idea: fetch, load, and touch only the data you actually need. relationshipKeyPathsForPrefetching on a fetch request tells Core Data to eagerly fetch specific relationships alongside the main results, avoiding a separate fault-fire (and separate round trip to the store) for each object's relationship the moment you access it; propertiesToFetch narrows a fetch to only specific attributes, useful when you need, say, just a list of names and don't want every other attribute of every object loaded.

For truly large result sets, fetchBatchSize (mentioned in the previous lesson) has real performance weight here too: rather than materializing an entire large result array's worth of faults up front, Core Data pages results in as you actually access them, keeping peak memory bounded regardless of how large the logical result set is. Combined with understanding faulting from earlier in this lesson, these settings are how you keep a Core Data app responsive even against a store with a very large number of rows.

For bulk changes — updating a status flag on every row matching some condition, or deleting every row older than some date — the most efficient approach is often not to fetch matching objects into memory at all. NSBatchUpdateRequest and NSBatchDeleteRequest operate directly against the persistent store, translating into efficient bulk SQL operations under the hood, without loading each affected row into a managed object first, tracking it for change, or holding it in memory. This can be dramatically faster and far lighter on memory than fetching a thousand objects, mutating each one, and saving — the tradeoff being that batch operations bypass the normal managed-object-context change tracking and faulting machinery entirely, so any already-in-memory managed objects affected by a batch operation need to be explicitly refreshed (or merged) to reflect it, since the context they live in was never told about the change.

Between this lesson and the previous one, you now have the complete arc: the stack that connects your application to a store file, the vocabulary for modeling an object graph, how to query it, how faulting keeps that querying cheap, how changes are tracked and saved, how those saves propagate between contexts, the non-negotiable threading rule that governs safe access, how a store's schema evolves across app versions, and how to keep the whole system fast as real data accumulates. Every one of these pieces maps back onto the same stack diagram from the previous lesson — application, persistent container, managed object context, persistent store, SQLite — and reasoning about a Core Data bug or performance question almost always comes down to asking which layer of that stack, and which of these concepts, is actually responsible for what you're seeing.

Code Examples

This shows the transparency that makes faulting easy to overlook: tasks.firstObject may hand back a fault, a lightweight placeholder, and nothing about the code that follows looks any different whether it's a fault or a fully loaded object. The instant .title is actually read, Core Data fires the fault behind the scenes and loads the real data — the calling code never explicitly asks for that to happen, which is exactly why reasoning about when the real cost of loading an object occurs requires understanding faulting, not just reading the code that touches the object.

NSLog(@"%@", firstTask.title);

This checks hasChanges first to avoid a pointless save call (and its disk I/O) when the context has nothing pending, then calls save: and checks its NSError ** out-parameter exactly the way every other Foundation error-producing API in this course has been handled. A real save failure — a validation error, a store-level I/O problem — is reported here rather than silently swallowed, which is the difference between a bug you can diagnose from a log and one that silently loses a user's data.

if (![context save:&error]) { performBackgroundTask: hands the block a private-queue background context already scheduled on its own correct queue, so the loop inserting Task objects runs safely off the main thread without ever touching viewContext directly. After saving that background context, NSPersistentContainer's background contexts are configured to automatically merge their saved changes into viewContext, which is what makes this pattern — heavy import work off the main thread, results appearing in the UI afterward — work without you writing the notification-observing merge code by hand. This block runs entirely on the background context's own private queue; nothing inside it should touch viewContext or any object belonging to a different context directly.

[self.persistentContainer performBackgroundTask:^(NSManagedObjectContext *bgContext) {

Rather than fetching every completed, overdue Task into memory, mutating a delete flag, and saving one object at a time, NSBatchDeleteRequest deletes every matching row directly in the store in one bulk operation — far more efficient for a large number of affected rows. Because a batch delete bypasses the normal context change-tracking machinery entirely, the store's own copy of the data changes but viewContext is never told automatically, which is why the result's object IDs are explicitly merged back in afterward with mergeChangesFromRemoteContextSave:intoContexts:, so any already-fetched Task objects in viewContext correctly reflect that they were deleted.

Common mistake: Forgetting to merge a batch operation's result back into any context holding affected objects leaves that context's in-memory objects silently stale — they'll still appear to exist even though the underlying rows are gone.

NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Task"];
NSArray<Task *> *tasks = [context executeFetchRequest:request error:nil];

Task *firstTask = tasks.firstObject;
// At this point, firstTask may still be an unfired fault: Core Data knows its
// entity and identity, but has not necessarily loaded "title" from the store yet.

NSLog(@"%@", firstTask.title);
// Accessing .title here fires the fault if it hasn't fired already: Core Data
// transparently loads the real attribute data from the store at this moment,
// then returns it — the calling code above never had to know the difference.
- (BOOL)saveContext:(NSManagedObjectContext *)context {
    if (![context hasChanges]) {
        return YES; // nothing pending, no need to touch the store
    }

    NSError *error = nil;
    if (![context save:&error]) {
        NSLog(@"Core Data save failed: %@, %@", error, error.userInfo);
        return NO;
    }
    return YES;
}
- (void)importTasksFromServerData:(NSArray<NSDictionary *> *)serverTasks {
    [self.persistentContainer performBackgroundTask:^(NSManagedObjectContext *bgContext) {
        for (NSDictionary *taskData in serverTasks) {
            Task *task = [NSEntityDescription insertNewObjectForEntityForName:@"Task"
                                                         inManagedObjectContext:bgContext];
            task.title = taskData[@"title"];
        }

        NSError *error = nil;
        if (![bgContext save:&error]) {
            NSLog(@"Background import save failed: %@", error);
            return;
        }
        // performBackgroundTask's context is configured to automatically merge
        // its saved changes into the persistentContainer's viewContext.
    }];
}
- (void)deleteCompletedTasksOlderThan:(NSDate *)cutoffDate {
    NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Task"];
    fetchRequest.predicate = [NSPredicate predicateWithFormat:
        @"isComplete == YES AND dueDate < %@", cutoffDate];

    NSBatchDeleteRequest *deleteRequest =
        [[NSBatchDeleteRequest alloc] initWithFetchRequest:fetchRequest];
    deleteRequest.resultType = NSBatchDeleteResultTypeObjectIDs;

    NSError *error = nil;
    NSBatchDeleteResult *result =
        [self.persistentContainer.viewContext executeRequest:deleteRequest error:&error];

    if (result != nil) {
        // The store was updated directly; merge the deleted IDs into viewContext
        // so any already-in-memory managed objects reflect the deletion too.
        NSArray *deletedIDs = result.result;
        [NSManagedObjectContext mergeChangesFromRemoteContextSave:
            @{ NSDeletedObjectsResultType: deletedIDs }
            intoContexts:@[self.persistentContainer.viewContext]];
    }
}

Where Core Data's Threading Rule Bites in Real Apps

The single most common real-world Core Data crash report is some variant of 'accessed a managed object (or its context) from the wrong thread' — often introduced when a background network callback, itself already off the main thread, touches a managed object fetched earlier on viewContext without routing back through performBlock:. Teams that adopt a strict convention — viewContext only ever touched from code that is already known to be on the main thread, and every background context operation wrapped in its own performBlock:/performBackgroundTask: — avoid the large majority of these bugs, which is exactly why this lesson treats the concurrency rule as one of its central topics rather than a footnote.

Calling context.save: directly from a background thread on a context whose concurrency type expects the main queue (or vice versa).

Wrap all operations on a context — fetch, save, insert — in that context's own performBlock: or performBlockAndWait:, matching its concurrency type.

A managed object context is tied to a specific queue by its concurrency type; operating on it directly from another thread is undefined behavior and a real source of crashes, not merely a style preference.

Assuming a fetched managed object's attributes are already fully loaded in memory simply because you hold a reference to it.

Remember a fetched object may still be an unfired fault — its real data loads transparently the first time a property is actually accessed, not necessarily at fetch time.

Faulting means the real cost, and the real data, of a managed object may be deferred until first access; reasoning about performance or data freshness without accounting for this leads to incorrect assumptions.

Assuming a background context's saved changes automatically and instantly appear in viewContext with no merging step at all, for every possible setup.

Rely on a documented merging mechanism — performBackgroundTask:'s automatic merge, notification-based mergeChangesFromContextDidSaveNotification:, or a parent/child context relationship — rather than assuming propagation just happens.

Each context maintains its own in-memory picture of the object graph; a save in one context does not automatically update a different context's already-loaded objects unless an explicit or configured merge mechanism is in place.

Assuming a complex model change (splitting an entity, restructuring relationships) will migrate automatically with lightweight migration.

Recognize when a change is beyond lightweight migration's inference and author a custom mapping model for it, rather than assuming automatic migration will handle anything.

Lightweight migration only handles changes Core Data can safely infer, like adding an attribute or a properly-annotated rename; more substantial restructuring needs an explicit custom mapping model or the store fails to migrate.

What is a Core Data 'fault'? A. An error returned when a fetch request fails B. A lightweight placeholder object that loads its real attribute data from the store only when actually accessed C. A corrupted row in the persistent store D. A synonym for a validation error during save

B. A lightweight placeholder object that loads its real attribute data from the store only when actually accessed — A fault is Core Data's lazy-loading placeholder: it knows its entity and identity but defers loading real attribute data until a property is actually accessed, at which point it 'fires' and loads transparently.

Why must context save:'s NSError ** out-parameter always be checked? A. It never actually gets populated, so checking is a formality B. save: can fail — due to validation errors, store I/O problems, or conflicts — and a failure means changes were not persisted C. Checking it is only necessary on a background context, never on viewContext D. The error parameter is used only for logging performance metrics

B. save: can fail — due to validation errors, store I/O problems, or conflicts — and a failure means changes were not persisted — save: can genuinely fail (a validation constraint violation, a store-level I/O error, a save conflict), and an unchecked failure means the caller believes changes were persisted when they were not.

What is the safe way to operate on a managed object context from a thread other than the one its concurrency type designates? A. Access it directly; Core Data automatically synchronizes all access internally B. Wrap the operations in that context's own performBlock: or performBlockAndWait: C. Convert the context to NSMainQueueConcurrencyType at runtime just before use D. Use @synchronized around every call to the context

B. Wrap the operations in that context's own performBlock: or performBlockAndWait: — performBlock:/performBlockAndWait: schedule work onto the context's own correct queue, which is the documented, safe way to operate on a context whose concurrency type doesn't match the calling thread; direct access from the wrong thread is undefined behavior.

What kind of model change is lightweight migration able to handle automatically? A. Splitting one entity into two entities with restructured relationships B. Adding a new attribute with a default value, or a properly-annotated rename C. Any change whatsoever, without exception D. Only changes to attribute types, never additions

B. Adding a new attribute with a default value, or a properly-annotated rename — Lightweight migration handles changes Core Data can safely infer on its own, like adding an attribute or a rename with a renaming identifier; more substantial restructuring (like splitting an entity) requires a custom mapping model.

Why would you use NSBatchDeleteRequest instead of fetching matching objects and deleting each one individually? A. NSBatchDeleteRequest is required whenever more than one object needs to be deleted B. It deletes matching rows directly in the store without loading each object into memory, which is much more efficient for bulk deletes C. It is the only way to delete objects that are currently faults D. It automatically merges its results into every context in the app with no further code needed

B. It deletes matching rows directly in the store without loading each object into memory, which is much more efficient for bulk deletes — NSBatchDeleteRequest operates directly against the persistent store, avoiding the cost of loading each matching object into a managed object context first — but its results still need to be explicitly merged into any context holding affected objects, since normal change tracking is bypassed.

How does a background context's saved changes typically reach the main-queue viewContext? A. They never do — each context is permanently isolated from every other context B. Through an explicit merge mechanism, such as performBackgroundTask:'s automatic merge or notification-based mergeChangesFromContextDidSaveNotification: C. viewContext polls the store on a timer and reloads everything automatically D. By directly reassigning managed objects from the background context to viewContext

B. Through an explicit merge mechanism, such as performBackgroundTask:'s automatic merge or notification-based mergeChangesFromContextDidSaveNotification: — Since each context maintains its own in-memory state, propagating another context's saved changes requires an explicit merge mechanism — performBackgroundTask:'s built-in merge, manual notification-based merging, or a parent/child context relationship.

Write a method that imports a large array of dictionaries into Task objects on a background context using performBackgroundTask:, saves that context, and then, after the save succeeds, safely logs the total count of Task objects now in viewContext (a value that must be read on viewContext's own correct queue, not assumed from the background context).

[self.persistentContainer performBackgroundTask:^(NSManagedObjectContext *bgContext) { for (NSDictionary *d in data) { Task *t = [NSEntityDescription insertNewObjectForEntityForName:@"Task" inManagedObjectContext:bgContext]; t.title = d[@"title"]; } NSError *error = nil; if ([bgContext save:&error]) { [self.persistentContainer.viewContext performBlock:^{ NSFetchRequest *fr = [NSFetchRequest fetchRequestWithEntityName:@"Task"]; NSUInteger count = [self.persistentContainer.viewContext countForFetchRequest:fr error:nil]; NSLog(@"Task count: %lu", (unsigned long)count); }]; } else { NSLog(@"Import failed: %@", error); } }];

After bgContext saves and its results have merged into viewContext, dispatch the count-and-log step through [self.persistentContainer.viewContext performBlock:^{ ... }] rather than touching viewContext directly from inside the background block.