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.
| Context | What 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
- “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(). - “
viewContextis 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. - “
save()on the background context updatesviewContextautomatically.” Only ifautomaticallyMergesChangesFromParent = trueis set onviewContextand the contexts share the same persistent coordinator. The default isfalse. - “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.
- “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 ado/catchthat logs theNSErroruserInfo dictionary, not justerror.localizedDescription. Core Data validation errors are buried inNSDetailedErrorsKeyand 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
NSPersistentContainerwith the model name. Usecontainer.viewContextfor the main queue. Create anNSManagedObjectsubclass, set its properties, callsave(). Read with anNSFetchRequestor, in SwiftUI,@FetchRequest.
Mid: “Walk me through threading in Core Data.”
Every
NSManagedObjectis bound to the context that created it.viewContextis main-queue-only. For heavy work usecontainer.performBackgroundTaskor anewBackgroundContext(). Never pass managed objects between contexts — passNSManagedObjectIDand re-fetch on the destination context. SetautomaticallyMergesChangesFromParentand a merge policy onviewContextso 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
Tagentity and the relationship. Add a.xcmappingmodelfrom v1 → v2. SubclassNSEntityMigrationPolicyand overridecreateDestinationInstances(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