6.1 — Core Data

Opening scenario

It’s Monday. Your PM walks over: “The journal app needs offline. Users complain that everything disappears when they lose signal on the subway.” You nod. You’ve been here before. The decision tree starts spinning: UserDefaults (no — relational data), files (no — querying), SQLite directly (no — you’d cry), Realm (third-party, abandoned-ish since MongoDB bought them), SwiftData (new, lots of gotchas pre-iOS 17.4), Core Data (15 years old, battle-tested, still under the hood of half the apps on your phone).

You pick Core Data. Not because it’s elegant. Because Apple Notes uses it, Mail uses it, Photos uses it, and when your app has 50,000 records and CloudKit sync and a migration from v1 to v7 schema — Core Data has already solved that, painfully, for a decade.

ContextWhat it usually means
Reads “stack setup”Knows NSPersistentContainer replaced the manual stack in iOS 10
Reads “background context”Has been bitten by viewContext deadlocks in production
Reads “lightweight migration”Has shipped a v2 model and watched 1% of users crash on launch
Reads “fetched results controller”Has a UIKit background, or maintains a legacy app
Reads “Core Data is dead, use SwiftData”Hasn’t shipped anything with SwiftData to >10k users yet

Concept → Why → How → Code

Concept

Core Data is not a database. It’s an object graph and persistence framework. It happens to use SQLite by default, but that’s an implementation detail. The mental model: you describe entities and relationships in a .xcdatamodeld schema, Core Data manages a graph of NSManagedObject instances in memory inside a NSManagedObjectContext, and you call save() to flush changes to the persistent store.

Why

You want this when you have:

  • Relational data (a journal has many entries, each entry has many tags)
  • Querying (NSPredicate, sort descriptors, faulting for memory)
  • Offline-first behavior
  • Sync (Core Data + CloudKit is the only first-party offline-sync solution Apple ships)
  • Migrations that survive multiple app versions

You don’t want this for: a list of recent searches, user preferences, a download cache, anything you’d be happy losing. UserDefaults and Codable to disk are fine for that.

How

The modern stack (iOS 10+) is three lines:

import CoreData

final class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Journal")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.loadPersistentStores { description, error in
            if let error {
                fatalError("Core Data failed to load: \(error)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

The viewContext is the main-queue context — use it for UI. For anything that touches more than a handful of rows, use a background context:

func importEntries(_ payload: [EntryDTO]) async throws {
    try await container.performBackgroundTask { context in
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        for dto in payload {
            let entry = Entry(context: context)
            entry.id = dto.id
            entry.title = dto.title
            entry.body = dto.body
            entry.createdAt = dto.createdAt
        }
        try context.save()
    }
}

Code — the full CRUD loop

Define Entry in Journal.xcdatamodeld with attributes id: UUID, title: String, body: String, createdAt: Date, and let Xcode codegen the NSManagedObject subclass (codegen = Class Definition).

Create:

@MainActor
func addEntry(title: String, body: String) throws {
    let context = PersistenceController.shared.container.viewContext
    let entry = Entry(context: context)
    entry.id = UUID()
    entry.title = title
    entry.body = body
    entry.createdAt = .now
    try context.save()
}

Read (in SwiftUI):

struct EntryListView: View {
    @FetchRequest(
        sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
        animation: .default
    )
    private var entries: FetchedResults<Entry>

    var body: some View {
        List(entries) { entry in
            VStack(alignment: .leading) {
                Text(entry.title ?? "Untitled").font(.headline)
                Text(entry.createdAt ?? .now, style: .date)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Update: mutate the NSManagedObject properties and save(). Core Data tracks the change automatically.

Delete:

func delete(_ entry: Entry) throws {
    let context = entry.managedObjectContext ?? PersistenceController.shared.container.viewContext
    context.delete(entry)
    try context.save()
}

NSFetchedResultsController — still relevant in 2026

In SwiftUI you’ll mostly use @FetchRequest. In UIKit (or anywhere you need controlled diffing into a UITableView/UICollectionView), NSFetchedResultsController is the workhorse:

let request: NSFetchRequest<Entry> = Entry.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Entry.createdAt, ascending: false)]

let frc = NSFetchedResultsController(
    fetchRequest: request,
    managedObjectContext: PersistenceController.shared.container.viewContext,
    sectionNameKeyPath: nil,
    cacheName: nil
)
frc.delegate = self
try frc.performFetch()

The delegate methods (controller(_:didChange:at:for:newIndexPath:)) hand you precise diffs you feed into a UITableViewDiffableDataSource. This is how Apple Mail’s inbox list updates without flicker when 50 messages arrive at once.

Migrations — the part everyone gets wrong

Lightweight migration (Apple infers the mapping): add a non-required attribute, add a new entity, rename via the “Renaming ID” model field. Enable it by setting both options:

let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true

Heavyweight migration (you write a NSMappingModel): required when you split one entity into two, merge attributes with logic, or transform data. You ship a .xcmappingmodel file and optionally an NSEntityMigrationPolicy subclass.

The rule that will save your job: every shipped schema version stays in the project forever. Name them Journal.xcdatamodel (v1), Journal 2.xcdatamodel (v2), etc. The current version is set in the .xcdatamodeld inspector. Migrating from v3 → v7? Core Data needs v3, v4, v5, v6, v7 all present to chain the migrations.

In the wild

  • Apple Notes runs on Core Data + CloudKit. The schema has dozens of versions accumulated since 2012. They use heavyweight migrations for major iCloud schema bumps.
  • Things 3 (Cultured Code) uses Core Data with a custom sync layer (not CloudKit — they shipped before CloudKit was viable). Their migrations are bulletproof; the app has been continuously installable since 2017.
  • Day One Journal moved off Core Data to Realm, then announced (2023) a partial move back to Core Data via Swift Data for the sync layer. The takeaway: third-party storage frameworks always look better in the demo, worse in year five.
  • Bear uses Core Data + CloudKit for sync. Their public postmortem of a 2022 sync bug is one of the best free Core Data lessons on the internet.

Common misconceptions

  1. “Core Data is a database.” No. It’s an object graph that can persist to SQLite (default), XML, binary, or in-memory. Treat it as an in-memory graph that flushes on save().
  2. viewContext is thread-safe.” It is safe only on the main queue. Touch it from a background thread and you get nondeterministic crashes, often months after the change ships.
  3. save() on the background context updates viewContext automatically.” Only if automaticallyMergesChangesFromParent = true is set on viewContext and the contexts share the same persistent coordinator. The default is false.
  4. “Faulting is a bug.” Faulting is Core Data not loading row data until you access it. It’s the entire reason Core Data scales to 100k records on a phone. Don’t fight it; iterate over the keys you need.
  5. “You don’t need migrations if no users have the old schema.” TestFlight users do have the old schema. App Store reviewers do update from previous binaries. Skipping a migration costs you a one-star review and possibly a rejection.

Seasoned engineer’s take

Core Data has the worst API surface in Apple’s catalog. It is also, by a wide margin, the most reliable persistence framework on the platform. Every team I’ve watched migrate from Core Data to something “simpler” — Realm, GRDB, raw SQLite via SQLite.swift, even SwiftData in 2024 — has either come back, or has built something that ships fewer features per quarter while burning more engineer-hours on data layer bugs.

The investment is real: spend a week understanding contexts, faulting, and migrations before you ship v1. After that week, Core Data fades into the background of your project and stops being a source of bugs. Skip that week and you’ll spend the next two years writing increasingly clever workarounds for things Core Data already does, which is exactly what most Realm-to-CoreData postmortems describe.

For new apps in 2026: I still recommend Core Data over SwiftData for any project that ships before iOS 17.4 is the floor and needs CloudKit sync, because SwiftData’s sync story remains noticeably rougher than NSPersistentCloudKitContainer. If you’re iOS 17.4+ only and the data model is simple, SwiftData is fine and the ergonomics are dramatically better.

TIP: Wrap every try context.save() in a do/catch that logs the NSError userInfo dictionary, not just error.localizedDescription. Core Data validation errors are buried in NSDetailedErrorsKey and you cannot debug them without that data.

WARNING: Never store image or video blobs in a Core Data attribute marked “Allows External Storage” without setting a size threshold. The “external” files are managed by Core Data and orphaned files don’t get cleaned up if your migration fails. Store the file path; keep the bytes on disk.

Interview corner

Junior: “How do you read and write data with Core Data?”

Set up an NSPersistentContainer with the model name. Use container.viewContext for the main queue. Create an NSManagedObject subclass, set its properties, call save(). Read with an NSFetchRequest or, in SwiftUI, @FetchRequest.

Mid: “Walk me through threading in Core Data.”

Every NSManagedObject is bound to the context that created it. viewContext is main-queue-only. For heavy work use container.performBackgroundTask or a newBackgroundContext(). Never pass managed objects between contexts — pass NSManagedObjectID and re-fetch on the destination context. Set automaticallyMergesChangesFromParent and a merge policy on viewContext so background saves flow to the UI.

Senior: “Design a migration from a schema where Entry.tags: String (comma-separated) becomes Entry.tags: [Tag] (many-to-many).”

Lightweight won’t handle this — the data needs reshaping. Create model v2 with the new Tag entity and the relationship. Add a .xcmappingmodel from v1 → v2. Subclass NSEntityMigrationPolicy and override createDestinationInstances(forSource:in:manager:) to split the comma-separated string, dedupe tags across entries (use a manager userInfo dictionary as a [String: Tag] cache to avoid duplicates), and wire the relationship. Ship v1 and v2 model files in the bundle so users on any prior version can chain through. Test the migration on a copy of a real production store before release.

Red flag: “We don’t use migrations — we just delete the old store on schema change.”

Tells the interviewer you have never shipped to a real user base and don’t understand that this destroys all user data on update. Instant downgrade in level discussion.

Lab preview

Lab 6.1 — Journal App with SwiftData is the modern counterpart to this chapter; building the same app on Core Data is left as a stretch goal in that lab so you can compare the two APIs side-by-side.


Next: SwiftData