A deep, hands-on guide to Core Data in Swift on iOS. We’ll work through the entire framework — the stack, the data model, CRUD, the unforgiving concurrency model, performance optimization, migrations, CloudKit sync — and then show how Core Data integrates with SwiftUI in two ways: the modern property-wrapper path (@FetchRequest, @Environment(\.managedObjectContext)) that’s expedient and good for many apps, and the manual path (custom repositories, NSFetchedResultsController, hand-rolled ObservableObjects) that gives you full control for testable, layered architectures. iOS-specific examples throughout, with attention to the gotchas that bite real apps.
Table of Contents
- What Core Data Is (and Isn’t)
- The Core Data Stack: Container, Coordinator, Contexts, Store
- The Data Model: Entities, Attributes, Relationships
- NSManagedObject and Code Generation
- CRUD: Creating, Reading, Updating, Deleting
- Saving Contexts and the Save Lifecycle
- NSFetchRequest in Detail
- NSPredicate Mastery
- Sort Descriptors and Result Limits
- Relationships: To-One, To-Many, Many-to-Many
- Inverse Relationships and Why They Matter
- Delete Rules
- Context Concurrency Types
- Background Contexts and perform/performAndWait
- Parent-Child Contexts
- Concurrency Pitfalls and Object IDs
- Faulting and Object Lifecycles
- Batch Operations: Insert, Update, Delete
- Fetch Optimization
- NSFetchedResultsController
- SwiftUI: @FetchRequest and @SectionedFetchRequest
- SwiftUI: @Environment(.managedObjectContext)
- SwiftUI: Observing NSManagedObject Changes
- SwiftUI Without Property Wrappers: The Repository Approach
- SwiftUI Without Property Wrappers: NSFetchedResultsController Bridges
- SwiftUI Without Property Wrappers: Combine and AsyncSequence
- SwiftUI Without Property Wrappers: Hand-Rolled ObservableObject
- Migrations: Lightweight and Manual
- CloudKit Integration with NSPersistentCloudKitContainer
- Performance Profiling and Debugging
- Common Gotchas and Anti-Patterns
- Where to Go Deeper
1. What Core Data Is (and Isn’t)
Core Data has a reputation. Some teams swear by it, build entire architectures around it, and would never use anything else. Others got burned by it years ago and won’t touch it. Both reactions usually trace back to the same root cause: misunderstanding what Core Data actually is.
Core Data is an object graph manager
The single most important sentence in this guide: Core Data is an object graph manager that happens to support persistence. It is not an ORM. It is not a database. It is not “SQLite with a Swift wrapper.” Internalizing this changes how you reason about it.
What it means in practice: Core Data manages a graph of objects in memory. Those objects (NSManagedObject instances) know about their relationships to each other. The graph supports change tracking (which objects have been inserted, updated, deleted), undo and redo, validation, faulting (lazy materialization of objects from disk), and KVO-based observation. Persistence — saving the graph to a backing store — is one capability among several.
You can use Core Data with no persistent store at all (an in-memory store, useful for tests). The graph behaves the same way. The only difference is that save() doesn’t write anything to disk.
What Core Data is NOT
A list of common misconceptions, with the corrections:
-
Not an ORM in the relational sense. In a typical ORM, you map classes to tables, write queries in SQL or a SQL-like DSL, and the framework translates. Core Data has its own query language (
NSPredicateandNSFetchRequest), its own model file format (.xcdatamodeld), and abstracts over the storage entirely. You don’t write SQL; you describe what you want, and Core Data figures it out. -
Not “SQLite with a wrapper.” While the default persistent store on iOS is SQLite, the SQL is hidden, the schema is generated by Core Data from your model, and you don’t directly control table layout. If you want SQL access, use GRDB or SQLite.swift.
-
Not just for displaying data. Core Data scales to large graphs, supports complex relationship semantics, validation rules, derived properties, and conflict resolution between concurrent edits. It’s appropriate for serious data layers, not just “display some rows.”
-
Not slow by default. Core Data has performance pitfalls (we’ll cover them), but a properly used Core Data stack is fast — comparable to direct SQLite for typical workloads. Most “Core Data is slow” stories are stories about misuse: faulting in a tight loop, no indexes, no batch fetching, blocking the main thread.
-
Not single-threaded. Core Data has a clear concurrency model (every context is bound to a queue). It’s strict and easy to violate, but it’s not “use one context everywhere.”
When Core Data fits
Use Core Data when you have:
- An object graph with relationships that matter — a
Userhas manyPosts, aPostbelongs to oneUserand has manyComments. The relationships aren’t incidental; they’re how your app naturally thinks. - Need for change tracking — knowing what’s been modified since the last save, undoing, observing.
- Need for fetched results that update the UI as data changes (the
NSFetchedResultsControlleruse case — table views, collection views, and SwiftUI lists). - Multiple parts of your app needing consistent views of the same data (the “single source of truth” property comes mostly for free).
- A model you’ll evolve over time. Core Data has migration support — lightweight for compatible changes, custom for complex ones.
- Apple-platform-only sync via CloudKit.
NSPersistentCloudKitContaineris the easiest way to add sync between a user’s devices.
When something else fits better
- You need raw SQL access. Use GRDB. You’ll write SQL, and you’ll be happy.
- You need cross-platform sync (iOS + Android). Look at Realm, Couchbase Lite, or design around your own backend with REST/GraphQL.
- Your data is small and key-value. UserDefaults or a small JSON file might suffice. Don’t bring Core Data for ten preferences.
- You need real-time multi-user collaborative editing. Look at CRDT libraries or Firebase / Realtime Database. Core Data’s conflict resolution isn’t designed for live coediting.
- You’re targeting iOS 17+ and want a Swift-native API. Consider SwiftData. It’s built on Core Data internally but with
@Model,@Query, value-type-feeling, and macros instead of.xcdatamodeldfiles.
Core Data and SwiftData
A note since SwiftData is now in the picture: SwiftData is, in a real sense, Core Data with a different API surface. Same store format under the hood (it can even share an existing Core Data SQLite file if you map carefully). Same concurrency model. Same engine. Wrapped in macros and Swift-native types instead of NSManagedObject subclasses and the model editor.
If you’re starting fresh on iOS 17+ with no existing Core Data, SwiftData is often the right choice — it’s Swift-native, type-safe, and a more modern API. But Core Data remains relevant because:
- A vast number of apps already use Core Data and aren’t going to migrate.
- Some advanced scenarios still require Core Data (custom migration policies, certain CloudKit configurations, fine control over the stack).
- SwiftData is younger and has rough edges, especially around complex predicates, sectioning, and migrations.
- Understanding Core Data makes you a much better SwiftData developer because the underlying mental model is the same.
This guide covers Core Data directly. The mental model and most patterns transfer to SwiftData with minimal adjustment.
Architecture-level placement
In a layered architecture, Core Data sits at the persistence layer. Above it: repositories, services, view models, views. Below it: the file system / SQLite, or CloudKit.
Where you draw the line between “Core Data” and the rest of your app matters a lot. Two extreme positions:
- NSManagedObject everywhere. Pass
NSManagedObjectinstances directly to view models and views. Easiest, fastest to build, tightest coupling. - NSManagedObject only inside the persistence layer. Map to plain Swift value types (DTOs / domain models) at the boundary. Every read produces value types; every write takes them.
Most production apps land somewhere in between. SwiftUI’s @FetchRequest strongly encourages “NSManagedObject everywhere” for the view layer — it gives you NSManagedObjects directly. We’ll cover both styles thoroughly.
What you’ll learn from this guide
By the end you should be comfortable with:
- The Core Data stack and how its components fit together.
- Designing a data model with the right entities, attributes, relationships, and indexes.
- Writing efficient fetches with
NSPredicateand sort descriptors. - The concurrency model and using background contexts safely.
- Observing changes and keeping the UI in sync — with SwiftUI’s property wrappers and without.
- Performance optimization for large datasets (faulting, prefetching, batches).
- Migrating data models without losing user data.
- Integrating with CloudKit.
- Profiling slow fetches and diagnosing concurrency bugs.
What to internalize
Core Data is an object graph manager that persists. It’s not SQL, not an ORM, not a database. Use it when your app’s natural shape is an object graph with relationships, change tracking, and observation needs. Use something else when you need raw SQL, cross-platform sync without Apple’s stack, or a smaller data set than a graph requires. SwiftData is Core Data with a different face — the patterns transfer.
2. The Core Data Stack: Container, Coordinator, Contexts, Store
The “Core Data stack” is the set of objects that, together, make Core Data work. Knowing each component and how they connect is essential — because when something goes wrong, you need to know which layer to look at.
The components
From the bottom up:
NSPersistentStore— the actual storage. By default an SQLite file on disk; can also be in-memory, XML (macOS), or binary. You usually have one persistent store, though you can have multiple (e.g., a CloudKit-synced store plus a local-only store).NSPersistentStoreCoordinator— coordinates between contexts and persistent stores. Mediates fetches and saves. Most apps have exactly one.NSManagedObjectModel— the schema. Loaded from your.xcdatamodeldfile at runtime. Describes entities, attributes, relationships.NSManagedObjectContext— the working area. Holds the in-memory object graph, tracks changes, validates on save. Apps typically have multiple contexts (one for the main thread / UI, several for background work).NSPersistentContainer— the modern convenience type that wires up all of the above with sensible defaults.
When you create an NSPersistentContainer, it builds the model, the coordinator, the store, and a main-queue context for you. You almost always want this convenience type unless you’re writing a custom stack for advanced scenarios.
Setting up a basic stack
The minimal modern setup:
import CoreData
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
// "Model" matches the name of your .xcdatamodeld file (without extension).
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error as NSError? {
fatalError("Unresolved Core Data error: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
A few things worth unpacking:
-
name: "Model"matches the filename of your.xcdatamodeld(without extension). The container loadsModel.xcdatamodeldand finds the SQLite file in your app’s Application Support directory by default. -
loadPersistentStoresis asynchronous in principle — it can take time on first launch (creating the SQLite file, running migrations). The completion handler runs synchronously after the load in this code, but the call returns to the caller before storage is fully set up if you don’t wait. In practice you wait synchronously here at app launch. -
inMemory: trueconfigures the store to use/dev/nullas the file URL, which the SQLite store interprets as “in-memory only.” Useful for tests and SwiftUI previews. -
automaticallyMergesChangesFromParentis critical. When background contexts save, their changes need to propagate toviewContextso your UI updates. Setting this means Core Data does the merging for you. Without it, you’d need to listen forNSManagedObjectContextDidSavenotifications and merge manually.
Where the SQLite file lives
By default, the SQLite store goes in Library/Application Support/<your-app-bundle-id>/. You can find the path:
print(container.persistentStoreDescriptions.first?.url?.path ?? "no store URL")
In the simulator, it’ll look something like:
~/Library/Developer/CoreSimulator/Devices/<device-uuid>/data/Containers/Data/Application/<app-uuid>/Library/Application Support/MyApp/Model.sqlite
There are actually three files: Model.sqlite, Model.sqlite-shm (shared memory), and Model.sqlite-wal (write-ahead log). All three are part of the database. The -wal file in particular can be substantial — it accumulates transactions until checkpointed. If you’re inspecting database size, look at all three.
You can open the SQLite file in any SQLite browser to peek at the schema. You’ll see tables like ZUSER, ZPOST, with columns prefixed Z (Core Data’s convention). Don’t modify anything by hand — Core Data has invariants you’d violate.
The view context
container.viewContext is the context tied to the main queue. It’s where you do UI-related Core Data work: fetching for display, observing changes, lightweight edits. It’s automatically NSMainQueueConcurrencyType.
You access it everywhere in your UI code:
let users: [User] = try context.fetch(User.fetchRequest())
Where context is container.viewContext (passed into your view models or accessed via SwiftUI environment).
Background contexts
For heavy work — large imports, batch processing, anything that would block the UI — use a background context:
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
// Work here runs on the context's private queue.
for json in incomingData {
let user = User(context: backgroundContext)
user.name = json["name"] as? String ?? ""
}
try? backgroundContext.save()
}
newBackgroundContext() creates a new context with NSPrivateQueueConcurrencyType, parented to the persistent store coordinator (not to viewContext). Each call returns a fresh context. Don’t reuse one across unrelated operations; create one per task or per background subsystem.
Stack diagram (textually)
┌──────────────────────────┐
│ NSPersistentContainer │
│ (the convenience wrap) │
└────────────┬─────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
┌───────▼──────────┐ ┌──────────▼────────────┐ ┌────────▼─────────────┐
│ viewContext │ │ background context A │ │ background context B │
│ (main queue) │ │ (private queue) │ │ (private queue) │
└──────────┬───────┘ └──────────┬─────────────┘ └──────────┬────────────┘
│ │ │
└─────────┬───────────┴────────────────────────────┘
│
┌─────────────▼──────────────┐
│ NSPersistentStoreCoordinator │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ NSPersistentStore │
│ (SQLite at Model.sqlite) │
└────────────────────────────┘
Each context has its own copy of the object graph (its own NSManagedObject instances), but they share the underlying store via the coordinator. A save in one context goes through the coordinator to the store; if another context has automaticallyMergesChangesFromParent = true, it picks up the changes.
Configuration options on the container
A few options worth knowing:
let description = container.persistentStoreDescriptions.first!
// Enable persistent history tracking — required for many features (CloudKit, batch operations).
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Enable remote change notifications — get notified when another context (including outside your process) changes data.
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
NSPersistentHistoryTrackingKey enables a write-ahead log that records every change. This is required if you want to use batch operations and have those changes reflected in contexts (we’ll cover this in section 18). It’s also required for NSPersistentCloudKitContainer. Best practice: enable it from the start unless you have a specific reason not to.
NSPersistentStoreRemoteChangeNotificationPostOptionKey makes Core Data post NSPersistentStoreRemoteChange notifications when something changes in the store, useful when multiple processes (your app + a Share extension, for instance) share the same store via an app group.
Integrating with SwiftUI
For SwiftUI, the persistence controller exposes its viewContext via the environment:
@main
struct MyApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
Then any view can access it:
struct ContentView: View {
@Environment(\.managedObjectContext) private var context
var body: some View { /* ... */ }
}
Or use @FetchRequest for declarative fetches. We’ll cover both extensively.
Common stack mistakes
Three mistakes I see frequently:
-
Treating the container as a heavyweight singleton you initialize lazily. You really want it created and loaded at app launch, before any UI tries to use it. Lazy initialization on first use leads to weird timing bugs.
-
Making the persistence controller’s
initasync or throwing. It’s fine forinitto callloadPersistentStoressynchronously andfatalErroron failure. A failed Core Data load means the user can’t use your app; there’s nothing you can do at runtime to recover. -
Forgetting
automaticallyMergesChangesFromParent. Without it, your background imports save successfully but the UI doesn’t update. You’ll spend an afternoon debugging “why don’t I see the new data” before realizing the merge is not automatic.
What to internalize
The Core Data stack is NSPersistentContainer (modern convenience) wrapping a model, a coordinator, persistent stores, and one or more contexts. viewContext is on the main queue for UI. newBackgroundContext() gives you a private-queue context for heavy work. Set automaticallyMergesChangesFromParent = true on viewContext. Enable persistent history tracking. Initialize the container synchronously at launch.
3. The Data Model: Entities, Attributes, Relationships
The data model is the schema for your object graph. It lives in a .xcdatamodeld file in your project. The model editor is a visual tool, but everything it does ultimately produces an NSManagedObjectModel at runtime.
Creating the model file
In Xcode: File → New → File → Data Model. Name it whatever your NSPersistentContainer(name:) expects — typically the same as your project name or just “Model”.
The file is actually a directory (.xcdatamodeld) containing one or more .xcdatamodel versions. Each version is a snapshot; when you change the schema in a way that requires migration, you create a new version.
Entities
An entity is roughly equivalent to a class — it’s a named, structured shape with attributes and relationships. In SQL terms, an entity becomes a table.
To create one: in the model editor, click “Add Entity”. Give it a name. Conventionally, entity names are PascalCase singular: User, Post, Comment, not users or Posts.
Each entity has:
- A name (used in fetches:
NSFetchRequest<NSManagedObject>(entityName: "User")). - Optionally a parent entity (Core Data supports entity inheritance — rarely needed; we’ll cover briefly).
- A class name (defaults to
NSManagedObjectbut you’ll usually subclass). - A set of attributes.
- A set of relationships.
- A set of fetch indexes (for query performance).
- Optionally a constraint (uniqueness rules).
Attributes
An attribute is a typed field on an entity. Types available:
- Integer 16, 32, 64 — different bit widths. Use
Integer 64for general-purpose integers. - Decimal — base-10 decimal. For currency and precise values where floating-point error is unacceptable.
- Double / Float — floating-point.
- Boolean — true/false. Stored as 0/1 internally.
- Date —
Datevalue. - String — variable-length text.
- Binary Data — raw
Data. The model editor has a “Allows External Storage” option that stores large blobs outside the SQLite file (in Application Support). - UUID — stored efficiently; great for entity identifiers.
- URI — stored as a URL.
- Transformable — anything
Codableor supportingNSSecureCoding. We’ll discuss in detail.
For each attribute you can configure:
- Optional. Whether nil is allowed. Warning: in the model editor, “Optional” defaults to checked — if you uncheck it, you must always provide a value before save, or save fails with a validation error.
- Default value. The value used when an instance is first created.
- Indexed in Spotlight, Stored in External Record File — features for system search integration. Mostly not used in modern apps.
- Use Scalar Type — for numeric and Boolean attributes, generates
Int64,Bool, etc. instead ofNSNumber. Almost always what you want for Swift. - Validation — minimum/maximum, regex patterns. Validation runs at save time.
For Date attributes, “Use Scalar Type” gives you Date instead of NSDate. You almost always want this on iOS.
A first model
Let’s design a simple model for a notes app. We’ll have Folder, Note, and Tag:
Folder
├── id: UUID
├── name: String
├── createdAt: Date
└── notes -> Note (to-many)
Note
├── id: UUID
├── title: String
├── body: String
├── createdAt: Date
├── updatedAt: Date
├── folder -> Folder (to-one, inverse of Folder.notes)
└── tags -> Tag (to-many)
Tag
├── id: UUID
├── name: String
└── notes -> Note (to-many, inverse of Note.tags)
In the model editor:
- Add three entities:
Folder,Note,Tag. - For each, add the attributes listed.
- Set “Use Scalar Type” on all the scalar attributes.
- Set defaults:
id = UUID()(you can’t actually set this in the editor; we’ll set it in code),createdAt = Date()(similar), or leave the defaults blank and set them programmatically on insert. - Add the relationships (we’ll cover the details in section 10).
Optional vs required: the trap
The “Optional” checkbox on attributes is one of Core Data’s most common gotchas.
If Optional is unchecked (i.e., the attribute is required), Core Data validates at save time that the attribute has a value. If you’ve configured Use Scalar Type for, say, an Int64, the runtime type is Int64 (non-optional) — but Core Data still treats “0” as a missing value in some contexts. Stick with consistent decisions:
- Make the attribute required + provide a default value in the model. The default ensures save validation passes.
- Or make the attribute optional + handle nil in your code.
Avoid: required + no default + relying on yourself to always set it. You’ll save without setting it once and crash.
For UUIDs and dates that should always have a value, my recommendation:
- Make them required.
- Don’t set a default in the model (the editor doesn’t let you specify
UUID()orDate()). - Use the “awakeFromInsert” hook in your NSManagedObject subclass (we’ll see this in section 4) to set them programmatically.
public override func awakeFromInsert() {
super.awakeFromInsert()
self.id = UUID()
self.createdAt = Date()
}
This runs once when the object is first inserted, before save. Required attributes get values; validation passes.
Transformable attributes
A Transformable attribute stores any object that supports NSSecureCoding. This includes things like [String], [String: String], custom Codable objects (with extra setup), UIColor, etc.
Setup:
- Set the attribute’s type to
Transformable. - Optionally specify a custom
Value Transformer Nameif you have a non-default transformer. - Optionally specify the
Custom Classfor compile-time type safety.
The default transformer is NSSecureUnarchiveFromDataTransformer, which uses NSSecureCoding. For Swift Codable types, you’ll usually write a custom transformer or a manual codable bridge.
@objc(StringArrayValueTransformer)
final class StringArrayValueTransformer: NSSecureUnarchiveFromDataTransformer {
static let name = NSValueTransformerName(rawValue: "StringArrayValueTransformer")
override class var allowedTopLevelClasses: [AnyClass] {
[NSArray.self, NSString.self]
}
static func register() {
let transformer = StringArrayValueTransformer()
ValueTransformer.setValueTransformer(transformer, forName: name)
}
}
Register at app launch:
StringArrayValueTransformer.register()
In the model editor, set the attribute’s Value Transformer Name to StringArrayValueTransformer.
For larger blobs, prefer Binary Data with “Allows External Storage” — Core Data offloads large blobs to separate files automatically.
Indexes
For attributes you query against frequently, add a fetch index. In the model editor, select an entity and look at the “Indexes” section.
A fetch index is a database index — it makes WHERE clauses on that attribute fast. Without an index, queries on a column do a full scan; with one, they hit the index directly.
Add indexes on:
- Foreign-key-like attributes you filter by (your
id,userID, etc.). - Date attributes you sort by (
createdAt,updatedAt). - Attributes used in predicates of fetches that run often.
Don’t add indexes on attributes you rarely query — they slow down writes.
A composite index covers multiple columns: (folder, createdAt) for “notes in folder X sorted by date”. Composite indexes only help when the query uses the leftmost columns first.
Constraints
You can add uniqueness constraints to an entity. In the model editor, select the entity, look at “Constraints”, and add a constraint listing the attributes that must together be unique.
Common: a unique constraint on id so you can’t have two User rows with the same UUID.
User constraint: id
When you save with a violation, Core Data either fails the save or — with the right merge policy — automatically resolves by replacing/merging. You configure the merge policy on the context:
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Available policies:
NSErrorMergePolicy(default) — fail the save with an error.NSMergeByPropertyStoreTrumpMergePolicy— store wins; in-memory changes lost.NSMergeByPropertyObjectTrumpMergePolicy— in-memory wins; store overwritten.NSOverwriteMergePolicy— overwrite the store.NSRollbackMergePolicy— discard in-memory changes.
For an “upsert” pattern (insert if missing, update if present), use NSMergeByPropertyObjectTrumpMergePolicy plus a unique constraint on the identifier.
Configurations
Configurations let you assign entities to specific persistent stores. Most apps don’t use this, but it’s useful when you have:
- Some entities synced via CloudKit, others local-only.
- Some entities in a read-only seed store, others in a writable store.
In the editor’s “Configurations” section, you create named configurations and assign entities to them. Then in code, you assign each configuration to a specific store.
Versioning
When you change the model in a way that requires migration, you don’t edit the existing version — you create a new version. Editor menu: Editor → Add Model Version.
The current version is selected via the model file’s “Versioned Model” inspector. When the new version differs from the deployed version on user devices, Core Data attempts migration on next launch (lightweight if possible, heavy otherwise). Section 28 covers this in detail.
What to internalize
Your model file contains entities (think: tables) with attributes (columns) and relationships (foreign keys). Optional vs required is significant; use awakeFromInsert to set required values like UUIDs and dates. Use scalar types in Swift. Add indexes to attributes you query. Use constraints + merge policies for upsert semantics. Use Transformable carefully; prefer Binary Data with external storage for large blobs.
4. NSManagedObject and Code Generation
Every entity in your model has a corresponding runtime class — by default, NSManagedObject directly. For real apps you almost always create a subclass per entity. The subclass gives you typed properties, custom methods, and a place to attach behavior.
Xcode can generate these subclasses for you, manually or automatically. Understanding the options matters: pick the wrong one and you’ll have duplicate-symbol errors, missing properties, or constant manual regeneration.
Three codegen options
In the model editor, select an entity, open the inspector (right side), and look at “Codegen”. Three choices:
- Manual/None — Core Data generates nothing. You write the entire
NSManagedObjectsubclass by hand. - Class Definition (default) — Core Data generates the subclass invisibly at build time, in DerivedData. You don’t see the file in your project, but the class exists and can be referenced.
- Category/Extension — Core Data generates an extension to a class you provide. You write the class skeleton; Core Data generates
@NSManagedproperties for the attributes/relationships.
For non-trivial apps, Manual/None is usually the right choice. You get full control, no generated code in DerivedData behaving like magic, and the subclass is visible in your project.
Writing an NSManagedObject subclass manually
For our Note entity:
import Foundation
import CoreData
@objc(Note)
public final class Note: NSManagedObject {
@NSManaged public var id: UUID
@NSManaged public var title: String
@NSManaged public var body: String
@NSManaged public var createdAt: Date
@NSManaged public var updatedAt: Date
@NSManaged public var folder: Folder?
@NSManaged public var tags: Set<Tag>
public override func awakeFromInsert() {
super.awakeFromInsert()
let now = Date()
self.id = UUID()
self.createdAt = now
self.updatedAt = now
}
}
extension Note {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Note> {
NSFetchRequest<Note>(entityName: "Note")
}
}
Things to notice:
-
@objc(Note)exposes the Swift class name to the Objective-C runtime asNote. Core Data uses this name to map between the model and the runtime class. The string in@objc(...)must match what you put in the model editor’s “Class” field for that entity. -
@NSManagedtells the compiler “this property’s storage is provided dynamically by NSManagedObject.” Core Data implements the getters and setters at runtime. You can’t initialize these ininitor have side effects in their accessors via Swift property observers — they’re not real Swift stored properties. -
The
Set<Tag>for the to-many relationship reflects how Core Data exposes to-many relationships in modern Swift. For an unordered relationship,Set<T>is the right type. For an ordered relationship, you useNSOrderedSet. -
awakeFromInsertis called once when the object is first inserted into a context. This is the right place to set required defaults like UUIDs and creation dates. -
The
fetchRequest()factory is a static method that returns a typedNSFetchRequest<Note>. WithClass Definitioncodegen this is generated for you; with manual you write it.
@NSManaged and KVO
@NSManaged properties go through the Objective-C runtime’s KVO machinery. This is how Core Data implements:
- Lazy loading (faulting): on access, Core Data fires the fault and loads the value from the store.
- Change tracking: setting a property records the change in the context’s pending changes.
- Validation: setting a property can run validation.
- Observation: external observers (KVO, SwiftUI’s
ObservableObject-via-NSManagedObject) get notified.
A consequence: you can’t override @NSManaged accessors directly. To customize behavior, use willChangeValue(forKey:) / didChangeValue(forKey:) patterns or override willSave() for save-time transformations.
Computed properties and helpers
You can absolutely add computed properties and helper methods on your subclass — they don’t need @NSManaged:
extension Note {
var displayTitle: String {
title.isEmpty ? "Untitled" : title
}
var preview: String {
String(body.prefix(100))
}
func touch() {
updatedAt = Date()
}
static func fetchAll(in context: NSManagedObjectContext,
sortedBy keyPath: String = "updatedAt",
ascending: Bool = false) throws -> [Note] {
let request = Note.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: keyPath, ascending: ascending)]
return try context.fetch(request)
}
}
Pure-Swift computed properties and methods are fine. They don’t go through the runtime’s dynamic dispatch.
Validation hooks
NSManagedObject provides several override points for validation:
public override func validateForInsert() throws {
try super.validateForInsert()
// Custom validation when inserting
try validateBody()
}
public override func validateForUpdate() throws {
try super.validateForUpdate()
try validateBody()
}
private func validateBody() throws {
if title.isEmpty && body.isEmpty {
throw NSError(domain: "Note", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Note must have a title or body"])
}
}
These run automatically when the context is saved. If they throw, the save fails. Use them for invariants the database itself doesn’t enforce.
There’s also per-attribute validation via methods named validate<AttributeName>:error:. They get called during save, with the value about to be saved as a parameter (you can change it).
Lifecycle hooks
Beyond awakeFromInsert:
awakeFromFetch— called after the object is fetched from the store. Use sparingly; modifying state here is risky because it doesn’t mark the object as “changed.”willSave— called just before save. The right place forupdatedAt = Date()so every save updates the timestamp.didSave— called after save. Useful for cleaning up post-save state.willTurnIntoFault— called when the object is about to become a fault (we’ll cover faults in section 17).prepareForDeletion— called when the object is about to be deleted. Useful for cleanup of resources tied to the object.
public override func willSave() {
super.willSave()
if isUpdated && !isDeleted {
if updatedAt == nil || updatedAt < Date(timeIntervalSinceNow: -0.01) {
// updatedAt itself counts as a change; the time-window check prevents infinite recursion.
self.updatedAt = Date()
}
}
}
Be very careful with willSave: setting a property re-fires willSave if the new value triggers another change. Use guards like the time-window check above to prevent infinite loops.
Generating subclasses with Editor → Create NSManagedObject Subclass
Xcode has a menu item that generates subclass files for you (Editor → Create NSManagedObject Subclass… when a model is selected). It’s useful for the initial scaffolding. You then switch the entity’s Codegen to “Manual/None” and edit the generated files freely.
What it generates: two files per entity. Note+CoreDataClass.swift for the class itself, Note+CoreDataProperties.swift for the @NSManaged properties. You can collapse them into one if you prefer.
I personally consolidate into one file per entity (Note.swift) — easier to find things.
Faults and accessors
When you access a @NSManaged property on a faulted object, Core Data fires the fault: it goes to the store, fetches the row’s data, and populates the object’s properties. The next access is just a memory read.
This is mostly invisible. The reason it matters: in tight loops, you’re firing faults you didn’t intend. Pre-fetching (covered in section 19) avoids it.
You can check fault state:
if note.isFault {
// The object exists but its data hasn't been loaded yet.
}
For a relationship, you can check whether it’s a fault:
if note.faultingState == 0 {
// Object's data is loaded.
}
Working with relationships
For a to-many relationship typed as Set<Tag>, you read it like any Set:
let tagNames = note.tags.map(\.name)
To modify, mutate the property:
note.tags.insert(tag)
note.tags.remove(tag)
note.tags = [] // remove all tags from this note (preserves Tag objects)
For relationships exposed as NSSet (in older codegen), Core Data provides KVC accessors:
note.mutableSetValue(forKey: "tags").add(tag) // works but verbose
Modern Swift prefers Set<Tag> directly. The NSSet versions still work if you have inherited code.
Subclass inheritance
You can have entity inheritance in Core Data — making an entity a subclass of another. In SQL terms, this can be implemented with single-table inheritance (one table for parent and all children). The model editor lets you set “Parent Entity”.
Inheritance is rarely the right choice. It bakes assumptions into your schema that are hard to migrate, and mostly the same effects can be achieved with composition and optional relationships. Use inheritance sparingly, if at all.
What to internalize
Your NSManagedObject subclass has @NSManaged properties for entity attributes/relationships. Use Manual codegen for control. awakeFromInsert sets required defaults. Lifecycle hooks (willSave, prepareForDeletion) let you attach behavior. Add computed properties and helpers freely. Don’t override @NSManaged accessors directly. Avoid entity inheritance unless you have a strong reason.
5. CRUD: Creating, Reading, Updating, Deleting
The four operations every persistence layer supports. Core Data has a specific way to do each, and a couple of subtleties worth knowing.
Creating
To insert a new object, you initialize it on a context:
let note = Note(context: viewContext)
note.title = "First note"
note.body = "Hello, world."
try? viewContext.save()
That’s it. The object is now in the context, marked as inserted. On the next save, it’s written to the store.
Behind the scenes, Note(context:) calls NSEntityDescription.insertNewObject(forEntityName: "Note", into: context) and casts the result. The awakeFromInsert hook runs. Required defaults get set.
Alternative initialization patterns
Two other patterns you’ll see:
// Using NSEntityDescription directly:
let entity = NSEntityDescription.entity(forEntityName: "Note", in: viewContext)!
let note = Note(entity: entity, insertInto: viewContext)
// Older / KVC-style (don't do this, but you'll see it in legacy code):
let note = NSEntityDescription.insertNewObject(forEntityName: "Note", into: viewContext) as! Note
The Note(context:) form is the cleanest in modern Swift. The other forms are equivalent but more verbose.
Reading
Reading is via NSFetchRequest. The simplest fetch:
let request = Note.fetchRequest()
do {
let notes = try viewContext.fetch(request)
print("Loaded \(notes.count) notes")
} catch {
print("Fetch failed: \(error)")
}
fetch(_:) returns an array of results. The fetch runs synchronously on the context’s queue. We’ll explore NSFetchRequest in depth in section 7.
For a single object by ID:
func note(withID id: UUID, in context: NSManagedObjectContext) -> Note? {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
We set fetchLimit = 1 because we only need one row; the SQLite query stops as soon as it finds a match instead of scanning further.
Reading all of an entity
let request = Note.fetchRequest()
let allNotes = try viewContext.fetch(request)
Beware: fetch returns all matching rows. For thousands of notes this might be a lot. Use fetchLimit, predicates, or NSFetchedResultsController for paging.
Updating
Updating is just setting properties:
note.title = "Updated title"
try? viewContext.save()
The context tracks the change. On save, the row is updated.
If you want to know whether a specific object has been modified since the last save:
if note.hasChanges {
print("Note has unsaved changes")
}
Or check the context overall:
if viewContext.hasChanges {
try? viewContext.save()
}
Bulk updates
For updating many objects, you have two options:
- Fetch all, mutate, save (the slow path).
- Use
NSBatchUpdateRequest(the fast path; covered in section 18).
For a few dozen objects, option 1 is fine. For thousands, use batch updates.
Deleting
Mark for deletion:
viewContext.delete(note)
try? viewContext.save()
The object is marked as deleted; it’s removed from the store on save. After delete(_:), the object is still in memory but its isDeleted is true. After save, the object is invalid — accessing properties may crash.
For batch deletes, use NSBatchDeleteRequest (section 18).
Soft delete
Sometimes you want “deleted” to be a flag, not actual removal. Add a deletedAt: Date? attribute, set it to Date() instead of calling context.delete(_:). Filter your fetches to exclude soft-deleted objects:
request.predicate = NSPredicate(format: "deletedAt == nil")
Soft delete is useful for:
- “Recently deleted” recovery.
- Sync with a backend that needs to know about deletions.
- Audit trails.
The trade-off: every fetch needs to exclude deleted objects, and you accumulate dead rows over time. Decide based on your use case.
Counting without fetching
Sometimes you want a count, not the objects:
let count = try? viewContext.count(for: Note.fetchRequest())
This runs a SELECT COUNT(*) in SQLite and returns the count without materializing objects. Faster than fetching and counting.
For a count with predicate:
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "folder == %@", folder)
let count = try? viewContext.count(for: request)
existingObject(with:)
Given an object’s NSManagedObjectID, you can get the object in any context:
let id = note.objectID // permanent ID after save; temporary before
let existing = try? otherContext.existingObject(with: id) as? Note
This is crucial for cross-context work (section 16). The object ID is the only safe way to refer to a Core Data object across contexts.
Discarding changes
If you’ve made edits in a context but don’t want to save them:
viewContext.rollback() // discard all unsaved changes
viewContext.refreshAllObjects() // reload fresh data from store
viewContext.reset() // throw away all in-memory state; objects become invalid
Three different operations with three different effects:
rollback()undoes inserts/updates/deletes since the last save. Existing objects revert to their last-saved values.refreshAllObjects()keeps your inserts but turns existing objects into faults — they’ll re-fetch their data from the store on next access.reset()is more aggressive — it throws away every object the context knows about. Use with care: any existing reference to an object becomes a stale reference.
For UI state recovery (e.g., user taps “Cancel” on an editor), rollback() is what you want.
Refreshing
If another part of your app modified data and you want this context to see the updates:
viewContext.refresh(note, mergeChanges: true)
mergeChanges: true keeps your local in-memory edits; the refresh just loads the latest persisted data and merges. mergeChanges: false discards your local edits.
You don’t usually need this in modern apps with automaticallyMergesChangesFromParent, but it’s the manual fallback if automatic merging isn’t enabled.
Putting it together
A typical “create or update” flow for syncing data from a server:
func upsertNote(from json: [String: Any], in context: NSManagedObjectContext) throws -> Note {
guard let idString = json["id"] as? String,
let id = UUID(uuidString: idString) else {
throw NSError(domain: "Sync", code: 1)
}
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
let note = try context.fetch(request).first ?? Note(context: context)
if note.isInserted {
note.id = id
}
note.title = json["title"] as? String ?? ""
note.body = json["body"] as? String ?? ""
if let timestamp = json["updatedAt"] as? TimeInterval {
note.updatedAt = Date(timeIntervalSince1970: timestamp)
}
return note
}
Even cleaner with a unique constraint on id and NSMergeByPropertyObjectTrumpMergePolicy:
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
let note = Note(context: context)
note.id = idFromJSON
note.title = ...
// On save, if a note with the same id exists, the constraint causes a conflict;
// the merge policy resolves by overwriting the existing object's properties.
try context.save()
This is genuinely upsert behavior at the database level — no fetch needed first.
What to internalize
Insert via Note(context:). Update by setting properties. Delete via context.delete(_:). Save with context.save(). Use NSBatchUpdateRequest and NSBatchDeleteRequest for many-row operations. Use objectID and existingObject(with:) for cross-context references. Use unique constraints + merge policies for upsert semantics. Soft-delete is a pattern, not a built-in feature.
6. Saving Contexts and the Save Lifecycle
Saving a context isn’t atomic in the simple sense of “write some bytes to disk.” It’s a sequence of validation, conflict resolution, persistence, notifications, and merging. Knowing the lifecycle helps you debug save failures and design save strategies that scale.
What save() actually does
Calling context.save() triggers, roughly, this sequence:
- Validation. Each inserted and updated object’s
validateForInsert()orvalidateForUpdate()runs, plus per-attribute validation methods. willSaveis called on each pending object.- Coordinator interaction. The context asks the persistent store coordinator to persist the changes.
- Conflict resolution. If another context has saved conflicting changes since this context fetched, the merge policy decides what to do.
- Store write. The coordinator passes the changes to the persistent store (SQLite), which writes them.
didSaveis called on each saved object.NSManagedObjectContextDidSavenotification fires, with userInfo containing inserted/updated/deleted objects.- Other contexts merge. If
automaticallyMergesChangesFromParentis true, sibling contexts pick up the changes and update their in-memory state.
If validation fails or conflicts can’t be resolved, save() throws and step 5 onward doesn’t happen. The context’s pending changes are still pending.
Error handling
save() is throws. Always handle errors:
do {
try context.save()
} catch let error as NSError {
print("Save failed: \(error.localizedDescription)")
print("Details: \(error.userInfo)")
}
The NSError’s userInfo is rich:
NSValidationKeyErrorKey— the property that failed validation.NSValidationObjectErrorKey— the object that failed.NSValidationValueErrorKey— the value that was invalid.NSDetailedErrors— for multiple validation failures, an array of individual errors.
For multi-object validation failures:
if let detailedErrors = error.userInfo[NSDetailedErrorsKey] as? [NSError] {
for detailedError in detailedErrors {
print(" - \(detailedError.localizedDescription)")
}
}
When to save
A common mistake: save after every change. Don’t. Saves are expensive — they go through validation, conflict resolution, and a SQLite write.
Reasonable strategies:
- Save explicitly when the user does something save-worthy. They tap “Done”, they switch screens, the app goes to background.
- Save in batches. A bulk import does many inserts, then one save at the end.
- Save on app backgrounding. A scene-phase observer catches the moment to flush:
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
try? PersistenceController.shared.container.viewContext.save()
}
}
Don’t save in tight loops. Don’t save inside an enumeration of fetched objects. One save at the end is typically right.
The save lifecycle hooks
We saw these in section 4. They’re the right places for derived value updates and cleanup:
willSave()— last chance to compute derived values likeupdatedAt.didSave()— post-save notifications.prepareForDeletion()— cleanup before delete propagates.
public override func willSave() {
super.willSave()
// Update updatedAt only if it didn't change in this save cycle (avoid loops).
let changes = changedValuesForCurrentEvent()
if !changes.keys.contains("updatedAt") && (isInserted || isUpdated) {
self.updatedAt = Date()
}
}
changedValuesForCurrentEvent() returns just the changes about to be written in this specific save, not all unsaved changes. This is the key to avoiding willSave loops.
Conflict resolution
When two contexts modify the same object and both save, you have a conflict.
Concretely: context A fetches a note, context B fetches the same note. A modifies title to “X” and saves. B modifies title to “Y” and saves. What ends up in the store?
The resolution depends on the merge policy of the saving context. For NSMergeByPropertyStoreTrumpMergePolicy, B’s save loses to whatever’s in the store (which now has A’s “X”). For NSMergeByPropertyObjectTrumpMergePolicy, B’s “Y” wins.
To detect conflicts at save time:
do {
try context.save()
} catch let error as NSError {
if error.domain == NSCocoaErrorDomain {
if let conflicts = error.userInfo[NSPersistentStoreSaveConflictsErrorKey] as? [NSMergeConflict] {
for conflict in conflicts {
print("Conflict on \(conflict.sourceObject)")
}
}
}
}
For most apps, set the merge policy at context creation:
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
And don’t worry about conflicts thereafter. The policy handles them automatically.
Notifications during save
Several notifications fire around save:
NSManagedObjectContextWillSave— just before save begins.NSManagedObjectContextDidSave— after save completes successfully. This is the primary hook for cross-context merging.NSManagedObjectContextObjectsDidChange— fires whenever objects change, including unsaved changes. Useful for live UI updates.
Listen for them:
NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: nil
) { notification in
guard let savedContext = notification.object as? NSManagedObjectContext,
savedContext !== self.viewContext else { return }
self.viewContext.perform {
self.viewContext.mergeChanges(fromContextDidSave: notification)
}
}
If you set automaticallyMergesChangesFromParent = true, you don’t write this code — Core Data does it for you. But if you need custom merge behavior (e.g., logging changes before merging), you write a manual handler.
The userInfo of save notifications
NSManagedObjectContextDidSave’s userInfo has:
NSInsertedObjectsKey→Set<NSManagedObject>of inserted objects.NSUpdatedObjectsKey→Set<NSManagedObject>of updated objects.NSDeletedObjectsKey→Set<NSManagedObject>of deleted objects.
You can inspect them:
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
for obj in inserted {
print("Inserted: \(obj)")
}
}
Critical caveat: these objects are tied to the saving context’s queue. If you receive the notification on a different context, you can’t safely access those objects — only their objectIDs. Use existingObject(with:) on the receiving context to get a usable instance.
Asynchronous saves
Modern code can save asynchronously:
try await context.perform {
try context.save()
}
Or:
context.perform {
do {
try context.save()
} catch {
print("Save failed: \(error)")
}
}
perform schedules a closure on the context’s queue and returns immediately (or, in the async version, suspends until the closure completes). For background contexts especially, always use perform to ensure you’re on the right queue.
Persistent history tracking and save
When persistent history tracking is enabled, every save writes an entry to a history table in the SQLite store. You can query this history later to discover changes that happened in other processes (a Share extension, for example) or to drive sync. Most apps don’t need to query history directly, but enabling tracking is required for batch operations and CloudKit sync.
What to internalize
save() validates, persists, and notifies. Always handle errors — they’re informative. Set a merge policy to handle conflicts gracefully. Don’t save after every change; save at coherent moments (user action, app backgrounding). The NSManagedObjectContextDidSave notification is the basis for cross-context merging, but automaticallyMergesChangesFromParent automates it. Use willSave() carefully for derived values, with guards against loops.
7. NSFetchRequest in Detail
NSFetchRequest is the workhorse for reading data. It’s deceptively simple — entity name, predicate, sort, fetch — but has a dozen knobs that affect performance dramatically. Knowing them is the difference between fast fetches and a scrollable list that hitches.
The basics
let request = NSFetchRequest<Note>(entityName: "Note")
request.predicate = NSPredicate(format: "folder == %@", folder)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
let notes = try context.fetch(request)
The generic parameter <Note> types the result, so notes is [Note]. You can use <NSManagedObject> if you don’t have a specific subclass.
When you have an NSManagedObject subclass with fetchRequest() static method, the typed form is cleaner:
let request: NSFetchRequest<Note> = Note.fetchRequest()
predicate — filtering
The predicate decides which rows match. We’ll go deep on predicates in section 8. A taste:
request.predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchText)
[cd] flags mean case-insensitive and diacritic-insensitive. The %@ is a placeholder filled by the searchText value.
sortDescriptors — ordering
Always set sort descriptors, even if you “don’t care” about order. Without them, the order is undefined and may change between fetches. For consistent results:
request.sortDescriptors = [
NSSortDescriptor(key: "createdAt", ascending: false),
NSSortDescriptor(key: "id", ascending: true)
]
The second sort descriptor breaks ties; without it, two notes with the same createdAt could appear in inconsistent order.
For string sorting, you usually want localized comparison:
NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare))
This sorts strings respecting the user’s locale (Æ properly placed, accents handled, etc.).
fetchLimit and fetchOffset
Limit how many results to return; useful for paging:
request.fetchLimit = 50 // first 50
request.fetchOffset = 0 // starting at the beginning
For the next page:
request.fetchLimit = 50
request.fetchOffset = 50 // skip the first 50
fetchOffset with large offsets is slow on SQLite (it scans through the skipped rows). For large data sets, prefer “keyset pagination”: fetch all rows where createdAt < lastCreatedAt, with a limit.
request.predicate = NSPredicate(format: "createdAt < %@", lastDate as NSDate)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchLimit = 50
fetchBatchSize — the magic knob
For results you’ll iterate over but might not consume entirely, set fetchBatchSize:
request.fetchBatchSize = 20
This tells Core Data to return a proxy array that, when accessed, fetches only 20 objects at a time. As you iterate past the first 20, Core Data fetches the next 20, etc.
Before fetchBatchSize, fetching 10,000 rows materialized 10,000 objects up front. With it, you get a thin array and only the rows you actually look at hit memory.
This is essential for NSFetchedResultsController-backed lists, where you’ve fetched “all notes” but might scroll through only a fraction. Most apps should set fetchBatchSize to something like 20–50 for list-backing fetches.
propertiesToFetch — partial fetches
If you only need a subset of attributes, ask for them:
request.resultType = .dictionaryResultType
request.propertiesToFetch = ["id", "title"]
let dicts = try context.fetch(request) as? [[String: Any]] ?? []
This skips loading body, dates, etc. Returns dictionaries instead of NSManagedObject instances. Use cases: extracting a list of titles for autocomplete, summary tables, anything where the full object is overkill.
propertiesToGroupBy — aggregation
For SQL-style GROUP BY:
request.resultType = .dictionaryResultType
request.propertiesToGroupBy = ["folder"]
let countDescription = NSExpressionDescription()
countDescription.name = "count"
countDescription.expression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "id")])
countDescription.expressionResultType = .integer64AttributeType
request.propertiesToFetch = ["folder", countDescription]
let counts = try context.fetch(request) as? [[String: Any]] ?? []
// counts is like: [{folder: <Folder>, count: 42}, ...]
This is dense; for occasional aggregation it’s worth the syntax, for routine work consider doing it in Swift after a regular fetch.
relationshipKeyPathsForPrefetching — avoiding N+1
The most common Core Data performance bug: iterating fetched objects, accessing a relationship on each one, firing N faults.
let notes = try context.fetch(Note.fetchRequest())
for note in notes {
print(note.folder?.name ?? "no folder") // each access fires a fault!
}
Fix: prefetch the relationship:
let request = Note.fetchRequest()
request.relationshipKeyPathsForPrefetching = ["folder"]
let notes = try context.fetch(request)
for note in notes {
print(note.folder?.name ?? "no folder") // already loaded
}
This loads all the related Folders in a single query, alongside the Notes. For nested relationships:
request.relationshipKeyPathsForPrefetching = ["folder", "folder.parent", "tags"]
This is one of the most impactful performance optimizations. Always prefetch relationships you know you’ll access.
returnsObjectsAsFaults
By default, Core Data returns objects as faults — placeholders whose properties load on first access. If you know you’ll access most properties, you can request fully-materialized objects:
request.returnsObjectsAsFaults = false
Behind the scenes, this changes the SQL to load all attributes in the initial fetch. Faster overall when you know you need the data; wasteful if you’ll only access a few fields.
For dictionary results (resultType = .dictionaryResultType), this setting doesn’t apply.
includesSubentities
If your model uses entity inheritance (rare), this controls whether the fetch includes subentities. Default true.
includesPendingChanges
By default, fetches include pending unsaved changes in the context. If you want only the persisted state:
request.includesPendingChanges = false
Use this carefully — it can lead to “stale” results in your UI if you’ve made changes that haven’t been saved.
affectedStores
If you have multiple persistent stores (common with CloudKit + local), you can scope the fetch to specific stores:
request.affectedStores = [container.persistentStoreCoordinator.persistentStores.first!]
Most apps don’t need this.
shouldRefreshRefetchedObjects
When fetching, if an object is already in the context, by default the existing in-memory state takes precedence over the database state. To force a refresh from the database:
request.shouldRefreshRefetchedObjects = true
This is the “I really want the latest” flag. Costs a bit because Core Data has to reconcile in-memory edits with database reads.
Putting it all together
A well-tuned fetch for a list of notes in a folder, with prefetched tags, paged, batched:
func notes(in folder: Folder, search: String?, context: NSManagedObjectContext) throws -> [Note] {
let request = Note.fetchRequest()
var predicates: [NSPredicate] = [NSPredicate(format: "folder == %@", folder)]
if let search, !search.isEmpty {
predicates.append(NSPredicate(format: "title CONTAINS[cd] %@ OR body CONTAINS[cd] %@",
search, search))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.sortDescriptors = [
NSSortDescriptor(key: "updatedAt", ascending: false),
NSSortDescriptor(key: "id", ascending: true)
]
request.fetchBatchSize = 20
request.relationshipKeyPathsForPrefetching = ["tags"]
return try context.fetch(request)
}
This fetch:
- Filters by folder and (optionally) search text.
- Sorts by updatedAt (descending) with id as tiebreaker.
- Returns results in batches of 20 (the rest fetch lazily).
- Prefetches the
tagsrelationship so iterating doesn’t fire N faults.
Asynchronous fetches
NSAsynchronousFetchRequest runs in the background:
let asyncRequest = NSAsynchronousFetchRequest(fetchRequest: request) { result in
if let notes = result.finalResult {
// notes is [Note]
}
}
do {
try context.execute(asyncRequest)
} catch {
print("Async fetch failed: \(error)")
}
In modern code, you typically use perform { } on a background context for async work instead. NSAsynchronousFetchRequest predates Swift Concurrency.
What to internalize
NSFetchRequest configures a query: predicate, sort, limit, offset, batch size, prefetch. fetchBatchSize makes large fetches lazy. relationshipKeyPathsForPrefetching avoids N+1 queries. propertiesToFetch with dictionary result type skips unwanted columns. Always set sort descriptors. For paging large results, prefer keyset over offset.
8. NSPredicate Mastery
NSPredicate is the query language for Core Data. It started as a way to filter Cocoa collections and got generalized into something that can also filter Core Data fetches (and fetch into SQL). It’s powerful, terse, and full of gotchas. Mastering it makes a huge difference.
The format string syntax
The most common usage: build a predicate from a format string with placeholders.
NSPredicate(format: "title == %@", "Hello")
NSPredicate(format: "age > %d", 18)
NSPredicate(format: "isActive == %@", NSNumber(value: true))
NSPredicate(format: "createdAt >= %@", date as NSDate)
Placeholders:
%@— for object values (strings, dates, NSNumbers, NSManagedObjects).%d— for integers. Equivalent to%@with an NSNumber.%K— for key paths (column names). Used for dynamic key paths.
The %K is for when the column name itself is a variable:
let key = "title"
NSPredicate(format: "%K == %@", key, value)
%K is treated as a key path; %@ is treated as a value. Don’t mix them up.
Comparison operators
NSPredicate(format: "age == 18")
NSPredicate(format: "age != 18")
NSPredicate(format: "age > 18")
NSPredicate(format: "age >= 18")
NSPredicate(format: "age < 18")
NSPredicate(format: "age <= 18")
For strings:
NSPredicate(format: "title == %@", "exact match")
NSPredicate(format: "title BEGINSWITH %@", "Hello")
NSPredicate(format: "title ENDSWITH %@", "world")
NSPredicate(format: "title CONTAINS %@", "lo, w")
NSPredicate(format: "title MATCHES %@", "^[A-Z].*") // regex
NSPredicate(format: "title LIKE %@", "Hel*o") // wildcards * and ?
MATCHES uses ICU regular expressions (similar to most regex flavors). LIKE uses simple wildcards (* and ?).
Modifiers: case and diacritic insensitivity
By default, string comparisons are case-sensitive and diacritic-sensitive. Add modifiers:
NSPredicate(format: "title CONTAINS[c] %@", "hello") // case-insensitive
NSPredicate(format: "title CONTAINS[d] %@", "hello") // diacritic-insensitive
NSPredicate(format: "title CONTAINS[cd] %@", "hello") // both
NSPredicate(format: "title CONTAINS[n] %@", "hello") // normalized (combines [cd])
NSPredicate(format: "title CONTAINS[l] %@", "hello") // localized (locale-aware)
For user-facing search, almost always use [cd] or [n]. Otherwise “Hello” doesn’t match a search for “hello”, and “café” doesn’t match a search for “cafe”.
Logical operators
NSPredicate(format: "title == %@ AND createdAt > %@", title, date as NSDate)
NSPredicate(format: "title == %@ OR title == %@", a, b)
NSPredicate(format: "NOT (title == %@)", title)
You can also build compound predicates programmatically:
let p1 = NSPredicate(format: "title == %@", title)
let p2 = NSPredicate(format: "createdAt > %@", date as NSDate)
let combined = NSCompoundPredicate(andPredicateWithSubpredicates: [p1, p2])
NSCompoundPredicate has and..., or..., and not... initializers. Useful when you have a list of optional predicates to combine:
var predicates: [NSPredicate] = [NSPredicate(format: "folder == %@", folder)]
if let searchText, !searchText.isEmpty {
predicates.append(NSPredicate(format: "title CONTAINS[cd] %@", searchText))
}
if let tag {
predicates.append(NSPredicate(format: "ANY tags == %@", tag))
}
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
IN and BETWEEN
For “value is in a set”:
let allowedTitles = ["Hello", "World", "Foo"]
NSPredicate(format: "title IN %@", allowedTitles)
For range:
NSPredicate(format: "age BETWEEN {18, 65}")
NSPredicate(format: "createdAt BETWEEN %@", [startDate, endDate])
BETWEEN is inclusive on both ends.
Nil checks
Comparing to nil uses nil literal:
NSPredicate(format: "deletedAt == nil")
NSPredicate(format: "deletedAt != nil")
Or NULL:
NSPredicate(format: "deletedAt == NULL")
Both work; nil is more readable.
Relationships in predicates
For to-one:
NSPredicate(format: "folder == %@", folder)
NSPredicate(format: "folder.name == %@", "Inbox")
For to-many, use ANY, ALL, NONE:
NSPredicate(format: "ANY tags.name == %@", "important")
// "Notes that have at least one tag named 'important'"
NSPredicate(format: "ALL tags.name == %@", "draft")
// "Notes whose every tag is named 'draft'"
NSPredicate(format: "NONE tags.name == %@", "deleted")
// "Notes that have no tag named 'deleted'"
For “to-many is empty”:
NSPredicate(format: "tags.@count == 0")
NSPredicate(format: "tags.@count > 0")
@count is one of several KVC aggregation operators (others: @sum, @avg, @min, @max).
SUBQUERY: more complex relationship filters
For “notes that have at least 3 important tags”:
NSPredicate(format: "SUBQUERY(tags, $tag, $tag.name == 'important').@count >= 3")
SUBQUERY syntax: SUBQUERY(<collection>, <iterator-variable>, <predicate-using-iterator>). Returns a filtered subset; you usually take its @count or compare to another collection.
For “users whose posts all have positive scores”:
NSPredicate(format: "SUBQUERY(posts, $p, $p.score < 0).@count == 0", arguments: nil)
SUBQUERYs are powerful but verbose. Use them sparingly; they don’t always have great SQL translations.
Type-safe predicates with #Predicate (modern)
Starting iOS 17 / Swift 5.9, there’s a macro-based #Predicate syntax:
let predicate = #Predicate<Note> { note in
note.title.contains("hello") && note.createdAt > someDate
}
let request = Note.fetchRequest()
request.predicate = NSPredicate(predicate)
This is type-checked at compile time — no %@ format string surprises. The compiler verifies that note.title exists and is a string, that someDate is a Date, etc.
For new code on iOS 17+, use #Predicate whenever possible. It’s far safer.
The catch: not every NSPredicate is expressible. Some advanced features (SUBQUERY, certain regex patterns) need to fall back to format strings. Mix as needed.
The KVC keypath quirk
You’ll sometimes see code like:
NSPredicate(format: "%K == %@", #keyPath(Note.title), "Hello")
#keyPath is a Swift compiler check that the key path is valid. With manual NSManagedObject subclasses, #keyPath(Note.title) resolves to the string "title" at compile time, and the compiler checks that Note has a title property.
This gives you a partial type safety even with classic NSPredicate. Use it for any predicate where you’d otherwise hardcode a string column name.
Dynamic predicates from search input
A real-world pattern: search across multiple fields with case-insensitive match:
func searchPredicate(for text: String) -> NSPredicate? {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
let words = trimmed.split(separator: " ").map(String.init)
let perWordPredicates: [NSPredicate] = words.map { word in
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "title CONTAINS[cd] %@", word),
NSPredicate(format: "body CONTAINS[cd] %@", word),
NSPredicate(format: "ANY tags.name CONTAINS[cd] %@", word)
])
}
return NSCompoundPredicate(andPredicateWithSubpredicates: perWordPredicates)
}
This implements “every word the user typed must appear somewhere (title, body, or any tag name)”. You build it from search input dynamically.
Pitfalls
-
Boolean attributes. Use
NSNumber(value: true)or1/0, not Swifttruedirectly:NSPredicate(format: "isActive == %@", NSNumber(value: true))orNSPredicate(format: "isActive == 1"). -
Date comparisons. Cast to
NSDate:NSPredicate(format: "createdAt > %@", someDate as NSDate). Otherwise the format string may misinterpret. -
UUID comparisons. Cast to
CVarArg:NSPredicate(format: "id == %@", id as CVarArg). -
String contains substrings.
==is exact match;CONTAINSlooks for a substring. Don’t confuse them. -
Format-string injection. Don’t interpolate user input into the format string itself. Always use placeholders.
NSPredicate(format: "title == \(userInput)")is unsafe;NSPredicate(format: "title == %@", userInput)is safe.
What to internalize
NSPredicate builds queries with format strings (%@ for values, %K for key paths) or, on iOS 17+, with #Predicate for compile-time safety. [cd] modifiers for case/diacritic-insensitive string compares. Use ANY/ALL/NONE for to-many relationships. Compose with NSCompoundPredicate. Always use placeholders, never interpolate user input. Cast Date to NSDate, UUID to CVarArg.
9. Sort Descriptors and Result Limits
We touched on sorting in section 7. Let’s go deeper — there are some subtleties to get right.
Why always sort
A fetch without sort descriptors returns rows in an undefined order. The order may be consistent within a single SQLite version on a single device, but it’s not part of the contract. Two consequences:
- Tests pass on your device, fail on CI.
- Two different launches show the user different ordering.
NSFetchedResultsControllerrequires sort descriptors and crashes without them.
Always sort. Even if you “don’t care,” pick something deterministic — usually id or createdAt.
Multiple sort descriptors
Sort descriptors apply in order:
request.sortDescriptors = [
NSSortDescriptor(key: "priority", ascending: false),
NSSortDescriptor(key: "createdAt", ascending: true),
NSSortDescriptor(key: "id", ascending: true)
]
This sorts by priority descending, then for ties by createdAt ascending, then for further ties by id ascending. The id sort guarantees a fully deterministic order even when priorities and dates collide.
String sorting
The default string comparison is binary: 'a' < 'B' < 'b' because it uses character codes. For user-visible text, almost always use a localized comparator:
NSSortDescriptor(key: "name", ascending: true,
selector: #selector(NSString.localizedCaseInsensitiveCompare))
NSSortDescriptor(key: "name", ascending: true,
selector: #selector(NSString.localizedStandardCompare))
localizedStandardCompare is “Finder-like” — it handles things like sorting “iPhone 10” before “iPhone 2” with embedded numbers (when the locale supports it).
localizedCaseInsensitiveCompare is the simpler choice for general case-insensitive sort.
Sorting with key paths
You can sort by relationship attributes:
NSSortDescriptor(key: "folder.name", ascending: true)
NSSortDescriptor(key: "author.lastName", ascending: true)
This works in Core Data because the SQL becomes a JOIN. There’s a cost — joins make queries slower. For frequently-sorted relationships, consider denormalizing (e.g., copy the folder name onto the note) or limiting how often you sort by joined fields.
Modern syntax: typed sort descriptors
iOS 15+ has SortDescriptor<Element>:
let sortByDate = SortDescriptor(\Note.createdAt, order: .reverse)
let sortByTitle = SortDescriptor(\Note.title)
request.sortDescriptors = [sortByDate.toNSSortDescriptor(), sortByTitle.toNSSortDescriptor()]
But Core Data’s NSFetchRequest uses [NSSortDescriptor], not [SortDescriptor<Note>]. To bridge, use NSSortDescriptor(_:) initializer or convert via your own helper. SwiftUI’s @FetchRequest accepts both, with conversions handled by the framework.
fetchLimit vs fetchOffset
request.fetchLimit = 50
request.fetchOffset = 100
This says: “skip the first 100 results, take the next 50.” For paging, this works but has performance issues:
- SQLite must compute the first 100 rows to skip them.
- Large offsets (10,000+) become slow.
- Inserts at the start of the result set shift the offset, leading to duplicates or skipped rows on consecutive pages.
For paging large datasets, use keyset pagination:
// First page:
request.predicate = nil // or your existing predicate
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchLimit = 50
let firstPage = try context.fetch(request)
// Next page (with the previous last item's createdAt as the cursor):
let lastItem = firstPage.last!
request.predicate = NSPredicate(format: "createdAt < %@", lastItem.createdAt as NSDate)
request.fetchLimit = 50
let secondPage = try context.fetch(request)
This uses an indexed comparison (createdAt <) instead of an offset. Always fast regardless of how deep into results you are. The trade-off: you can’t jump to “page 47” — you must walk forward.
fetchBatchSize: lazy loading
Already covered in section 7, but worth re-emphasizing:
request.fetchBatchSize = 20
let allNotes = try context.fetch(request)
// `allNotes` is an array proxy; only 20 objects are loaded immediately.
// Accessing index 35 fetches the next batch automatically.
Use this for fetches that back lists where you might not consume all results — table view data sources, anywhere with thousands of rows but paged display.
Sorting and paging together
Don’t change the sort between pages. If page 1 sorts by date, page 2 must also. Otherwise the underlying ordering shifts and your paging is wrong.
let sort = [NSSortDescriptor(key: "createdAt", ascending: false)]
func fetchPage(after cursor: Date?, in context: NSManagedObjectContext) throws -> [Note] {
let request = Note.fetchRequest()
if let cursor {
request.predicate = NSPredicate(format: "createdAt < %@", cursor as NSDate)
}
request.sortDescriptors = sort
request.fetchLimit = 50
return try context.fetch(request)
}
Pass nil cursor for the first page; pass the last item’s createdAt for subsequent pages. Each page is a fresh fetch, but each is fast (cursor uses an indexed comparison).
Counting before paging
If you need to display “X of N”:
let total = try context.count(for: Note.fetchRequest()) // total notes
let page = try context.fetch(pagedRequest) // current page
count(for:) is fast — it’s a SQL COUNT, no object materialization. Use it freely.
What to internalize
Always set sort descriptors (deterministic ordering required). Use multiple descriptors for tie-breaking. Use localizedCaseInsensitiveCompare for user-facing strings. Sort by relationship key paths only when needed (joins are costly). Paging with fetchOffset works for small offsets; for large datasets use keyset pagination (predicates + cursor). Use fetchBatchSize for lazily-loaded lists.
10. Relationships: To-One, To-Many, Many-to-Many
Relationships are what make Core Data an object graph and not just a database. They define how entities connect, and they shape your queries, your performance, and your model.
The three cardinalities
- To-One. A
Notehas oneFolder. The note’sfolderproperty isFolder?. - To-Many. A
Folderhas manyNotes. The folder’snotesproperty isSet<Note>(orNSOrderedSetfor ordered). - Many-to-Many. A
Notecan have manyTags, and aTagcan be on manyNotes. Both sides haveSetproperties.
These cardinalities are properties of the relationship, not the entity. Core Data creates the underlying database structure to support each.
Defining a relationship
In the model editor, select an entity, click “+” in the Relationships section. For each relationship:
- Name — what the property is called (
folder,notes,tags). - Destination — what entity it points to.
- Inverse — the relationship on the other end (we’ll cover next section; you must set this).
- Type — To One or To Many.
- Optional — whether nil/empty is allowed.
- Delete Rule — what happens on delete (covered in section 12).
To-one example
Note → Folder:
Note entity:
folder: To One → Folder, optional, inverse: Folder.notes
In Swift:
@NSManaged public var folder: Folder?
Setting the relationship:
note.folder = folder
Reading:
print(note.folder?.name ?? "no folder")
To-many example
Folder → Note:
Folder entity:
notes: To Many → Note, optional, inverse: Note.folder
In Swift:
@NSManaged public var notes: Set<Note>
Reading:
let count = folder.notes.count
let titles = folder.notes.map(\.title)
Modifying:
folder.notes.insert(newNote)
folder.notes.remove(oldNote)
When you set note.folder = folder, Core Data automatically adds the note to folder.notes (because of the inverse). Likewise, folder.notes.insert(note) automatically sets note.folder = folder. The two sides stay consistent.
NSSet and Set in Swift
In older codegen, to-many relationships are exposed as NSSet. With manual codegen (recommended), use Swift’s Set. The bridge happens at the runtime level — Core Data internally uses NSSet, but @NSManaged var tags: Set<Tag> works.
For mutating, you can use either:
note.tags.insert(tag) // Swift Set syntax (most natural)
note.mutableSetValue(forKey: "tags").add(tag) // KVC syntax (older, also works)
Many-to-many example
Note ↔ Tag:
Note entity:
tags: To Many → Tag, optional, inverse: Tag.notes
Tag entity:
notes: To Many → Note, optional, inverse: Tag.notes
Both sides are to-many. Core Data creates a join table behind the scenes; you don’t see it, but it’s there in the SQLite database.
In Swift:
@NSManaged public var tags: Set<Tag> // on Note
@NSManaged public var notes: Set<Note> // on Tag
Adding:
note.tags.insert(tag)
// equivalent to:
tag.notes.insert(note)
Either side updates both. The inverse relationship makes it possible.
Ordered relationships
By default, to-many relationships are unordered (using sets). If you need order — say, items in a playlist — use an ordered to-many.
In the model editor, check “Ordered” on the relationship. The Swift type becomes NSOrderedSet:
@NSManaged public var items: NSOrderedSet
This is more cumbersome than Set because Swift doesn’t have a great native ordered-set type. You read with index:
let firstItem = playlist.items[0] as? PlaylistItem
You modify via mutable accessors:
let mutable = playlist.mutableOrderedSetValue(forKey: "items")
mutable.add(item)
mutable.insert(item, at: 0)
mutable.move(from: 1, to: 3)
Use ordered relationships when order is genuinely a property of the relationship (not derivable from a sort key). Otherwise, prefer unordered + a position attribute for cleaner SQL queries.
Querying through relationships
Already covered in the predicate section, but worth re-emphasizing:
// "Notes in folder X":
NSPredicate(format: "folder == %@", folder)
// "Notes with at least one tag named 'important'":
NSPredicate(format: "ANY tags.name == %@", "important")
// "Notes where every tag is in this list":
NSPredicate(format: "ALL tags IN %@", allowedTags)
These use the relationships as JOINs in the underlying SQL. They’re efficient if the joined attribute is indexed; slow if not.
Bidirectional consistency
Core Data maintains both sides of a relationship for you, if you’ve set up inverses. Without inverses, you must manually sync. Section 11 covers why inverses are essentially required.
Performance considerations
Each relationship traversal can fire a fault. note.folder?.name fires the folder’s fault if it hasn’t been loaded yet.
For loops accessing relationships:
for note in fetchedNotes {
print(note.folder?.name ?? "?") // fires N folder faults
}
Use relationshipKeyPathsForPrefetching = ["folder"] to load all folders in one query. Same idea for nested relationships:
request.relationshipKeyPathsForPrefetching = ["folder", "tags", "folder.parent"]
Each entry causes Core Data to issue an extra query (or a join) to materialize that level of relationship. Done once before the loop, not N times during it.
When the relationship is huge
Sometimes a to-many relationship has thousands of items — e.g., a user has 50,000 messages. You don’t want to load all of them just to show “5 unread.”
For these, don’t navigate the relationship; do a separate fetch:
let request = Message.fetchRequest()
request.predicate = NSPredicate(format: "user == %@ AND isRead == NO", user)
let unreadCount = try context.count(for: request)
This is a single COUNT query, very fast. Compare to:
let unreadCount = user.messages.filter { !$0.isRead }.count // loads all 50,000!
Rule of thumb: traversing a to-many relationship loads it. For huge ones, use a fetch with the relationship as a predicate instead.
Derived attributes
iOS 13+ supports derived attributes: attributes whose value is computed from other attributes (or from a relationship’s count). They’re updated automatically by Core Data on save.
In the model editor, mark an attribute as “Derived” and provide a derivation expression. For example, messageCount on User derived from messages.@count:
messageCount: Integer 64, Derived: messages.@count
Now user.messageCount is always the count of messages, maintained automatically. You query it as a regular attribute (fast, indexable).
This pattern is excellent for “count of related items” cases. Use it instead of computing the count on every UI update.
What to internalize
Relationships have cardinality (to-one, to-many, many-to-many) and direction. To-many uses Set (or NSOrderedSet if ordered). Always set inverses; Core Data manages bidirectional consistency. Use relationshipKeyPathsForPrefetching to avoid N+1 queries. For large to-many relationships, use a fetch with the relationship as a predicate, not direct traversal. Derived attributes give you cached aggregates.
11. Inverse Relationships and Why They Matter
Inverse relationships are required by Core Data for a reason: without them, the framework can’t maintain the object graph’s consistency. Strange bugs follow. This section is about why inverses matter and how to set them up correctly.
What inverse relationships do
When you set up Note.folder and Folder.notes as inverses of each other, Core Data understands they’re two sides of the same relationship. Setting one updates the other automatically:
note.folder = folder
// Core Data automatically adds `note` to `folder.notes`
Or:
folder.notes.insert(note)
// Core Data automatically sets `note.folder = folder`
This bidirectional consistency is essential for an object graph. Without it, your in-memory graph is inconsistent; saves can produce corrupted data.
What goes wrong without inverses
Consider a model with no inverse on Note.folder:
note.folder = folder
// `folder.notes` doesn't include this note (no inverse to update).
// Now if we query:
folder.notes.contains(note) // false! Even though note.folder == folder.
That’s just the start. Worse problems:
-
Orphaned objects. When you delete a folder, Core Data uses the inverse to find related notes (to apply the delete rule). Without an inverse, related notes aren’t found. They remain pointing at a now-deleted folder.
-
Faults that don’t propagate. When a folder is faulted in, Core Data updates the inverse. Without an inverse, related notes aren’t kept in sync.
-
Data corruption on save. The store has the relationship; in-memory might not match. After save and refresh, things look weird.
Apple’s documentation explicitly says: every relationship should have an inverse, even if you “only ever traverse in one direction.” Don’t argue with this — it’s a hard requirement of the framework.
Setting up inverses in the model editor
When you add a relationship, the model editor has an “Inverse” dropdown. After you’ve created both sides:
- Select
Note, look at itsfolderrelationship. - Set Destination:
Folder. - Set Inverse:
notes.
Then on the other side:
- Select
Folder, look at itsnotesrelationship. - Set Destination:
Note. - Set Inverse:
folder.
The editor warns if a relationship has no inverse — yellow triangle in the issues navigator. Address every one.
Verifying inverses
If you have a relationship that doesn’t show an inverse, the model is incomplete. Check:
- The inverse dropdown in both directions.
- The destination entity has a corresponding relationship.
If you change a relationship’s name, the inverse may break — the model editor doesn’t always auto-update. Re-verify both sides.
One-way relationships in spirit
Sometimes you want a relationship that’s “logically one-way.” For example, a User has a lastLoginNote that points to a Note, but you don’t want every Note to know about being someone’s last-login note (because most notes aren’t).
Even here, you should set up an inverse — but one that’s lightweight. Add a lastLoginUser relationship on Note, optional and to-one. Most of the time it’s nil. The model is a little fatter but the consistency works.
If you really, really don’t want the inverse, you can set the relationship’s “Inverse” dropdown to “No Inverse Relationship” — but expect Core Data to log warnings in the console, and brace for occasional consistency bugs.
Programmatic checks
You can verify the model has no missing inverses programmatically (useful in tests):
extension NSManagedObjectModel {
var relationshipsWithoutInverse: [(String, String)] {
var result: [(String, String)] = []
for entity in entities {
for (name, rel) in entity.relationshipsByName {
if rel.inverseRelationship == nil {
result.append((entity.name ?? "?", name))
}
}
}
return result
}
}
// In a test:
func test_noMissingInverses() {
let model = persistenceController.container.managedObjectModel
XCTAssertTrue(model.relationshipsWithoutInverse.isEmpty,
"Missing inverses: \(model.relationshipsWithoutInverse)")
}
This catches accidental missing inverses before they cause runtime mysteries.
Cardinality interactions
The cardinalities of two sides of a relationship must be compatible:
- to-one ↔ to-one: a one-to-one relationship.
- to-one ↔ to-many: a one-to-many relationship (the to-many side has many of the to-one side).
- to-many ↔ to-many: a many-to-many relationship.
You can’t have, e.g., to-one ↔ to-one but with both sides being non-optional except in special cases. Generally, optionality must align so that you can construct objects piecewise.
For a one-to-one relationship like User → Profile, you typically:
- Mark both sides optional in the model.
- Enforce non-nil at app logic level (e.g., always create a Profile alongside a User).
Otherwise you can’t insert a User without first having a Profile (and vice versa), which is a chicken-and-egg problem.
What to internalize
Every relationship needs an inverse. Core Data uses inverses to maintain bidirectional consistency, propagate deletes, and update faults correctly. Without inverses, your object graph silently corrupts. Set them up in the model editor; verify with a test. Compatible cardinalities and optionality matter.
12. Delete Rules
When you delete a Core Data object, what happens to objects related to it? The answer depends on the delete rule set on each relationship. Picking the right rule for each relationship is part of getting your model right.
The four delete rules
For each relationship, you set a delete rule on its source side. The four options:
- Nullify. When the source is deleted, the related object’s relationship pointer is set to nil. The related object is preserved.
- Cascade. When the source is deleted, the related object is also deleted. Cascades chain.
- Deny. When the source is deleted, Core Data refuses if there are related objects. The save fails.
- No Action. When the source is deleted, do nothing — the related object’s pointer is not updated (it points to a deleted object). You handle consistency yourself.
These apply asymmetrically: each side of a relationship has its own rule, and they can differ.
Picking the right rule
For most cases, the rules are intuitive once you think about ownership.
Cascade when the related object can’t exist without the parent. If a Folder is deleted, all its Notes should go too (assuming notes don’t move freely between folders).
Folder.notes: Cascade
Nullify when the related object exists independently. A Note might have a Tag, but deleting the tag doesn’t delete the note — just nilify the relationship.
Tag.notes: Nullify
Note.tags: Nullify (typically; they'd usually both nullify in many-to-many)
Deny when you want to forbid deletion as long as related objects exist. A User with BillingTransactions shouldn’t be deleted — deny it.
User.transactions: Deny
No Action is rare and almost always wrong. It’s “I’ll handle consistency myself,” which means you’ll forget once and end up with dangling references.
Cascade chains
A cascade can chain. If Folder.notes is cascade and Note.attachments is cascade, deleting a folder cascades to its notes, which cascades to their attachments. All deleted in one save.
This is usually what you want, but watch for cycles. If you have A → B → A, a cascade from A to B to A could loop. Core Data is supposed to handle these gracefully, but designing your model to avoid cyclic cascades is safer.
Deny: handling rejection
If a save fails because a Deny rule blocked the delete:
do {
try context.save()
} catch let error as NSError {
if error.code == NSValidationRelationshipDeniedDeleteError {
// User can't be deleted — show error to user
}
}
For UI: detect this case before the user attempts the delete. Check the count of denying-relationship items; if greater than zero, disable the delete button or show a clear message.
Combining rules with optionality
A subtle interaction: if a relationship is non-optional (required) and the related object’s relationship is nullified, you have a problem.
Example: Note.folder is non-optional and Folder.notes is Nullify. If you delete a folder, all its notes have their folder set to nil — but that violates the non-optional constraint. Save fails.
Fix: either make Note.folder optional, or use Cascade on Folder.notes. Pick what makes sense for your model.
Cascade vs application-level deletes
Sometimes you want to delete with extra logic, not just relational propagation. For example, when a user is deleted, you want to:
- Delete their notes.
- Update statistics counters elsewhere.
- Send a “user deleted” event to the analytics service.
You can use prepareForDeletion() for this:
public override func prepareForDeletion() {
super.prepareForDeletion()
Analytics.track("user_deleted", userID: id)
// Other side effects
}
prepareForDeletion runs just before the object is removed. You can read its data and trigger side effects. Don’t insert or fetch new objects here — the context is in transition.
Batch deletes and rules
NSBatchDeleteRequest (covered in section 18) bypasses delete rules. It’s a SQL DELETE, no object lifecycle, no rule application. Use it for bulk deletes, but only when you’re sure no rules need to fire.
What to internalize
Each relationship has a delete rule on its source side. Cascade for owned children. Nullify for independent associates. Deny to forbid deletion when related objects exist. No Action almost never. Watch the interaction with non-optional inverses. Use prepareForDeletion() for application-level side effects.
13. Context Concurrency Types
Core Data has the strictest concurrency model of any major iOS persistence framework. Get it wrong and you crash at random — sometimes immediately, sometimes hours into a session, sometimes only on real devices and not in the simulator. Get it right and Core Data is rock solid.
The fundamental rule
Each NSManagedObjectContext is bound to a queue. Every operation on the context — every property access on its objects, every fetch, every save — must happen on that queue.
There are no exceptions. You can’t “just read this property real quick” from a different queue. The Core Data runtime enforces this; with the right environment variable set, it crashes immediately on violation.
Concurrency types
When you create a context, you specify its concurrency type:
NSMainQueueConcurrencyType— bound to the main queue (the run loop’s queue). Use for any context that interacts with UI.NSPrivateQueueConcurrencyType— bound to its own private dispatch queue, automatically managed by Core Data.
NSPersistentContainer.viewContext is NSMainQueueConcurrencyType. NSPersistentContainer.newBackgroundContext() returns NSPrivateQueueConcurrencyType.
You almost never create contexts manually anymore — let the container do it. But if you do:
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = container.persistentStoreCoordinator
The perform family
To safely operate on a context from arbitrary code, wrap your work in perform or performAndWait:
context.perform {
// Runs on the context's queue (main or private).
// You can safely access objects, run fetches, save, etc.
}
The closure is executed on the context’s queue. If you’re already on that queue (e.g., calling viewContext.perform from main thread code), perform schedules it asynchronously — so don’t expect immediate execution.
performAndWait is the synchronous version:
context.performAndWait {
// Runs on the context's queue, blocking until complete.
}
The current thread is blocked until the closure finishes. If you’re already on the context’s queue, performAndWait runs immediately (no scheduling). This makes it safer to use recursively.
Async/await form
Modern Swift offers async-friendly versions:
let users = try await context.perform {
return try context.fetch(User.fetchRequest())
}
The closure runs on the context’s queue; the await suspends your code until it completes. This is the cleanest form for Swift Concurrency.
For the throwing variant, the closure can throw and the await propagates:
let result = try await context.perform {
let user = User(context: context)
user.name = "Alice"
try context.save()
return user.objectID
}
Note we return objectID (a Sendable value) rather than the User itself. We’ll see why in section 16.
Why the model is so strict
The strictness comes from the implementation:
- Each context has its own object graph (NSManagedObject instances are unique to a context).
- Each context has its own undo manager, change tracker, and lock state.
- Faulting (lazy loading) involves trips to the persistent store, which has its own queue.
If two threads tried to fault the same object simultaneously, or if two threads modified the same context’s pending changes, you’d get inconsistent state — randomly. Core Data’s solution is the queue-binding rule.
Catching violations: the concurrency debug flag
For development, set this environment variable in your scheme:
-com.apple.CoreData.ConcurrencyDebug 1
In Xcode: Edit Scheme → Run → Arguments → Arguments Passed On Launch.
With this enabled, Core Data crashes (with a clear stack trace) any time you access a managed object from the wrong queue. Always run with this in development. Never ship with it (it adds checks that slow things down).
The viewContext is on the main queue
viewContext.perform { ... } from the main thread schedules the closure asynchronously, even though you’re already on the right queue. To avoid this, you can access viewContext directly from main-thread code — but use perform defensively if you’re not sure what queue you’re on:
// Main thread, definitely:
let count = try? viewContext.count(for: User.fetchRequest())
// Maybe main thread, maybe not:
viewContext.perform {
let count = try? self.viewContext.count(for: User.fetchRequest())
// Use count
}
For SwiftUI views, you’re on main thread. Direct access is fine. For background work that wants to merge or query the viewContext, use perform.
MainActor and Core Data
You can mark a class @MainActor, and viewContext access from within it doesn’t need wrapping:
@MainActor
final class FolderListViewModel: ObservableObject {
@Published var folders: [Folder] = []
let context: NSManagedObjectContext // viewContext
func load() {
let request = Folder.fetchRequest()
folders = (try? context.fetch(request)) ?? []
}
}
This works because @MainActor guarantees load() runs on the main thread, and viewContext is the main-queue context. No perform needed.
For background contexts, you can’t use @MainActor — they’re on private queues. Always wrap in perform.
Putting it together
A clean pattern: a “Core Data service” type that exposes async methods:
final class NoteService {
let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func createNote(title: String, body: String) async throws -> NSManagedObjectID {
let context = container.newBackgroundContext()
return try await context.perform {
let note = Note(context: context)
note.title = title
note.body = body
try context.save()
return note.objectID
}
}
func notes() async throws -> [NoteDTO] {
let context = container.newBackgroundContext()
return try await context.perform {
let notes = try context.fetch(Note.fetchRequest())
return notes.map(NoteDTO.init)
}
}
}
struct NoteDTO {
let id: UUID
let title: String
let body: String
init(_ note: Note) {
self.id = note.id
self.title = note.title
self.body = note.body
}
}
The service wraps each operation in perform on a background context. It returns plain Swift values (DTOs) and NSManagedObjectIDs — Sendable types that can cross queues safely.
What to internalize
Every context has a queue. Every access goes on that queue. viewContext is main-queue; newBackgroundContext() is private-queue. Wrap operations in context.perform { } or context.performAndWait { }. Use the async form with Swift Concurrency. Set -com.apple.CoreData.ConcurrencyDebug 1 in development. Pass objectID (not objects) across queues.
14. Background Contexts and perform/performAndWait
Now that we know contexts are queue-bound, let’s go deeper on how background contexts integrate with the rest of your app.
Creating a background context
The simplest way:
let bgContext = container.newBackgroundContext()
This returns a new context every call. Don’t cache it as a singleton; it’s lightweight. Either:
- Create one per logical operation (an import session, a sync run).
- Create one in a service class for that service’s lifetime.
- Create one per
Taskfor short-lived background work.
Each call to newBackgroundContext() produces a context with its own queue. You can have many running concurrently.
What happens on save
When you save a background context, the changes go through the persistent store coordinator to the persistent store (SQLite). Other contexts (including viewContext) see the changes if automaticallyMergesChangesFromParent = true.
let bgContext = container.newBackgroundContext()
bgContext.perform {
let note = Note(context: bgContext)
note.title = "Imported"
try? bgContext.save()
// After save, viewContext can see the new note (eventually).
}
The merge into other contexts happens asynchronously through notifications. There’s a small lag — you can’t save in the background and immediately read in viewContext. If you need that, use viewContext.refreshAllObjects() or wait for the merge notification.
Using perform correctly
A common pattern:
bgContext.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "title == %@", "search")
let results = try? bgContext.fetch(request)
// Process results — but stay inside this closure!
for note in results ?? [] {
note.body = note.body.uppercased()
}
try? bgContext.save()
}
The closure runs on the context’s queue. Everything inside it can touch the context safely.
What you can’t do:
bgContext.perform {
let results = try? bgContext.fetch(Note.fetchRequest())
DispatchQueue.main.async {
for note in results ?? [] { // ❌ accessing background context's objects on main!
print(note.title)
}
}
}
The note objects belong to bgContext. Once you’re on the main queue, accessing them is a violation. Pass objectIDs instead:
bgContext.perform {
let results = (try? bgContext.fetch(Note.fetchRequest())) ?? []
let ids = results.map(\.objectID)
DispatchQueue.main.async {
viewContext.perform {
let mainNotes = ids.compactMap { try? viewContext.existingObject(with: $0) as? Note }
// Now safe to use on main.
}
}
}
performAndWait
The synchronous version. Useful when you need a value back synchronously:
let count: Int = bgContext.performAndWait {
return try? bgContext.count(for: Note.fetchRequest())
} ?? 0
Returns are supported in performAndWait (since iOS 15). On older versions you’d capture into a local variable from outside the closure.
performAndWait blocks the calling thread until the closure completes. Don’t call from the main thread for long operations — you’ll freeze the UI.
When can you call from the main thread?
- Quick reads:
count,fetchLimit = 1queries. - Deterministic operations that finish in milliseconds.
When you should never:
- Saves (especially with cascades or many objects).
- Heavy fetches.
- Anything involving network or file I/O.
Async form
let count = try await bgContext.perform {
return try bgContext.count(for: Note.fetchRequest())
}
The async perform integrates with Swift Concurrency. It schedules on the context’s queue, suspends the calling task, and resumes when the closure returns. Throwing closures propagate.
This is the modern style. Use it everywhere new code is written.
Background context inheritance from coordinator
newBackgroundContext() creates a context parented to the coordinator, not to the viewContext. Saves go directly to the persistent store, not through viewContext.
This is important: if you’re using parent-child contexts (next section), newBackgroundContext() doesn’t give you a child of viewContext. It gives you a sibling.
For viewContext-parented background contexts, you create them manually:
let childContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
childContext.parent = container.viewContext
Now saves in childContext propagate to viewContext (in memory), not to the store. To persist, viewContext has to be saved separately.
Workflows with background contexts
A typical import workflow:
func importNotes(from json: [[String: Any]]) async throws {
let bgContext = container.newBackgroundContext()
bgContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
try await bgContext.perform {
for noteJSON in json {
let note = Note(context: bgContext)
note.id = UUID(uuidString: noteJSON["id"] as? String ?? "") ?? UUID()
note.title = noteJSON["title"] as? String ?? ""
note.body = noteJSON["body"] as? String ?? ""
}
try bgContext.save()
}
// viewContext picks up changes via auto-merge.
}
A typical “fetch and report” flow:
func searchNotes(_ query: String) async throws -> [NoteDTO] {
let bgContext = container.newBackgroundContext()
return try await bgContext.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "title CONTAINS[cd] %@", query)
request.fetchLimit = 50
let notes = try bgContext.fetch(request)
return notes.map(NoteDTO.init) // convert to value types before returning
}
}
Returning DTOs (value types) avoids the cross-context-object problem. You read on the background, convert, return. The DTOs are safe to use anywhere.
Pitfalls
Forgetting perform. Accessing bgContext directly without perform is a concurrency violation. The debug flag will catch it; without the flag, it might “work” most of the time and crash randomly under load.
Holding onto background context objects. If you save references to NSManagedObjects from a background context, then access them later from a different context’s queue, crash. Use objectID references.
Saving in viewContext while a background context save is in progress. Both go through the coordinator; one waits for the other. Usually fine, but if both are heavy, you have UI lag.
Long-running performAndWait from main. Freezes the UI. Don’t.
Reusing background contexts
If you’re doing many small operations from a service, creating a new context for each is wasteful. Cache one:
final class NoteService {
private let bgContext: NSManagedObjectContext
init(container: NSPersistentContainer) {
self.bgContext = container.newBackgroundContext()
}
func someOperation() async {
await bgContext.perform {
// ...
}
}
}
The same context handles many operations sequentially. Beware: if you have unsaved changes and someone else triggers a fetch, the unsaved changes affect the fetch (default includesPendingChanges = true).
A reusable context can accumulate orphaned objects — references to inserted-but-never-saved objects, faults that never fired. After heavy use, consider bgContext.reset() to clean up.
What to internalize
Use newBackgroundContext() for heavy work. Always wrap in perform { } (sync), performAndWait { } (sync, blocks caller), or await context.perform { } (async). Pass objectID across queues, not objects. Auto-merge propagates background saves to viewContext. Cache contexts for services; reset after heavy use.
15. Parent-Child Contexts
Beyond viewContext + background contexts, Core Data supports nested contexts: a context with a parent property pointing to another context. Saving the child propagates changes to the parent (in memory), but doesn’t reach the persistent store until the parent is saved.
Why parent-child
The pattern is useful for:
- Editor scratchpads. A view that lets the user edit an object, with Cancel/Save semantics. Edits happen in a child; cancel discards (just don’t save the child). Save propagates to parent (which then propagates to the store).
- Validation gates. Multi-step validation; each step runs in a child context, and only fully-validated changes propagate up.
- Atomic blocks. Group operations: do many things in a child; if any fail, discard the child without affecting the parent.
Creating a child
let parent = container.viewContext
let child = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
child.parent = parent
The child can have any concurrency type, but its queue is independent of the parent’s. Most editor-scratchpad use cases create a main-queue child (so it integrates with UI without perform calls).
When you set parent, the child doesn’t get a coordinator. It uses the parent’s coordinator transitively. Don’t set both; pick one.
Save propagation
Saving a child propagates to its parent — but only as in-memory changes:
child.perform {
let note = Note(context: child)
note.title = "Draft"
try? child.save()
// Now `note` is also in the parent context, as an inserted object.
// The persistent store doesn't have it yet.
}
To actually persist:
parent.perform {
try? parent.save()
// Now `note` is in the persistent store.
}
This two-step save is the trade-off. You get isolation in the child, but you must remember to save the parent.
Editor scratchpad pattern
A SwiftUI editor:
struct NoteEditorView: View {
@Environment(\.managedObjectContext) var parentContext
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel: NoteEditorViewModel
let originalNoteID: NSManagedObjectID?
init(noteID: NSManagedObjectID?, parentContext: NSManagedObjectContext) {
self.originalNoteID = noteID
let child = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
child.parent = parentContext
let editingNote: Note
if let noteID, let existing = try? child.existingObject(with: noteID) as? Note {
editingNote = existing
} else {
editingNote = Note(context: child)
}
_viewModel = StateObject(wrappedValue: NoteEditorViewModel(note: editingNote, context: child))
}
var body: some View {
Form {
TextField("Title", text: $viewModel.title)
TextEditor(text: $viewModel.body)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
// Discard child by simply not saving.
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
do {
try viewModel.save()
try? viewModel.context.parent?.save()
dismiss()
} catch {
// Show error
}
}
}
}
}
}
@MainActor
final class NoteEditorViewModel: ObservableObject {
@Published var title: String
@Published var body: String
let note: Note
let context: NSManagedObjectContext
init(note: Note, context: NSManagedObjectContext) {
self.note = note
self.context = context
self.title = note.title
self.body = note.body
}
func save() throws {
note.title = title
note.body = body
try context.save()
}
}
The editor’s child context isolates edits. The user can change properties freely; only on “Save” do changes propagate to the parent and then to the store.
When the parent is on a different queue
If parent is mainQueue and child is privateQueue, both are accessed via perform:
let child = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
child.parent = container.viewContext
child.perform {
let note = Note(context: child)
// ...
try? child.save()
}
container.viewContext.perform {
try? container.viewContext.save()
}
Save in child runs on its own queue, marks parent as having changes. Then the parent save (on main queue) writes to the store.
Performance considerations
Child saves don’t write to disk — they’re memory-only. So child saves are fast. But:
- Each save through a child also fires save notifications, which trigger merges (for parents that listen).
- Heavy child saves followed by heavy parent saves can double the work.
For simple “edit one object, then save” cases, parent-child works well. For batch operations, prefer a single background context that saves directly to the store.
Pitfalls
Forgetting to save the parent. “I called child.save() but my changes aren’t in the database.” The child save only goes to the parent. If you don’t save the parent, the changes never persist.
Using a long-lived child. A child context accumulates state. If your editor is long-lived, the child grows. Normal pattern: child lives only as long as the editor.
Cross-context object access. Same rules as before — child.objects aren’t valid in parent’s queue. After saving the child, you can find the same object in the parent via objectID.
Three or more levels. You can have grandchild contexts. Don’t. The complexity grows non-linearly. Two levels (child of viewContext) is the sweet spot.
Alternative: direct viewContext editing
Many apps don’t bother with parent-child. They edit objects in viewContext directly. To support cancel:
viewContext.rollback()
This discards all unsaved changes in viewContext. Reverts inserted objects (they’re deleted), restores updated objects to their pre-edit values.
The disadvantage: while editing, the changes are visible to anything observing viewContext — other views, fetchedResultsControllers, etc. They’ll see “in-progress” state. For complex apps with many views, isolation via child contexts is cleaner.
What to internalize
A child context has parent set; saves propagate to the parent (in memory only). To persist, the parent must be saved separately. Useful for editor scratchpads with cancel semantics. Don’t go more than two levels deep. Direct viewContext editing with rollback() is a simpler alternative for less complex apps.
16. Concurrency Pitfalls and Object IDs
We’ve covered the rules. Now the cardinal sin and how to avoid it: passing managed objects between contexts.
NSManagedObject is not Sendable
Each NSManagedObject belongs to exactly one context. The object is bound to that context’s queue. Passing it to another queue is a violation.
This means NSManagedObject is not, and can’t easily be made, Sendable. Swift’s strict concurrency mode flags this. Even without strict checking, the runtime will crash with the concurrency debug flag.
// ❌
let note = try viewContext.fetch(Note.fetchRequest()).first!
Task.detached {
print(note.title) // crash: accessing main-context object from another queue
}
// ❌
let note = bgContext.performAndWait {
return Note(context: bgContext)
}
viewContext.perform {
note.title = "X" // crash: bg-context object on main queue
}
NSManagedObjectID is the answer
NSManagedObjectID is a Sendable identifier. Pass it instead of the object:
let id = note.objectID // Safe to capture and pass
Task.detached {
let bgContext = container.newBackgroundContext()
await bgContext.perform {
if let bgNote = try? bgContext.existingObject(with: id) as? Note {
print(bgNote.title)
}
}
}
existingObject(with:) either returns the object (faulted, available on this context’s queue) or throws if it doesn’t exist anymore.
Temporary vs permanent IDs
When you insert a new object, it gets a temporary ID. After save, the temporary ID is replaced with a permanent one. If you cache the temporary ID and the object’s been saved (giving it a permanent ID), the temporary ID is no longer valid.
let note = Note(context: viewContext)
let temporaryID = note.objectID
print(temporaryID.isTemporaryID) // true
try viewContext.save()
print(note.objectID.isTemporaryID) // false now (permanent)
print(temporaryID == note.objectID) // false! the ID changed!
To force a permanent ID before save:
try viewContext.obtainPermanentIDs(for: [note])
let permanentID = note.objectID // Safe to cache
This is the safe pattern: get a permanent ID, then pass it to other contexts. The temporary ID approach is fragile.
existingObject(with:) vs object(with:)
Two methods for resolving an objectID to an object:
existingObject(with:)— returns the object or throws if it doesn’t exist. Safe.object(with:)— returns an object (might be a fault). If the object doesn’t exist in the store, accessing the fault later throws.
Use existingObject(with:). It’s the safer one. object(with:) is for cases where you’ve stored an ID and want to navigate to it lazily — but you must handle the “object was deleted” case.
Cross-context fetches
A pattern: fetch on background, return a list of objectIDs, then resolve in viewContext for display.
func searchAndDisplay(_ query: String) async {
let bgContext = container.newBackgroundContext()
let ids = await bgContext.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "title CONTAINS[cd] %@", query)
let notes = (try? bgContext.fetch(request)) ?? []
return notes.map(\.objectID)
}
await viewContext.perform {
let mainNotes = ids.compactMap { try? viewContext.existingObject(with: $0) as? Note }
// Now safe to display mainNotes in UI.
}
}
The fetch happens on background. The IDs (which are Sendable) cross to main. The viewContext re-resolves them to its own NSManagedObject instances.
The Sendable problem in Swift Concurrency
With Swift’s strict concurrency, capturing an NSManagedObject in a Task or await-ing closure produces a warning. The standard workaround:
@unchecked Sendable
Or wrap the object in a struct that captures only Sendable info (objectID, computed from the object before crossing the boundary).
For modern code, prefer DTOs (Data Transfer Objects) that are pure Sendable values:
struct NoteSnapshot: Sendable {
let id: NSManagedObjectID
let title: String
let body: String
let createdAt: Date
}
extension NoteSnapshot {
init(_ note: Note) {
self.id = note.objectID
self.title = note.title
self.body = note.body
self.createdAt = note.createdAt
}
}
NSManagedObjectID is Sendable; the rest are basic Sendable types. The whole struct is safe to pass around. When you need the actual managed object, resolve from the ID via existingObject(with:) on the right context.
Stale objects
If you’re holding a reference to an object that was deleted in another context, and changes have merged in:
let note: Note = ... // captured earlier
// Background deleted the note and saved.
// Main context auto-merged the deletion.
print(note.title) // might crash, or return weird data
print(note.isDeleted) // true (eventually)
print(note.managedObjectContext == nil) // also true (eventually)
Always defensively check before accessing:
guard !note.isDeleted else { return }
Or re-fetch via objectID:
guard let fresh = try? context.existingObject(with: note.objectID) as? Note else {
return
}
The merge that didn’t happen
If automaticallyMergesChangesFromParent = false (or you forgot to enable it), background saves don’t reach viewContext. Symptoms:
- Inserting in background, then fetching in main: the new object isn’t there.
- Updating in background, then reading in main: the old value is returned.
Fix: enable automaticallyMergesChangesFromParent = true on viewContext at setup.
If you can’t enable it (custom merge logic needed), listen for NSManagedObjectContextDidSave:
NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: nil
) { [weak self] notification in
guard let other = notification.object as? NSManagedObjectContext, other !== self?.viewContext else { return }
self?.viewContext.perform {
self?.viewContext.mergeChanges(fromContextDidSave: notification)
}
}
Refreshing a single object
To force-refresh an object’s properties from the store:
context.refresh(note, mergeChanges: true)
mergeChanges: true keeps your in-memory edits, just reloads persisted data. mergeChanges: false discards in-memory.
What to internalize
NSManagedObject isn’t Sendable. Pass NSManagedObjectID across queues, not objects. Use existingObject(with:) to resolve IDs in another context. Get permanent IDs (obtainPermanentIDs) before passing inserted-but-unsaved objects’ IDs. Build Sendable DTOs for cross-queue results. Always check isDeleted and managedObjectContext if you’re holding a reference that might be stale.
17. Faulting and Object Lifecycles
Faulting is one of Core Data’s signature features and the source of much of its performance — and most of its weirdness. Understanding faulting changes how you think about NSManagedObject. It’s also a prerequisite for the optimization tools we’ll cover later.
What a fault is
When Core Data fetches a row, it doesn’t necessarily load all the column values into memory. Instead, it can return a fault — a placeholder object that knows its identity (objectID, entity) but hasn’t loaded its property values yet.
The fault behaves like a real object. You can pass it around, store references, check its identity. The moment you touch a property, Core Data fires the fault: it reaches into the store, fetches the row’s data, and populates the object’s attributes. From that point on, the object is fully materialized.
let notes = try viewContext.fetch(Note.fetchRequest())
let first = notes[0]
print(first.isFault) // might be true (depends on result type)
print(first.title) // fires fault: round-trip to store
print(first.isFault) // false now
Why faults exist
Faulting is a memory-and-time optimization. If you fetch 1,000 rows but only display 20 of them, you don’t want to materialize all 1,000 rows. Instead:
- Materialize 1,000 placeholder objects (cheap — just IDs and entity references).
- Fire faults only for the 20 you actually look at.
The cost of materialized objects (with all attributes in memory) is significant. For a Note with title (~100 bytes), body (~10KB), date, etc., one materialized note might be 10KB. A thousand of them is 10MB. With faulting and lazy access, you stay near zero until you actually need the data.
Faulting and to-many relationships
Relationships fault too. A Folder.notes relationship is initially a fault — accessing it triggers a fetch:
let folder = try viewContext.fetch(Folder.fetchRequest()).first!
folder.notes.count // fires the relationship fault: fetches all notes in this folder
If the folder has thousands of notes, that fetch is expensive. This is why the N+1 pattern hurts: in a loop over folders, accessing folder.notes.count for each folder is N separate fetches.
The fix, as covered in section 7: relationshipKeyPathsForPrefetching = ["notes"] on the original folder fetch, so all the relationships are loaded up front.
Fault firing performance
Each fault firing is at least one round trip to the store (SQLite). For a single object, this is fast — milliseconds at most. But at scale, fault firings dominate.
Three signals you’re firing faults you didn’t intend:
- A loop with property access on fetched objects, no prefetch.
- A list display showing relationship-derived data, no
relationshipKeyPathsForPrefetching. - “Slow” iteration over a result set that should be fast.
In Instruments → Core Data, the “Faults” instrument shows fault firings. Each one is a discrete event. Spikes in fault firings during a UI scroll = N+1.
Forcing a fault to fire
Sometimes you want the data loaded in advance:
note.title // accessing any property fires the fault
Or explicitly:
note.willAccessValue(forKey: nil)
note.didAccessValue(forKey: nil)
Or via context:
context.refresh(note, mergeChanges: true)
// Reloads object data from store; equivalent to firing fault.
Turning an object back into a fault
You can also turn objects back into faults to free memory:
viewContext.refreshAllObjects()
// All objects in viewContext become faults. Memory freed.
// Next access fires faults again.
Useful if your viewContext has accumulated thousands of materialized objects. Periodically refreshing reclaims memory.
For a single object:
viewContext.refresh(note, mergeChanges: true)
willTurnIntoFault
If you want to react when an object is about to become a fault:
public override func willTurnIntoFault() {
super.willTurnIntoFault()
// Cleanup tied to the materialized state — uncached transient values, etc.
}
This is rarely overridden, but useful for objects that cache derived state in transient (non-@NSManaged) properties.
Fault firing in different contexts
A fault in viewContext is independent from a fault in a background context. The same objectID could be a fault in one context and materialized in another.
When automaticallyMergesChangesFromParent propagates a save, related objects in the receiving context get refreshed (turned back into faults or directly updated). On next access, they materialize with the new values.
Faulting and propertiesToFetch
A fetch with propertiesToFetch asks for specific columns:
request.resultType = .dictionaryResultType
request.propertiesToFetch = ["id", "title"]
This returns dictionaries, not NSManagedObject instances. No faulting involved — just [String: Any] values. Fastest fetch type, but you lose the object graph.
returnsObjectsAsFaults
For a regular fetch (not dictionary), you can ask Core Data to return fully-materialized objects:
request.returnsObjectsAsFaults = false
Now the SQL fetch loads all attributes; the resulting objects have their data populated. No fault firing on access. Trade-off: you’re loading data you might not use.
When to set false: when you know you’ll access most attributes of every result. For a “load all 50 contacts to display in a list” scenario, false is better — you load all data once, no per-row fault firings.
When to leave true: for “load thousands, only display 20” — most of the loaded objects stay as faults and don’t pay the cost.
Lifecycle states summarized
An NSManagedObject can be in several states:
- Inserted. Just created in this context. No corresponding row in store yet.
isInserted == true. - Updated. Loaded from store, modified, not yet saved.
isUpdated == true. - Deleted. Marked for deletion. Will be removed on save.
isDeleted == true. - Faulted. In context, not yet materialized.
isFault == true. - Materialized. In context, data loaded.
- Stale. Was loaded; underlying store has been updated; in-memory copy is out of date.
- Invalid. Was deleted in another context that saved; this object is no longer valid.
managedObjectContext == nil.
You usually don’t track these explicitly — Core Data manages them. But knowing them helps debug “why is this object weird?” mysteries.
Defensive access
For an object that might be invalid:
extension NSManagedObject {
var isAlive: Bool {
return !isDeleted && managedObjectContext != nil
}
}
guard note.isAlive else { return }
print(note.title) // safe
Use this in views and view models that hold references — especially after a save/refresh cycle.
What to internalize
Faulting is lazy materialization: an object exists as a placeholder until you access its properties. Each fault fire is a round trip to the store. Use prefetching to avoid N+1. returnsObjectsAsFaults = false if you’ll access most attributes; default if you might not. refreshAllObjects reclaims memory by turning everything back into faults. Defensive checks (isDeleted, managedObjectContext) protect against stale references.
18. Batch Operations: Insert, Update, Delete
For large-scale changes, fetching objects, modifying them, and saving is slow. Core Data has three batch operation request types that bypass the context’s object graph and operate directly on the store: NSBatchInsertRequest, NSBatchUpdateRequest, NSBatchDeleteRequest. They’re orders of magnitude faster than the equivalent context-based operations.
There’s a catch: they bypass the context, so the in-memory graph doesn’t know about the changes. You have to merge the changes back yourself. We’ll cover that.
When to use batches
- Importing thousands of records.
- Marking thousands of items as read.
- Deleting old records (e.g., entries older than 30 days).
- Periodic data cleanup.
When not to use batches:
- Small changes (under a few hundred objects). Regular context operations are simpler.
- Operations that need delete rules to apply. Batches don’t fire delete rules.
- Operations that need
willSave/didSavehooks. Batches skip lifecycle.
Batch insert
func batchInsert(notesJSON: [[String: Any]], in context: NSManagedObjectContext) async throws {
try await context.perform {
let request = NSBatchInsertRequest(entity: Note.entity(), objects: notesJSON.map { json in
return [
"id": (json["id"] as? String).flatMap(UUID.init) as Any,
"title": json["title"] as? String ?? "",
"body": json["body"] as? String ?? "",
"createdAt": Date()
]
})
request.resultType = .objectIDs
let result = try context.execute(request) as? NSBatchInsertResult
if let ids = result?.result as? [NSManagedObjectID] {
self.mergeChanges(insertedIDs: ids)
}
}
}
The dictionaries are direct attribute mappings — keys must match attribute names in the model. Required attributes must be provided.
For very large datasets, the closure-based form is more memory-efficient:
var iterator = notesJSON.makeIterator()
let request = NSBatchInsertRequest(entity: Note.entity()) { (managedObject: NSManagedObject) -> Bool in
guard let json = iterator.next() else { return true } // true means stop
managedObject.setValue(json["id"] as? String, forKey: "id")
managedObject.setValue(json["title"] as? String ?? "", forKey: "title")
return false // false means continue
}
Core Data calls the closure repeatedly, asking you to fill in one object at a time. You return true when there’s nothing left.
Batch update
func batchMarkAsRead(in context: NSManagedObjectContext) async throws {
try await context.perform {
let request = NSBatchUpdateRequest(entity: Note.entity())
request.predicate = NSPredicate(format: "isRead == NO")
request.propertiesToUpdate = ["isRead": NSNumber(value: true)]
request.resultType = .updatedObjectIDsResultType
let result = try context.execute(request) as? NSBatchUpdateResult
if let ids = result?.result as? [NSManagedObjectID] {
self.mergeChanges(updatedIDs: ids)
}
}
}
Sets isRead to true on every Note matching the predicate. Single SQL UPDATE statement; no objects materialized.
Batch delete
func batchDeleteOldNotes(olderThan date: Date, in context: NSManagedObjectContext) async throws {
try await context.perform {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Note.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "createdAt < %@", date as NSDate)
let request = NSBatchDeleteRequest(fetchRequest: fetchRequest)
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
if let ids = result?.result as? [NSManagedObjectID] {
self.mergeChanges(deletedIDs: ids)
}
}
}
Single SQL DELETE. Fast. But: delete rules don’t fire. If you delete a Note that has Cascading attachments, the attachments are not deleted. You either:
- Construct a fetch request that includes everything to delete (all notes + their attachments), and execute multiple batch deletes.
- Use regular context-based deletes when delete rules matter.
Merging batch changes back
After a batch operation, the context still has stale data in memory. To inform other contexts (especially viewContext, where the UI reads from):
private func mergeChanges(insertedIDs: [NSManagedObjectID] = [],
updatedIDs: [NSManagedObjectID] = [],
deletedIDs: [NSManagedObjectID] = []) {
let changes: [AnyHashable: Any] = [
NSInsertedObjectsKey: insertedIDs,
NSUpdatedObjectsKey: updatedIDs,
NSDeletedObjectsKey: deletedIDs
]
let contexts = [viewContext] // and any other relevant contexts
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: contexts)
}
mergeChanges(fromRemoteContextSave:into:) is the API for merging batch changes. It refreshes/inserts/deletes affected objects in each receiving context.
Persistent history tracking + batches
For more sophisticated change handling — especially in apps with extensions or multiple processes — enable persistent history tracking and process the history feed:
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
Then to fetch changes since a token:
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
let result = try context.execute(request) as? NSPersistentHistoryResult
if let transactions = result?.result as? [NSPersistentHistoryTransaction] {
for transaction in transactions {
for change in transaction.changes ?? [] {
switch change.changeType {
case .insert: print("Inserted \(change.changedObjectID)")
case .update: print("Updated \(change.changedObjectID)")
case .delete: print("Deleted \(change.changedObjectID)")
@unknown default: break
}
}
}
if let lastTransaction = transactions.last {
// Save token to resume from here next time
self.lastToken = lastTransaction.token
}
}
This is how production apps handle batch changes propagating across contexts and processes. Token-based: each consumer remembers where they left off.
Performance comparison
For a benchmark of inserting 50,000 records:
- Regular context: ~30 seconds (with autoreleasepool, otherwise much worse).
- Batch insert: ~1.5 seconds.
For deleting 50,000 records:
- Fetch + delete: ~10 seconds.
- Batch delete: ~0.3 seconds.
The numbers vary by hardware, but the order-of-magnitude difference is consistent. Use batches for any operation hitting thousands of rows.
Pitfalls
Required attributes missing in batch insert. Each dictionary must include all required attributes (or the entity must have defaults for them). Awakefrom-insert hooks don’t fire — you can’t rely on them setting UUIDs and dates. Set them yourself in the dictionary.
Validation rules don’t fire. validateForInsert, validateForUpdate are skipped. If you depend on them to maintain invariants, batches break those invariants silently.
Delete rules don’t fire. As mentioned: cascades, denies, nullifies all skipped. Plan your batches to delete every related object explicitly.
Save notifications don’t fire. NSManagedObjectContextDidSave doesn’t fire on batches. If your code listens to that notification, it won’t see batch changes. Use the merge mechanism above.
Identifier conflicts. Batch insert doesn’t check uniqueness constraints unless you also have an index on the column. Verify uniqueness in your input data, or use the merge policy + a different insert path.
Putting it together: a sync tier
A common architecture: a “sync tier” that uses batches:
final class SyncService {
let container: NSPersistentContainer
func syncFromServer(_ serverData: [ServerNote]) async throws {
let context = container.newBackgroundContext()
try await context.perform {
// 1. Insert/update — use batch insert for new ones, fall back to context for updates
let newNotes = serverData.filter { $0.isNew }
try await self.batchInsert(newNotes, into: context)
let updatedNotes = serverData.filter { !$0.isNew }
try await self.batchUpdate(updatedNotes, into: context)
// 2. Delete old data
try await self.batchDeleteOlderThan(Date.now.addingTimeInterval(-86400 * 30), in: context)
}
}
// Implement helpers above...
}
The combination of batch operations and persistent history tracking makes Core Data viable for large sync workloads — millions of records — without the framework getting in your way.
What to internalize
Batch operations bypass the context: NSBatchInsertRequest, NSBatchUpdateRequest, NSBatchDeleteRequest. Orders of magnitude faster than fetch-modify-save. Trade-off: no validation, no delete rules, no lifecycle hooks. Merge changes into other contexts via mergeChanges(fromRemoteContextSave:into:). Use persistent history tracking for robust cross-context/cross-process propagation.
19. Fetch Optimization
A slow Core Data fetch is rarely Core Data being slow. It’s almost always one of: missing index, no batching, no prefetching, or a predicate that can’t use indexes. Knowing the optimization toolkit transforms slow lists into smooth ones.
Indexes
The single most impactful optimization. If you query an attribute and it’s not indexed, SQLite scans the entire table. With an index, it goes straight to matching rows.
In the model editor, select an entity, look at “Indexes”. Add an index for any attribute (or combination) you commonly query.
Note Indexes:
- createdAt
- folder
- (folder, createdAt) -- composite, for "notes in folder X by date"
A composite index (folder, createdAt) accelerates queries that filter on folder and sort by createdAt. The leftmost-prefix rule applies: it also helps folder == X alone, but not createdAt > Y alone.
Don’t over-index. Each index slows down inserts/updates because SQLite must maintain it. Index what you query, not everything.
propertiesToFetch for slim results
If you don’t need full objects, ask for only the columns you need:
let request = NSFetchRequest<NSDictionary>(entityName: "Note")
request.resultType = .dictionaryResultType
request.propertiesToFetch = ["id", "title"]
let dicts = try context.fetch(request)
// dicts is [[id: UUID, title: String]]
Returning dictionaries skips object materialization entirely. Useful for autocomplete, summary lists, anything that doesn’t need behavior.
fetchBatchSize
We’ve covered this. For lists where you might not consume all results:
request.fetchBatchSize = 20
Returns a proxy array. Items load in chunks of 20 as you access them.
relationshipKeyPathsForPrefetching
The N+1 killer. Use it whenever a fetch is followed by relationship traversal in a loop.
request.relationshipKeyPathsForPrefetching = ["folder", "tags"]
Multi-level:
request.relationshipKeyPathsForPrefetching = ["folder.parent.organization"]
Each entry causes Core Data to issue an extra fetch (or join) to load that level. Done once before the loop.
includesPropertyValues
If you only need objectIDs, set:
request.includesPropertyValues = false
Core Data fetches just the row IDs, not the column values. Used internally by count(for:). Useful if you want to fetch IDs to pass to background work without materializing.
shouldRefreshRefetchedObjects
By default, fetches respect in-memory edits — if a fetched object is already in the context with pending changes, those changes are kept. To always get the database value:
request.shouldRefreshRefetchedObjects = true
Costs a bit (extra reconciliation). Useful when you’ve accumulated stale state and want fresh data.
returnsDistinctResults
For dictionary-result fetches, this deduplicates rows:
request.resultType = .dictionaryResultType
request.propertiesToFetch = ["folder"]
request.returnsDistinctResults = true
Returns each unique folder once. Useful for “what folders have notes” queries.
Compound indexes and predicates
The predicate must match the index for it to be used. A predicate WHERE folder == ? AND createdAt > ? uses a composite (folder, createdAt) index. But WHERE createdAt > ? alone doesn’t.
You can verify by enabling SQL debug logging:
-com.apple.CoreData.SQLDebug 1
In the console, you’ll see the SQL generated. Look for EXPLAIN QUERY PLAN lines — they show whether SQLite is using an index or scanning.
Avoiding string-heavy predicates
CONTAINS and LIKE against indexed columns don’t use indexes (they have wildcards). For string search, you have options:
- Exact match.
==does use the index. - Prefix match.
BEGINSWITHcan use an index. - Substring match.
CONTAINS/LIKE %x%does a full scan.
For substring search at scale, consider denormalizing — storing a normalized lowercased version of the search field, or using a full-text-search-aware library (Core Data doesn’t have FTS built in).
Avoiding nil checks on indexed columns
Predicates with attribute == nil may or may not use indexes depending on the SQLite setup. Test with SQLDebug. If they don’t, denormalize: introduce a hasAttribute bool column that’s indexed.
Query plan with EXPLAIN
For deeper SQL inspection:
-com.apple.CoreData.SQLDebug 4
This shows query plans. You’ll see whether each predicate uses an index (SEARCH ... USING INDEX) or scans (SCAN ...). Missing indexes show up here.
Fetching just the count
Don’t fetch and count:
let count = try context.fetch(request).count // ❌ materializes all results
Use count(for:):
let count = try context.count(for: request) // ✅ SQL COUNT
Same for “any results?”:
let hasAny = try context.count(for: request) > 0
Minimizing context churn
If your app does many fetches, the context accumulates objects. Eventually it’s holding references to thousands. This:
- Slows lookup (Core Data has to check pending changes for every fetch).
- Slows save (more objects to validate).
- Increases memory.
Periodic cleanup:
context.refreshAllObjects()
// All objects become faults; their materialized state is freed.
Or for one-shot operations, use a fresh background context that’s discarded after.
Avoiding fetches in display loops
A common issue:
ForEach(notes) { note in
Text("\(note.tags.count) tags") // fires tags fault for every row!
}
Fix: prefetch in the original fetch, or use a derived attribute:
// In the model: derived attribute "tagCount" = "tags.@count"
// Then in SwiftUI: Text("\(note.tagCount) tags") -- no fault firing
Derived attributes are computed at write time, stored as regular columns, and indexable. They’re a powerful pattern.
Profiling
Use Instruments → Core Data:
- Fetches. Each fetch shown with its predicate and duration.
- Faults. Every fault firing.
- Saves. Each save with duration breakdown.
- Cache misses. When the row cache doesn’t have what’s needed.
Run your scenario, examine the timeline. Long gaps in the main thread during a list render are usually faults.
Workflow
A practical optimization workflow:
- Reproduce the slow scenario. Scroll the list, perform the search, etc.
- Run with SQLDebug enabled. See the queries.
- Run in Instruments → Core Data. See timing.
- Identify the dominant cost. Faults? Slow query? Many round trips?
- Apply targeted fix. Add an index, prefetch, batch.
- Measure again.
Don’t optimize speculatively. Most “Core Data is slow” stories are about one specific bottleneck that, once identified, is a five-line fix.
What to internalize
Indexes for attributes you query. relationshipKeyPathsForPrefetching to avoid N+1. fetchBatchSize for lazy materialization. propertiesToFetch + dictionary results for slim queries. count(for:) for counts. SQLDebug to see actual queries. Instruments → Core Data for profiling. Derived attributes for cached aggregates.
20. NSFetchedResultsController
NSFetchedResultsController (NSFRC) is Core Data’s purpose-built primitive for backing list-style UIs. It runs a fetch, watches for changes via the context, and notifies you of fine-grained updates: which row was inserted, deleted, moved, updated.
If you’re writing a UITableView or UICollectionView backed by Core Data, NSFRC is what you reach for. In SwiftUI, @FetchRequest is the equivalent — but NSFRC remains essential for non-trivial list updates and as the foundation for SwiftUI integrations without @FetchRequest.
Basic setup
class NotesViewController: UIViewController {
let container: NSPersistentContainer
var fetchedResultsController: NSFetchedResultsController<Note>!
override func viewDidLoad() {
super.viewDidLoad()
let request = Note.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchBatchSize = 20
fetchedResultsController = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
fetchedResultsController.delegate = self
do {
try fetchedResultsController.performFetch()
} catch {
print("FRC fetch failed: \(error)")
}
}
}
Three things here:
- The fetch request must have at least one sort descriptor.
- The context must be the one you’ll observe (typically viewContext).
performFetch()runs the fetch synchronously.
Once performFetch runs, fetchedObjects, sections, and the count properties are populated.
Accessing results
let count = fetchedResultsController.fetchedObjects?.count ?? 0
let note = fetchedResultsController.object(at: IndexPath(row: 0, section: 0))
For UITableView:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
fetchedResultsController.sections?[section].numberOfObjects ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "NoteCell", for: indexPath)
let note = fetchedResultsController.object(at: indexPath)
cell.textLabel?.text = note.title
return cell
}
The delegate
NSFetchedResultsControllerDelegate callbacks fire as data changes:
extension NotesViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.beginUpdates()
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
switch type {
case .insert:
tableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
tableView.deleteRows(at: [indexPath!], with: .automatic)
case .update:
tableView.reloadRows(at: [indexPath!], with: .automatic)
case .move:
tableView.moveRow(at: indexPath!, to: newIndexPath!)
@unknown default:
break
}
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange sectionInfo: NSFetchedResultsSectionInfo,
atSectionIndex sectionIndex: Int,
for type: NSFetchedResultsChangeType) {
switch type {
case .insert:
tableView.insertSections(IndexSet(integer: sectionIndex), with: .automatic)
case .delete:
tableView.deleteSections(IndexSet(integer: sectionIndex), with: .automatic)
default:
break
}
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
tableView.endUpdates()
}
}
The beginUpdates/endUpdates pair batches the row operations into a single visual update. Without it, the table view might be in an inconsistent state mid-update.
Sectioned results
To group results into sections:
let request = Note.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "folder.name", ascending: true), // section sort first
NSSortDescriptor(key: "createdAt", ascending: false) // then within section
]
fetchedResultsController = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: container.viewContext,
sectionNameKeyPath: "folder.name", // group by folder name
cacheName: nil
)
Now sections returns a list of NSFetchedResultsSectionInfo, each with its name, objects, and numberOfObjects. The first sort descriptor must match sectionNameKeyPath, otherwise sections will be wrong.
Snapshot-based delegate (modern)
iOS 13+ supports a snapshot-based callback that integrates with diffable data sources:
extension NotesViewController: NSFetchedResultsControllerDelegate {
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(typedSnapshot, animatingDifferences: true)
}
}
This delivers the entire snapshot rather than per-row callbacks. Better for diffable data sources, less granular control.
Caching
The cacheName parameter enables persistent caching of section info:
NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: "folder.name",
cacheName: "NotesByFolder"
)
Core Data writes section computation to disk. On subsequent launches, the cache is used instead of recomputing. Useful for huge sectioned datasets.
The cache is invalidated automatically if the fetch or sections change. To clear manually:
NSFetchedResultsController<Note>.deleteCache(withName: "NotesByFolder")
For most apps, leave cacheName: nil. Caching is an optimization for very specific scenarios.
Updating the fetch
If the user filters or sorts:
fetchedResultsController.fetchRequest.predicate = NSPredicate(format: "title CONTAINS[cd] %@", searchText)
do {
try fetchedResultsController.performFetch()
tableView.reloadData()
} catch {
print("Refetch failed: \(error)")
}
You modify the underlying fetchRequest and call performFetch again. The delegate doesn’t fire for this — you reload the table manually.
How NSFRC observes changes
NSFRC listens for NSManagedObjectContextDidSave and NSManagedObjectContextObjectsDidChange notifications on its context. When changes happen, it diffs the current results against the new state and fires delegate callbacks for the deltas.
This means: changes saved in another context don’t show up unless they merge into NSFRC’s context. With automaticallyMergesChangesFromParent = true, the merge happens, and NSFRC notices.
NSFRC and SwiftUI
NSFRC’s API is callback-based, designed for UIKit. In SwiftUI, @FetchRequest provides similar functionality with declarative syntax. But you might still use NSFRC in SwiftUI:
- For dynamic predicates that change at runtime (
@FetchRequestrequires a static initial predicate, though you can change it via thensPredicatesetter). - For section computations that
@SectionedFetchRequestdoesn’t handle well. - When wrapping in a custom ObservableObject for cleaner ViewModels.
We’ll cover the SwiftUI bridge in section 25.
Pitfalls
No sort descriptor. NSFRC crashes immediately. Always set at least one.
Wrong first sort. If sectionNameKeyPath is set but the first sort descriptor doesn’t match, sections come out wrong (multiple “Inbox” sections, etc.). Match them.
Forgetting to performFetch. No data appears; no errors. Always call performFetch after configuring.
Updates not propagating. If automaticallyMergesChangesFromParent is off, background saves don’t merge into the FRC’s viewContext. NSFRC sees nothing change. Enable it.
Cell update vs reconfiguration. When NSFRC says “update at indexPath”, you can call reloadRows (full re-fetch from data source) or update the cell in place. The latter is faster but trickier. For most apps, reloadRows is fine.
What to internalize
NSFetchedResultsController fetches and watches a context for changes. Callbacks fire for inserts, updates, deletes, moves. sectionNameKeyPath enables grouped results. The first sort descriptor must match the section key path. Use NSFRC for UIKit list views and as the foundation for SwiftUI integrations without @FetchRequest.
21. SwiftUI: @FetchRequest and @SectionedFetchRequest
@FetchRequest is SwiftUI’s declarative bridge to Core Data. It runs a fetch, exposes the results as a property, and re-runs the view body whenever the results change. For many apps it’s the ideal way to wire up Core Data to SwiftUI — terse, automatic, and deeply integrated with the rendering system.
The basics
struct NotesListView: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(key: "createdAt", ascending: false)],
animation: .default
)
private var notes: FetchedResults<Note>
var body: some View {
List(notes) { note in
NoteRow(note: note)
}
}
}
What @FetchRequest does:
- Creates an
NSFetchRequest<Note>with the given parameters. - Looks up the
managedObjectContextfrom the SwiftUI environment. - Runs the fetch (and re-runs as data changes).
- Exposes results as
FetchedResults<Note>— a collection-conforming type. - Causes the view body to recompute when results change.
- Animates changes if
animationis provided.
The view automatically reflects changes: insert a Note via the context, the list updates. No glue code.
Static predicates
@FetchRequest(
sortDescriptors: [NSSortDescriptor(key: "createdAt", ascending: false)],
predicate: NSPredicate(format: "isArchived == NO"),
animation: .default
)
private var notes: FetchedResults<Note>
The predicate is set once at creation time. Useful for fetches that don’t depend on view state.
Modern syntax with SortDescriptor
iOS 15+:
@FetchRequest(
sortDescriptors: [SortDescriptor(\Note.createdAt, order: .reverse)],
animation: .default
)
private var notes: FetchedResults<Note>
Type-checked sort descriptors, more readable. Both forms work; mix as needed.
Dynamic predicates via init
The hard case: you want the predicate to depend on a parameter passed to the view. @FetchRequest’s default initializer takes a static predicate, but you can override with the underscore form:
struct FilteredNotesView: View {
@FetchRequest var notes: FetchedResults<Note>
init(folder: Folder) {
let request = Note.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.predicate = NSPredicate(format: "folder == %@", folder)
_notes = FetchRequest(fetchRequest: request, animation: .default)
}
var body: some View {
List(notes) { NoteRow(note: $0) }
}
}
The trick: _notes (with underscore) accesses the property wrapper itself. You initialize it manually with a configured fetch request.
When the parent passes a different folder, the view’s init runs again — but only if SwiftUI considers it a structural change. The @FetchRequest is reinitialized, the new predicate takes effect.
Updating a @FetchRequest’s predicate at runtime
For predicates that change while the view is alive (e.g., a search field):
struct SearchableNotesView: View {
@FetchRequest(sortDescriptors: [SortDescriptor(\Note.createdAt, order: .reverse)])
private var notes: FetchedResults<Note>
@State private var searchText = ""
var body: some View {
List(notes) { NoteRow(note: $0) }
.searchable(text: $searchText)
.onChange(of: searchText) { _, newValue in
notes.nsPredicate = newValue.isEmpty ? nil :
NSPredicate(format: "title CONTAINS[cd] %@", newValue)
}
}
}
notes.nsPredicate is the setter — assign a new predicate and the fetch re-runs. Same for notes.sortDescriptors.
This is much cleaner than the init approach for runtime-changing predicates.
FetchedResults<Note>
FetchedResults conforms to RandomAccessCollection. You can:
notes.countnotes[i]notes.firstfor note in notesForEach(notes)
It’s also Identifiable-aware, so List(notes) works automatically.
The underlying type wraps an NSFetchedResultsController internally, so you get the same change-tracking efficiency.
Animations
The animation: parameter at the @FetchRequest level makes inserts, deletes, and moves animated:
@FetchRequest(
sortDescriptors: [SortDescriptor(\Note.createdAt, order: .reverse)],
animation: .default
)
This animates results changes globally. For per-change animation control, you’d need a custom approach.
@SectionedFetchRequest
For sectioned results (think: notes grouped by folder), use @SectionedFetchRequest:
struct SectionedNotesView: View {
@SectionedFetchRequest(
sectionIdentifier: \Note.folder?.name,
sortDescriptors: [
SortDescriptor(\Note.folder?.name),
SortDescriptor(\Note.createdAt, order: .reverse)
]
)
private var sections: SectionedFetchResults<String?, Note>
var body: some View {
List {
ForEach(sections) { section in
Section(section.id ?? "Unfiled") {
ForEach(section) { NoteRow(note: $0) }
}
}
}
}
}
sectionIdentifier is a key path that produces the section identifier. sortDescriptors must include a sort that puts items in the same section adjacent (matching sectionIdentifier).
SectionedFetchResults<SectionID, Element> is a collection of sections, each section is itself a collection of elements.
Animations and sections
Animations through @SectionedFetchRequest:
@SectionedFetchRequest(
sectionIdentifier: \Note.folder?.name,
sortDescriptors: [...],
animation: .default
)
Sections animate in/out as items move between groups. Quite slick when working.
Initializing inside a @FetchRequest-using view
A pattern that confuses people: you have data dependencies that change.
// ❌ This doesn't work as expected:
struct NotesView: View {
let folder: Folder // changes
@FetchRequest var notes: FetchedResults<Note>
init(folder: Folder) {
self.folder = folder
_notes = FetchRequest(...)
}
var body: some View { ... }
}
When folder changes (the parent passes a new one), SwiftUI re-runs init. The new _notes gets configured. SwiftUI invalidates the body, which re-runs and uses the new notes. Works.
The subtlety: @FetchRequest is a @DynamicProperty. SwiftUI updates its internal state when the view’s identity changes. As long as the view is “the same view” structurally, the FRC inside is reused; init changes get applied.
When @FetchRequest is wrong for you
@FetchRequest is great when:
- The view directly displays Core Data results.
- The fetch is simple (one entity, predicates, sort).
- You want minimal glue code.
- You’re OK with NSManagedObject in your view.
@FetchRequest is wrong when:
- You want testable view models that don’t know about Core Data.
- You need complex fetches with multiple entities or aggregations.
- You want to map Core Data to value-type domain models.
- You need the same data shared across many views with different filters (the FRC duplication adds up).
For these cases, write a manual fetch in a view model and inject it. Section 24 onward covers this approach.
Performance with @FetchRequest
@FetchRequest doesn’t set a fetch batch size by default. For large result sets, you should set it:
@FetchRequest(
sortDescriptors: [...],
animation: .default
)
private var notes: FetchedResults<Note>
init() {
let request: NSFetchRequest<Note> = Note.fetchRequest()
request.sortDescriptors = [...]
request.fetchBatchSize = 20
request.relationshipKeyPathsForPrefetching = ["folder", "tags"]
_notes = FetchRequest(fetchRequest: request)
}
Use the manual init form to access the underlying fetch request and tune it. Important for lists of thousands.
Pitfalls
Forgetting environment context. @FetchRequest fails silently (or crashes) if no managedObjectContext is in the environment. Set it at the App or root view level.
Predicate not updating. If you set a predicate via init that depends on a parameter, but SwiftUI doesn’t re-run init (because the view’s identity didn’t change), the predicate stays old. Use .id(folder.id) on the view to force re-init when the folder changes:
NotesListView(folder: selectedFolder)
.id(selectedFolder.id)
N+1 in the list. Showing related data (note.tags.count) without prefetching fires faults per row. Configure relationshipKeyPathsForPrefetching via the manual init.
Crashes when the underlying NSManagedObject is deleted. SwiftUI might still reference a deleted object briefly. Defensive checks (note.isDeleted, note.managedObjectContext != nil) avoid crashes.
What to internalize
@FetchRequest provides a SwiftUI-idiomatic Core Data fetch. Static predicates inline; dynamic via init or nsPredicate setter. @SectionedFetchRequest for grouped results. Configure batch size and prefetching for performance. Use the manual init form to access the underlying NSFetchRequest. Great for direct view-to-data wiring; less great for testable architectures.
22. SwiftUI: @Environment(.managedObjectContext)
@FetchRequest reads the context from the environment. To set it, you use the .environment modifier on a parent view. To use the context directly (for inserts, deletes, saves), you read it from the environment.
Setting the context
At the App level:
@main
struct MyApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
Every view in the hierarchy now sees this viewContext as the environment’s managedObjectContext. Multiple scenes can have different contexts (rare but possible).
For SwiftUI Previews:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environment(\.managedObjectContext,
PersistenceController(inMemory: true).container.viewContext)
}
}
The in-memory store gives you a clean slate per preview, no on-disk pollution.
Reading the context
In any view:
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
Button("Add Note") {
let note = Note(context: viewContext)
note.title = "New note"
try? viewContext.save()
}
}
}
The context is the same instance everywhere in the hierarchy (as long as you don’t override it). All inserts, fetches, and saves go through it.
Saving on user action
A common pattern: a button or detail view that saves on tap:
struct NoteDetailView: View {
@ObservedObject var note: Note
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
var body: some View {
Form {
TextField("Title", text: Binding(get: { note.title }, set: { note.title = $0 }))
TextEditor(text: Binding(get: { note.body }, set: { note.body = $0 }))
}
.toolbar {
ToolbarItem {
Button("Save") {
do {
try viewContext.save()
dismiss()
} catch {
// show error
}
}
}
}
}
}
The Binding(get:set:) lets TextField read and write note.title directly. SwiftUI observes the NSManagedObject (next section) so changes propagate.
Saving on app backgrounding
Catch the moment the app goes to background:
@main
struct MyApp: App {
let persistenceController = PersistenceController.shared
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
let context = persistenceController.container.viewContext
if context.hasChanges {
try? context.save()
}
}
}
}
}
This ensures unsaved edits don’t get lost when the app is suspended. Combined with explicit save on user actions, you have good coverage.
Per-feature contexts
You can override the environment in subtrees:
struct EditorContainer: View {
let parentContext: NSManagedObjectContext
let childContext: NSManagedObjectContext
init(parentContext: NSManagedObjectContext) {
self.parentContext = parentContext
let child = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
child.parent = parentContext
self.childContext = child
}
var body: some View {
NoteEditorView()
.environment(\.managedObjectContext, childContext)
}
}
The editor uses childContext. Saves there propagate to parentContext. This is parent-child isolation expressed through the environment.
Inserting and deleting
struct AddFolderButton: View {
@Environment(\.managedObjectContext) private var context
var body: some View {
Button("Add Folder") {
let folder = Folder(context: context)
folder.id = UUID()
folder.name = "New Folder"
folder.createdAt = Date()
// No save needed yet — happens later or on backgrounding.
}
}
}
struct DeleteNoteButton: View {
let note: Note
@Environment(\.managedObjectContext) private var context
var body: some View {
Button(role: .destructive) {
context.delete(note)
try? context.save()
} label: {
Label("Delete", systemImage: "trash")
}
}
}
Direct, simple, idiomatic. The view holds the context reference; operations are immediate.
Background work from a SwiftUI view
When a button triggers heavy work, route through a background context:
struct ImportButton: View {
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
Button("Import") {
Task {
let container = (viewContext.persistentStoreCoordinator?.persistentStores.first?.metadata)
// Better: have a service that holds the container
await importInBackground()
}
}
}
func importInBackground() async {
let bgContext = PersistenceController.shared.container.newBackgroundContext()
await bgContext.perform {
// import logic
try? bgContext.save()
}
}
}
The viewContext doesn’t do the work; a new background context does. Auto-merge propagates the changes. The view body refreshes (any @FetchRequest sees the new data).
delete(_:) on a FetchedResults
FetchedResults doesn’t support .remove(at:) directly. To delete from a list:
.onDelete { indexSet in
for index in indexSet {
viewContext.delete(notes[index])
}
try? viewContext.save()
}
This integrates with List’s swipe-to-delete. The delegate-style notification propagates the deletion, the row animates out.
The environment everywhere problem
In large apps, every view that touches Core Data reads @Environment(\.managedObjectContext). This is convenient but couples views to Core Data. If you later want to extract a view to a different persistence layer, you have refactoring to do.
A middle ground: views read a domain-specific service from the environment, not the context directly:
struct NotesView: View {
@Environment(\.noteService) private var noteService
var body: some View {
// ...
}
}
extension EnvironmentValues {
var noteService: NoteService {
get { self[NoteServiceKey.self] }
set { self[NoteServiceKey.self] = newValue }
}
}
struct NoteServiceKey: EnvironmentKey {
static var defaultValue: NoteService = NoteService.shared
}
Now views see a service, not Core Data. Tests inject a mock service. Section 24 covers this in depth.
What to internalize
@Environment(\.managedObjectContext) reads the context set higher in the tree. Set at App level via .environment. Subtrees can override (for child contexts). Use the context directly for inserts, deletes, and saves. Save on user action; save on backgrounding for unsaved-edit safety. For testable architectures, abstract behind a service rather than reading context directly in views.
23. SwiftUI: Observing NSManagedObject Changes
A critical fact: NSManagedObject is observable by SwiftUI. You can use @ObservedObject (or @StateObject) on an NSManagedObject and SwiftUI will redraw the view when properties change. This works because NSManagedObject is KVO-compliant, and SwiftUI’s observation machinery understands KVO via ObservableObject.
Why it works
NSManagedObject has automatic Key-Value Observing (KVO) on @NSManaged properties. When you set note.title = "X", KVO fires, observers are notified.
ObservableObject is a protocol that requires an objectWillChange publisher. NSManagedObject conforms to ObservableObject automatically (Apple does the bridging in Core Data — when any property changes, objectWillChange.send() is triggered).
This means you can use @ObservedObject on NSManagedObject directly, and SwiftUI re-renders when the object changes.
@ObservedObject on a Note
struct NoteDetailView: View {
@ObservedObject var note: Note
var body: some View {
VStack {
Text(note.title)
Text(note.body)
Text("Updated: \(note.updatedAt, style: .date)")
}
}
}
When note.title changes, the view body recomputes. Same for any property of note.
This is automatic — you don’t write any glue code. It “just works.”
@StateObject vs @ObservedObject vs nothing
For NSManagedObject:
@ObservedObject— the parent passed it in; the view doesn’t own it. Re-renders on property change.@StateObject— the view owns it. Use sparingly with NSManagedObject (since the object’s lifecycle is managed by Core Data, not SwiftUI). Generally not the right tool here.- No wrapper — you treat the object as a plain reference. SwiftUI doesn’t observe it. Property changes don’t trigger updates.
For passed-in NSManagedObjects, @ObservedObject is almost always right.
Updating in place
struct EditTitleView: View {
@ObservedObject var note: Note
@State private var draftTitle: String
init(note: Note) {
self.note = note
self._draftTitle = State(initialValue: note.title)
}
var body: some View {
TextField("Title", text: $draftTitle)
.onSubmit {
note.title = draftTitle // triggers update + save
}
}
}
Setting note.title updates the object. The viewContext (or whoever owns this object) registers the change. Anyone observing note re-renders.
For more direct binding:
TextField("Title", text: Binding(
get: { note.title },
set: { note.title = $0 }
))
This binds the text field directly to note.title. Edits update the property continuously.
Caveats around binding directly
Direct binding is convenient but has subtleties:
- Per-keystroke updates. Every keystroke updates
note.title, which marks the context dirty, which firesobjectWillChangeon every observing view. For a long page, this can cause flicker.
Fix: use a @State draft and copy on commit/blur, as in the first example.
-
Validation triggers per keystroke. If
note.titlehas validation, every keystroke runs it. Mostly fine, but watch for surprises. -
Empty strings vs nil. If your attribute is optional
String?, a binding toStringwon’t compile. Usenote.title ?? ""and convert back.
objectWillChange mechanics
When you change a property:
- KVO fires.
- Core Data’s KVO handler calls
objectWillChange.send(). - SwiftUI’s observation infrastructure sees the publisher fire.
- Views observing this object schedule a redraw on next frame.
The redraw is actually deferred to the next runloop pass — multiple changes in the same call become one redraw. So setting five properties in sequence doesn’t cause five renders; it causes one.
Faulting and observation
A faulted object responds to KVO when accessed. When you observe a faulted Note and read note.title, the fault fires, KVO doesn’t, and the view draws once. Subsequent changes do fire KVO and trigger re-renders.
Observing collections
If you @ObservedObject var folder: Folder and the view shows folder.notes, does adding a note re-render?
struct FolderView: View {
@ObservedObject var folder: Folder
var body: some View {
List(Array(folder.notes), id: \.objectID) { note in
Text(note.title)
}
}
}
Yes — adding to folder.notes (or removing) triggers KVO on the notes relationship, which fires objectWillChange on folder. The view re-renders.
But: changing properties of individual notes inside folder.notes doesn’t bubble up to folder. You need to observe each note individually:
struct FolderView: View {
@ObservedObject var folder: Folder
var body: some View {
List(Array(folder.notes), id: \.objectID) { note in
NoteRow(note: note) // NoteRow is @ObservedObject var note: Note
}
}
}
struct NoteRow: View {
@ObservedObject var note: Note
var body: some View {
Text(note.title)
}
}
Each row observes its own note. Property changes update only that row.
Animating changes
TextField("Title", text: $draftTitle)
.onSubmit {
withAnimation {
note.title = draftTitle
}
}
withAnimation wraps the property change. Any view re-render triggered by this change is animated. You can use this for slick UI updates when properties change.
Pitfalls
Object becomes invalid. If the NSManagedObject is deleted in another context (and the deletion merges in), the view might briefly hold a reference to a deleted object. Defensive check before access:
guard !note.isDeleted, note.managedObjectContext != nil else {
return Text("Note no longer available")
}
return Text(note.title)
Crashes on access after merge. Auto-merge can change object state unpredictably. You can’t always predict when an observed object becomes invalid. Wrap risky accesses in defensive checks.
Re-render storms. Observing many objects in one view, all changing rapidly, causes many re-renders. Look at Instruments → Time Profiler to see if you’re spending too much time in body computation.
Comparison with @FetchRequest
@FetchRequest observes the whole result set. It re-renders when any matching object changes. @ObservedObject on a single Note observes just that one. Use @FetchRequest for the list, @ObservedObject for the detail.
// List view: uses @FetchRequest
@FetchRequest(...) private var notes: FetchedResults<Note>
// Detail view: uses @ObservedObject
@ObservedObject var note: Note
This division is idiomatic and works well.
What to internalize
NSManagedObject conforms to ObservableObject and is KVO-driven. @ObservedObject on a managed object causes SwiftUI to re-render when any of its properties change. Direct binding to properties works but watch for per-keystroke storms. Defensive checks for stale/deleted objects. Use @FetchRequest for the list, @ObservedObject for the detail row or page.
24. SwiftUI Without Property Wrappers: The Repository Approach
@FetchRequest is convenient but has drawbacks. The view depends on Core Data types. Tests are awkward. Mocking is hard. Reusing the same data in multiple views with slight variations leads to duplicated @FetchRequests.
For larger apps, a manual approach pays off: a repository layer that exposes domain types and async APIs, keeping Core Data invisible to the view layer.
Why a repository
A repository:
- Exposes domain types (value types, not NSManagedObject).
- Provides async methods for queries and mutations.
- Hides Core Data implementation details.
- Is testable (you can mock the protocol).
- Lets you swap persistence layers later (Core Data → SwiftData → REST).
The view layer talks to the repository, not to Core Data. The view layer is thus decoupled from persistence.
Domain types
Define value types that mirror your entities:
struct NoteDTO: Identifiable, Equatable, Sendable {
let id: UUID
let title: String
let body: String
let createdAt: Date
let updatedAt: Date
let folderID: UUID?
}
struct FolderDTO: Identifiable, Equatable, Sendable {
let id: UUID
let name: String
let createdAt: Date
}
These are pure Swift value types. No Core Data dependencies. They’re Sendable, so they cross task/queue boundaries safely.
The mapping from NSManagedObject to DTO:
extension NoteDTO {
init(_ note: Note) {
self.id = note.id
self.title = note.title
self.body = note.body
self.createdAt = note.createdAt
self.updatedAt = note.updatedAt
self.folderID = note.folder?.id
}
}
Repository protocol
protocol NoteRepository: Sendable {
func notes(in folderID: UUID?, search: String?) async throws -> [NoteDTO]
func note(id: UUID) async throws -> NoteDTO?
func createNote(title: String, body: String, folderID: UUID?) async throws -> NoteDTO
func updateNote(id: UUID, title: String, body: String) async throws -> NoteDTO
func deleteNote(id: UUID) async throws
func notesPublisher(in folderID: UUID?) -> AsyncStream<[NoteDTO]>
}
The protocol defines the interface. Implementation can be Core Data, REST, in-memory mock, etc.
notesPublisher is a stream that emits whenever the underlying data changes — the bridge to “live” UI updates we’ll cover in section 25.
Core Data implementation
final class CoreDataNoteRepository: NoteRepository {
private let container: NSPersistentContainer
init(container: NSPersistentContainer) {
self.container = container
}
func notes(in folderID: UUID?, search: String?) async throws -> [NoteDTO] {
let context = container.newBackgroundContext()
return try await context.perform {
let request = Note.fetchRequest()
var predicates: [NSPredicate] = []
if let folderID {
predicates.append(NSPredicate(format: "folder.id == %@", folderID as CVarArg))
}
if let search, !search.isEmpty {
predicates.append(NSPredicate(format: "title CONTAINS[cd] %@ OR body CONTAINS[cd] %@", search, search))
}
request.predicate = predicates.isEmpty ? nil :
NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
request.relationshipKeyPathsForPrefetching = ["folder", "tags"]
let notes = try context.fetch(request)
return notes.map(NoteDTO.init)
}
}
func note(id: UUID) async throws -> NoteDTO? {
let context = container.newBackgroundContext()
return try await context.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try context.fetch(request).first.map(NoteDTO.init)
}
}
func createNote(title: String, body: String, folderID: UUID?) async throws -> NoteDTO {
let context = container.newBackgroundContext()
return try await context.perform {
let note = Note(context: context)
note.title = title
note.body = body
if let folderID {
let folderRequest = Folder.fetchRequest()
folderRequest.predicate = NSPredicate(format: "id == %@", folderID as CVarArg)
folderRequest.fetchLimit = 1
note.folder = try context.fetch(folderRequest).first
}
try context.save()
return NoteDTO(note)
}
}
func updateNote(id: UUID, title: String, body: String) async throws -> NoteDTO {
let context = container.newBackgroundContext()
return try await context.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
guard let note = try context.fetch(request).first else {
throw RepositoryError.notFound
}
note.title = title
note.body = body
try context.save()
return NoteDTO(note)
}
}
func deleteNote(id: UUID) async throws {
let context = container.newBackgroundContext()
try await context.perform {
let request = Note.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
if let note = try context.fetch(request).first {
context.delete(note)
try context.save()
}
}
}
// notesPublisher: covered in next section
func notesPublisher(in folderID: UUID?) -> AsyncStream<[NoteDTO]> {
// ...
}
}
enum RepositoryError: Error {
case notFound
}
Each method runs on a background context, performs Core Data work, returns DTOs. The view layer never sees an NSManagedObject.
View model using the repository
@MainActor
final class NotesListViewModel: ObservableObject {
@Published private(set) var notes: [NoteDTO] = []
@Published var search: String = ""
@Published private(set) var isLoading = false
private let repository: NoteRepository
private let folderID: UUID?
init(repository: NoteRepository, folderID: UUID?) {
self.repository = repository
self.folderID = folderID
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
notes = try await repository.notes(in: folderID, search: search.isEmpty ? nil : search)
} catch {
print("Load failed: \(error)")
notes = []
}
}
func deleteNote(id: UUID) async {
do {
try await repository.deleteNote(id: id)
await load() // refresh
} catch {
print("Delete failed: \(error)")
}
}
}
The view model is @MainActor, so its @Published properties update on main. It calls async repo methods, which run on background. Results come back as DTOs, ready to publish.
View using the view model
struct NotesListView: View {
@StateObject var viewModel: NotesListViewModel
var body: some View {
List(viewModel.notes) { note in
NavigationLink(destination: NoteDetailView(noteID: note.id)) {
NoteRowDTO(note: note)
}
.swipeActions {
Button(role: .destructive) {
Task { await viewModel.deleteNote(id: note.id) }
} label: {
Image(systemName: "trash")
}
}
}
.searchable(text: $viewModel.search)
.task(id: viewModel.search) {
await viewModel.load()
}
}
}
struct NoteRowDTO: View {
let note: NoteDTO
var body: some View {
VStack(alignment: .leading) {
Text(note.title).font(.headline)
Text(note.body).font(.caption).lineLimit(2)
}
}
}
The view sees [NoteDTO]. No Core Data, no NSManagedObject, no @FetchRequest. Pure value types, async loading.
Wiring up
In your App:
@main
struct MyApp: App {
let persistence = PersistenceController.shared
let repository: NoteRepository
init() {
self.repository = CoreDataNoteRepository(container: persistence.container)
}
var body: some Scene {
WindowGroup {
NotesListView(viewModel: NotesListViewModel(repository: repository, folderID: nil))
}
}
}
The repository is constructed once and injected into view models.
Tests
The big payoff. Mock the repository:
final class MockNoteRepository: NoteRepository {
var notes: [NoteDTO] = []
var createdNote: NoteDTO?
func notes(in folderID: UUID?, search: String?) async throws -> [NoteDTO] {
return notes
}
func createNote(title: String, body: String, folderID: UUID?) async throws -> NoteDTO {
let dto = NoteDTO(id: UUID(), title: title, body: body,
createdAt: Date(), updatedAt: Date(), folderID: folderID)
createdNote = dto
notes.append(dto)
return dto
}
// ... other methods
}
@Test func testLoadingNotes() async {
let mock = MockNoteRepository()
mock.notes = [
NoteDTO(id: UUID(), title: "Test", body: "Body", createdAt: .now, updatedAt: .now, folderID: nil)
]
let viewModel = NotesListViewModel(repository: mock, folderID: nil)
await viewModel.load()
#expect(viewModel.notes.count == 1)
#expect(viewModel.notes[0].title == "Test")
}
No Core Data needed in tests. No on-disk store. Tests run fast.
Trade-offs
You give up:
- The automatic UI updates from
@FetchRequest. You need to refresh manually or use the publisher pattern (next section). - Some terseness — repositories add code.
- The free
Identifiableconformance via NSManagedObject.
You gain:
- Decoupled view layer.
- Easier testing.
- Domain types you can pass anywhere.
- A natural place for caching, retry logic, etc.
For small apps, the repository pattern is overkill. For medium-to-large apps with multiple developers, it’s worth the structure.
What to internalize
The repository pattern: define value-type DTOs, define a repository protocol, implement with Core Data behind the scenes. View models depend on the protocol, not Core Data. Views depend on the view model. Tests use mock repositories. The view layer never sees NSManagedObject. Trade-offs: more code, no automatic UI updates, but huge testability and decoupling wins.
25. SwiftUI Without Property Wrappers: NSFetchedResultsController Bridges
The repository approach in section 24 is clean but loses one thing @FetchRequest gives you for free: automatic UI updates when the data changes. To get that back without @FetchRequest, we wrap NSFetchedResultsController in an AsyncStream (or a Combine publisher, or a raw delegate observer) and feed updates to our view model.
Goal
// In view model:
for await notes in repository.notesPublisher(in: folderID) {
self.notes = notes
}
The publisher emits a fresh [NoteDTO] whenever the underlying Core Data changes — inserts, updates, deletes, anywhere.
NSFRC under the hood
NSFetchedResultsController already does the change tracking. Its delegate fires for every change. We translate those callbacks into a stream of snapshots.
The simplest approach: every change, take a snapshot of the current results.
Building the AsyncStream
extension CoreDataNoteRepository {
func notesPublisher(in folderID: UUID?) -> AsyncStream<[NoteDTO]> {
AsyncStream { continuation in
let context = container.viewContext
let request = Note.fetchRequest()
var predicates: [NSPredicate] = []
if let folderID {
predicates.append(NSPredicate(format: "folder.id == %@", folderID as CVarArg))
}
request.predicate = predicates.isEmpty ? nil :
NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
request.fetchBatchSize = 50
let frc = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
let observer = FetchedResultsObserver { dtos in
continuation.yield(dtos)
}
frc.delegate = observer
do {
try frc.performFetch()
let initial = (frc.fetchedObjects ?? []).map(NoteDTO.init)
continuation.yield(initial)
} catch {
continuation.finish()
return
}
continuation.onTermination = { _ in
_ = observer // keep alive until termination
_ = frc
}
}
}
}
private final class FetchedResultsObserver: NSObject, NSFetchedResultsControllerDelegate {
let onChange: ([NoteDTO]) -> Void
init(onChange: @escaping ([NoteDTO]) -> Void) {
self.onChange = onChange
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
let notes = (controller.fetchedObjects as? [Note]) ?? []
let dtos = notes.map(NoteDTO.init)
onChange(dtos)
}
}
Walking through it:
- Create the fetch request, NSFRC.
- Set up a delegate that, on
controllerDidChangeContent, takes a snapshot and yields it. - Run
performFetchand yield the initial snapshot. - Hold references in
onTerminationto prevent ARC from killing them prematurely.
The AsyncStream returns; consumers can iterate it.
Consuming the stream
@MainActor
final class NotesListViewModel: ObservableObject {
@Published private(set) var notes: [NoteDTO] = []
private let repository: NoteRepository
private let folderID: UUID?
private var subscriptionTask: Task<Void, Never>?
init(repository: NoteRepository, folderID: UUID?) {
self.repository = repository
self.folderID = folderID
}
func subscribe() {
subscriptionTask?.cancel()
subscriptionTask = Task { [weak self] in
guard let self else { return }
let stream = await self.repository.notesPublisher(in: self.folderID)
for await notes in stream {
self.notes = notes // updates @Published, view re-renders
}
}
}
func unsubscribe() {
subscriptionTask?.cancel()
subscriptionTask = nil
}
deinit {
subscriptionTask?.cancel()
}
}
The view model holds a Task that consumes the stream. As changes flow through, self.notes updates. Because the view model is @MainActor and notes is @Published, the view re-renders.
Cancellation
struct NotesListView: View {
@StateObject var viewModel: NotesListViewModel
var body: some View {
List(viewModel.notes) { /* ... */ }
.task {
viewModel.subscribe()
}
}
}
The .task modifier runs an async closure tied to the view’s lifecycle. When the view disappears, the task is cancelled. The subscribe() method’s Task is cancelled, the for await loop exits, the AsyncStream’s onTermination fires, and resources are freed.
Handling threading correctly
The example above is simplified — there’s a subtle threading issue. NSFRC’s context is viewContext (main queue). The delegate callbacks fire on main. The .map(NoteDTO.init) runs on main. If the result set is large, that’s a main-thread cost.
For better performance, use a background context:
func notesPublisher(in folderID: UUID?) -> AsyncStream<[NoteDTO]> {
let bgContext = container.newBackgroundContext()
bgContext.automaticallyMergesChangesFromParent = true
return AsyncStream { continuation in
bgContext.perform {
// Set up the FRC inside `perform` so it's on the right queue.
let request = Note.fetchRequest()
// ... configure as before, with bgContext
let frc = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: bgContext,
sectionNameKeyPath: nil,
cacheName: nil
)
// delegate, performFetch, etc.
// The conversion to DTOs happens on bg queue; yield to continuation.
}
}
}
This is more involved. For most apps, the main-context version is fine — the conversion cost is small.
Throttling
For rapidly-changing data (typing into a text field that triggers many inserts), the stream might emit faster than the view can render. You can throttle:
import AsyncAlgorithms // or write your own
let throttled = stream.throttle(for: .milliseconds(100))
for await notes in throttled {
self.notes = notes
}
AsyncAlgorithms is Apple’s async-friendly utility library. If you don’t want the dependency, you can write a manual throttle with Task.sleep.
Snapshot-based delegate (modern alternative)
Instead of controllerDidChangeContent, use the snapshot delegate:
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
let ids = typedSnapshot.itemIdentifiers
// resolve IDs to NSManagedObjects, map to DTOs
}
The snapshot has [NSManagedObjectID]. You resolve via existingObject(with:) and map.
This is closer to how UIKit diffable data sources work. Use either delegate flavor; pick what feels clean.
Memory and lifecycle
A pitfall: the FRC must outlive the stream. If the FRC is deallocated, callbacks stop. The pattern in the AsyncStream code above — keeping references in onTermination — handles this. The FRC is kept alive by the closure capture; only when the consumer cancels does it get released.
Comparing with @FetchRequest
@FetchRequest is essentially the same machinery (NSFRC under the hood) wrapped in a SwiftUI-native API. The difference: with @FetchRequest, the dependency on Core Data is explicit and the results are NSManagedObjects. With this approach, the dependency is hidden and results are DTOs.
For trivial cases, @FetchRequest wins on simplicity. For testable, layered architectures, the AsyncStream approach is preferred.
What to internalize
Wrap NSFetchedResultsController in an AsyncStream<[DTO]>. The delegate fires on changes; convert to DTOs and yield. The view model consumes the stream and updates @Published properties. Lifecycle tied to view via .task. For performance, do conversion on a background context.
26. SwiftUI Without Property Wrappers: Combine and AsyncSequence
Beyond NSFRC bridges, Core Data integrates with Combine and Swift Concurrency in several places. Knowing these tools gives you flexibility for different scenarios.
Notifications via Combine
NSManagedObjectContextDidSave and friends are NSNotifications, easily wrapped:
import Combine
extension NSManagedObjectContext {
var didSavePublisher: AnyPublisher<Notification, Never> {
NotificationCenter.default.publisher(
for: .NSManagedObjectContextDidSave,
object: self
).eraseToAnyPublisher()
}
var objectsDidChangePublisher: AnyPublisher<Notification, Never> {
NotificationCenter.default.publisher(
for: .NSManagedObjectContextObjectsDidChange,
object: self
).eraseToAnyPublisher()
}
}
Then in a view model:
final class NotesViewModel {
private var cancellables = Set<AnyCancellable>()
init(context: NSManagedObjectContext) {
context.didSavePublisher
.sink { [weak self] notification in
self?.refetch()
}
.store(in: &cancellables)
}
private func refetch() {
// re-run a fetch and update local state
}
}
Useful for “refresh when anything saves” — coarse-grained but simple.
Filtering by entity
context.didSavePublisher
.filter { notification in
let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? []
return [inserted, updated, deleted].contains { set in
set.contains { $0 is Note }
}
}
.sink { [weak self] _ in
self?.refetch()
}
.store(in: &cancellables)
Only refetch when a Note is changed; ignore Folder or Tag changes.
KVO via Combine
For observing a specific object:
note.publisher(for: \.title)
.sink { newTitle in
print("Title is now: \(newTitle)")
}
.store(in: &cancellables)
KVO publisher works on any @objc dynamic property — including @NSManaged. Updates fire whenever the property changes.
AsyncSequence from notifications
Convert a notification name to an AsyncSequence:
let didSaveSequence = NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave)
for await notification in didSaveSequence {
// handle save
}
NotificationCenter.notifications(named:) returns an AsyncSequence of Notification. Combined with Swift Concurrency, you have a clean way to react to saves without Combine.
Wrapping a fetch in an AsyncStream
For “fetch and emit, then watch for changes”:
func notesAsyncStream() -> AsyncStream<[NoteDTO]> {
AsyncStream { continuation in
let observerTask = Task {
// Initial emission
let initial = (try? await fetchNotes()) ?? []
continuation.yield(initial)
// Subsequent emissions on save
let saves = NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave)
for await _ in saves {
let fresh = (try? await fetchNotes()) ?? []
continuation.yield(fresh)
}
}
continuation.onTermination = { _ in
observerTask.cancel()
}
}
}
private func fetchNotes() async throws -> [NoteDTO] {
let context = container.newBackgroundContext()
return try await context.perform {
let notes = try context.fetch(Note.fetchRequest())
return notes.map(NoteDTO.init)
}
}
This is a simpler alternative to NSFRC: just listen for any save and re-fetch. It’s less efficient (re-fetches everything every time something saves) but easier to reason about. For small data sets, it’s fine.
Combining multiple sources
If your data combines multiple Core Data entities + a remote source, Combine shines:
let coreDataNotes = context.didSavePublisher.compactMap { _ in /* fetch local notes */ }
let remoteNotes = remoteService.notesPublisher
Publishers.CombineLatest(coreDataNotes, remoteNotes)
.map { local, remote in
// merge, deduplicate
return mergedNotes
}
.receive(on: DispatchQueue.main)
.assign(to: &$displayedNotes)
You can compose Core Data, REST, and other event sources into one pipeline. This is where Combine’s strength shows.
Async/await for one-shot operations
For non-streaming operations (one fetch, one save), async/await is cleaner than Combine:
func loadNotes() async throws -> [NoteDTO] {
try await container.newBackgroundContext().perform {
let notes = try $0.fetch(Note.fetchRequest())
return notes.map(NoteDTO.init)
}
}
// Usage:
let notes = try await loadNotes()
(Wait, the closure parameter trick with $0 for context only works if perform’s closure has the context as parameter. It doesn’t — perform is a method on context, so the context is self in scope. Replace $0 with the captured context.)
When to choose which
AsyncStreamfor a stream of values that updates over time, consumed by Swift Concurrency code.- Combine
Publisherfor complex pipelines (merging, throttling, mapping, etc.). - NSFetchedResultsController directly when you need fine-grained delegate callbacks (insert/delete/move per row, not just snapshots).
- Plain async/await for one-shot operations.
- Notifications when you need to react to global events but don’t need a structured stream.
Most production apps use a mix: NSFRC under the hood, wrapped in AsyncStream for view-model consumption, with KVO publishers for individual-object observation.
Combine assign(to:) to a published property
@Published var notes: [NoteDTO] = []
context.didSavePublisher
.compactMap { _ in self.fetchNotesSync() }
.receive(on: DispatchQueue.main)
.assign(to: &$notes)
assign(to: &$notes) (note the $ and &) connects a publisher directly to a @Published property. Updates flow automatically. assign(to:) returns nothing — there’s no AnyCancellable to manage; cancellation is tied to the property’s owner.
What to internalize
Notifications + Combine = simple coarse-grained observation. KVO via Combine watches single objects’ properties. NotificationCenter.notifications(named:) gives AsyncSequence form. AsyncStream wrapping an NSFRC is the structured streaming pattern. Use assign(to: &$prop) for clean Combine-to-Published bridges. Pick the tool to match the level of granularity you need.
27. SwiftUI Without Property Wrappers: Hand-Rolled ObservableObject
The repository + AsyncStream pattern is elegant but adds layers. Sometimes you want a more direct approach: an ObservableObject that wraps Core Data fetching and observation directly, exposing @Published arrays of NSManagedObjects (or DTOs) to the view.
This sits between @FetchRequest (auto, but coupled) and the full repository pattern (decoupled, but more code).
The pattern
@MainActor
final class NotesStore: ObservableObject {
@Published private(set) var notes: [Note] = []
@Published private(set) var error: Error?
private let context: NSManagedObjectContext
private var observer: NSObjectProtocol?
init(context: NSManagedObjectContext) {
self.context = context
observer = NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: nil
) { [weak self] _ in
Task { @MainActor in
self?.refetch()
}
}
refetch()
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
}
private(set) var predicate: NSPredicate? {
didSet { refetch() }
}
func refetch() {
let request = Note.fetchRequest()
request.predicate = predicate
request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
do {
notes = try context.fetch(request)
error = nil
} catch let e {
error = e
notes = []
}
}
func setSearch(_ text: String) {
if text.isEmpty {
predicate = nil
} else {
predicate = NSPredicate(format: "title CONTAINS[cd] %@ OR body CONTAINS[cd] %@", text, text)
}
}
func delete(_ note: Note) {
context.delete(note)
try? context.save()
}
func createNote(title: String, body: String) {
let note = Note(context: context)
note.title = title
note.body = body
try? context.save()
}
}
This NotesStore:
- Is
ObservableObjectwith@Publishedproperties. - Holds a viewContext.
- Observes
NSManagedObjectContextDidSaveand refetches. - Exposes operations: search, delete, create.
- Is
@MainActorso all access is on main.
Using it in a view
struct NotesListView: View {
@StateObject var store: NotesStore
init(context: NSManagedObjectContext) {
_store = StateObject(wrappedValue: NotesStore(context: context))
}
var body: some View {
List(store.notes, id: \.objectID) { note in
NoteRow(note: note)
.swipeActions {
Button(role: .destructive) { store.delete(note) } label: {
Image(systemName: "trash")
}
}
}
.searchable(text: Binding(
get: { "" },
set: { store.setSearch($0) }
))
}
}
The view talks to store, not Core Data directly. The store owns the fetching and observation.
Trade-offs vs full repository
Compared to the repository pattern:
- ✅ Less code (no DTO mapping, no separate view model).
- ✅ Direct Core Data behavior (you can use NSManagedObject’s KVO,
@ObservedObject, etc.). - ❌ View models still see NSManagedObject (some coupling).
- ❌ Harder to mock (the store depends on a real context).
Compared to @FetchRequest:
- ✅ More control (custom logic in the store, custom search, custom errors).
- ✅ Easier to test (you can inject a context with seed data).
- ✅ Reusable across multiple views.
- ❌ More boilerplate.
For small-to-medium apps, this hand-rolled pattern is a sweet spot.
Granular observation
The “refetch on every save” approach is coarse — every save anywhere triggers a refetch here, even if the saved object isn’t a Note. Refine by inspecting the userInfo:
observer = NotificationCenter.default.addObserver(
forName: .NSManagedObjectContextDidSave,
object: nil,
queue: nil
) { [weak self] notification in
let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> ?? []
let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? []
let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> ?? []
let allChanges = inserted.union(updated).union(deleted)
let hasNoteChange = allChanges.contains { $0.entity.name == "Note" }
if hasNoteChange {
Task { @MainActor in
self?.refetch()
}
}
}
Now Folder edits don’t trigger Note refetches.
Avoiding refetch storms
Multiple saves in quick succession cause multiple refetches. Throttle:
import Combine
private var refetchSubject = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
init(...) {
refetchSubject
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
self?.refetchInternal()
}
.store(in: &cancellables)
// Notification observer triggers refetchSubject.send(), not refetchInternal directly
}
A 100ms throttle batches rapid changes into one refetch.
Updates and observation
A subtle point: the store’s notes array contains NSManagedObject references. If a note is updated (not inserted or deleted), the array doesn’t change identity — but the property of an element does.
If your view shows note.title, and note.title changes, the view doesn’t refresh because @Published only signals on assignment, not on element mutation.
Two fixes:
- Use
@ObservedObjecton each row. EachNoteRowobserves its ownnote. KVO triggers row re-renders independently. - Convert to DTOs in the store.
[NoteDTO]is a value type; mutations create new arrays,@Publishedfires.
Pick based on your needs: NSManagedObjects + @ObservedObject for live KVO updates per row, or DTOs for fully-explicit data flow.
Multiple stores
In a larger app, you might have a NotesStore, FoldersStore, TagsStore, each handling its area. Keep them small, single-purpose. Compose at the app level:
@MainActor
final class AppStores {
let notes: NotesStore
let folders: FoldersStore
let tags: TagsStore
init(context: NSManagedObjectContext) {
self.notes = NotesStore(context: context)
self.folders = FoldersStore(context: context)
self.tags = TagsStore(context: context)
}
}
Inject AppStores (or individual stores) via environment.
What to internalize
A hand-rolled ObservableObject is the middle ground between @FetchRequest and a full repository: it owns the fetching and observation, exposes results as @Published, and provides operations. Refetch on NSManagedObjectContextDidSave, filter by entity, throttle to avoid storms. Use NSManagedObjects + @ObservedObject per row for KVO-driven updates, or DTOs for explicit value-type flow.
28. Migrations: Lightweight and Manual
When you change your data model — add a new entity, add an attribute, rename something, restructure relationships — and ship the change, existing user data needs to be migrated. Core Data handles this with model versioning and migrations. Get this right and updates work seamlessly. Get it wrong and users lose data on update.
Versioning your model
A .xcdatamodeld file is a directory of versions (.xcdatamodel). The “current” version is selected via the file’s inspector.
To add a new version: in Xcode, with the model file selected, Editor → Add Model Version. Name it (e.g., “Model 2”). Choose “based on Model” so the new version starts as a copy.
Now you have two versions: the original (v1) and v2. Edit v2 to make your changes. Set v2 as the current version (file inspector → Versioned Core Data Model → Current).
Build the app. The shipping binary now expects the v2 schema. On launch, Core Data sees:
- The store on disk has the v1 schema (from the previous version).
- The model in the binary is v2.
- A migration is needed.
Lightweight migration
For compatible changes, Core Data can migrate automatically:
- Adding a new entity.
- Adding a new attribute (with a default value, or marked optional).
- Removing an attribute.
- Adding/removing relationships.
- Renaming attributes (with hints — see below).
- Renaming entities (also with hints).
Enable lightweight migration on the persistent store description:
let description = container.persistentStoreDescriptions.first!
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
Both of these are true by default for NSPersistentContainer, so you usually don’t write this code. But if you’ve customized the descriptions, make sure these flags are set.
When Core Data loads the store and sees a version mismatch, it:
- Compares the source model (v1) and destination model (v2).
- Tries to infer a mapping model.
- If successful, runs the inferred migration.
- If unsuccessful, throws an error.
For most schema changes — especially additive ones — this works without you doing anything.
Renaming with hints
If you rename an attribute (createdAt → dateCreated), the editor doesn’t know it’s a rename — it looks like “delete createdAt + add dateCreated”. Core Data drops the old column and creates a new empty one; user data is lost.
To preserve data: in the v2 model, select the renamed attribute, look at the inspector → “Renaming ID”. Set it to the old name (createdAt).
Now Core Data understands “this new attribute corresponds to that old attribute” and copies values.
Same for renaming entities: set the entity’s “Renaming ID” to the old name.
When lightweight isn’t enough
Some changes are too complex:
- Splitting one entity into two.
- Combining two entities into one.
- Changing an attribute’s type (e.g., String to Integer).
- Re-shaping data programmatically (e.g., normalize phone numbers).
- Adding a derived attribute that needs to be backfilled with logic.
For these, you write a mapping model and possibly a custom migration policy.
Mapping models
A mapping model (.xcmappingmodel) is a file that specifies how to transform a source model into a destination model: which entities map to which, which attributes map to which, with optional value-transformation expressions.
Create one: File → New → File → Mapping Model. Pick the source model version and the destination version. Xcode generates a default mapping (which is what lightweight inference would produce).
In the mapping editor, you see each entity mapping and within it, each attribute mapping. You can:
- Customize the source/destination expressions (
$source.firstName + ' ' + $source.lastNamefor combining attributes). - Specify a custom policy class for complex per-entity logic.
- Reorder mappings to control dependency order (e.g., create new entities before referencing them).
Custom migration policy
For logic that the mapping model can’t express — calling external services, complex computations — write an NSEntityMigrationPolicy subclass:
import CoreData
final class FolderToOrganizedFolderPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager) throws {
try super.createDestinationInstances(forSource: sInstance, in: mapping, manager: manager)
// Get the destination instance just created
guard let dInstance = manager.destinationInstances(forEntityMappingName: mapping.name,
sourceInstances: [sInstance]).first else {
return
}
// Custom logic: compute a normalized name
let originalName = sInstance.value(forKey: "name") as? String ?? ""
let normalized = originalName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
dInstance.setValue(normalized, forKey: "normalizedName")
}
}
Register the policy in the mapping model: select the entity mapping, set “Custom Policy” to FolderToOrganizedFolderPolicy (with full module path).
Now during migration, Core Data calls your createDestinationInstances for each source object, you customize the resulting destination object.
Triggering a manual migration
If automatic migration is disabled (or you want explicit control):
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: destinationModel)
let storeURL = ... // current store URL
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType, at: storeURL)
if !destinationModel.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) {
// Migration needed
let sourceModel = try findSourceModel(for: metadata) // your code to locate the right source model
let migrationManager = NSMigrationManager(sourceModel: sourceModel, destinationModel: destinationModel)
let mappingModel = try NSMappingModel.inferredMappingModel(forSourceModel: sourceModel,
destinationModel: destinationModel)
let intermediateURL = storeURL.appendingPathExtension("migrated")
try migrationManager.migrateStore(from: storeURL,
sourceType: NSSQLiteStoreType,
options: nil,
with: mappingModel,
toDestinationURL: intermediateURL,
destinationType: NSSQLiteStoreType,
destinationOptions: nil)
// Replace the original store with the migrated one
try FileManager.default.removeItem(at: storeURL)
try FileManager.default.moveItem(at: intermediateURL, to: storeURL)
}
// Now load normally
try coordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: nil)
This is involved. Use only when you need fine control — typically for custom UI during migration, error handling, or progressive migration.
Progressive migration
If users skip versions — they have v1 and update to a binary that ships v3 — Core Data tries to migrate v1 → v3 directly. If you have a mapping model for v1 → v2 and v2 → v3 but not v1 → v3, the migration fails.
Solution: progressive migration. Walk forward through versions:
func migrateStore(at url: URL, to finalModel: NSManagedObjectModel) throws {
var currentURL = url
while true {
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType, at: currentURL)
if finalModel.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) {
break // done
}
let sourceModel = try findSourceModel(for: metadata)
guard let nextModel = nextModelInChain(from: sourceModel) else {
throw MigrationError.noPath
}
let mapping = try NSMappingModel.inferredMappingModel(forSourceModel: sourceModel,
destinationModel: nextModel) // or load custom .xcmappingmodel
let migratedURL = currentURL.appendingPathExtension("step")
let migrator = NSMigrationManager(sourceModel: sourceModel, destinationModel: nextModel)
try migrator.migrateStore(from: currentURL, sourceType: NSSQLiteStoreType, options: nil,
with: mapping, toDestinationURL: migratedURL,
destinationType: NSSQLiteStoreType, destinationOptions: nil)
try FileManager.default.removeItem(at: currentURL)
try FileManager.default.moveItem(at: migratedURL, to: currentURL)
}
}
Find each step’s source model, infer or load the mapping, migrate, repeat. After all steps, the store is at the final version.
The helpers (findSourceModel, nextModelInChain) need to be implemented based on your version naming. A common approach: numerical version names (Model 1, Model 2, …) and walk in order.
Backups before migration
Migration can fail mid-way and leave the store in a bad state. Before migration, copy the file:
let backupURL = storeURL.deletingLastPathComponent().appendingPathComponent("Model-backup.sqlite")
try FileManager.default.copyItem(at: storeURL, to: backupURL)
// Run migration
// On failure, restore from backup
For SQLite, also back up the -wal and -shm files (they’re part of the database).
Testing migrations
Migration bugs are notoriously hard to catch — they only manifest when a user updates from a specific version. Test:
- Create a snapshot of the database file from each shipped version.
- In CI, copy the snapshot into your test bundle.
- In the test, point your
NSPersistentContainerat it, load, verify migration succeeded and data is intact.
func test_migrationFromV1ToCurrent() throws {
let snapshotURL = Bundle.test.url(forResource: "v1-snapshot", withExtension: "sqlite")!
let testStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.sqlite")
try FileManager.default.copyItem(at: snapshotURL, to: testStoreURL)
let container = NSPersistentContainer(name: "Model")
container.persistentStoreDescriptions.first?.url = testStoreURL
container.loadPersistentStores { _, error in
XCTAssertNil(error)
}
let context = container.viewContext
let request = Note.fetchRequest()
let notes = try context.fetch(request)
XCTAssertEqual(notes.count, 100) // your seed data
XCTAssertEqual(notes.first?.title, "Expected title")
}
This catches migration bugs in your CI before users see them.
What to internalize
Each release that changes the model needs a new model version. Lightweight migration handles additive and renaming changes (with hints). Mapping models + custom policies handle complex transforms. Progressive migration walks through versions when users skip. Always back up before migrating. Test migrations with snapshot databases in CI.
29. CloudKit Integration with NSPersistentCloudKitContainer
NSPersistentCloudKitContainer adds iCloud sync to your Core Data stack with surprisingly little code. Behind the scenes, it watches your local Core Data store, mirrors changes to CloudKit, and pulls down changes from CloudKit on other devices. The trade-off: CloudKit’s data model is constrained, sync isn’t instant, and conflict resolution is semi-magic. Knowing both the simplicity and the constraints is essential.
The basic setup
Replace NSPersistentContainer with NSPersistentCloudKitContainer:
import CloudKit
import CoreData
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "Model")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Missing persistent store description")
}
// Required for sync
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// CloudKit container reference
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.example.MyApp"
)
container.loadPersistentStores { description, error in
if let error { fatalError("Failed to load: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
Capabilities and entitlements you need:
- iCloud capability enabled in your target.
- CloudKit checked, with the container ID
iCloud.com.example.MyApp(matching what you pass). - Push notifications enabled (CloudKit uses silent push to notify of changes).
- Background modes → Remote notifications enabled.
With these set up, the app launches, loads the store, and starts syncing. New writes to viewContext save locally first, then mirror to CloudKit asynchronously. Changes from other devices come in via push, get merged into the store, and automaticallyMergesChangesFromParent propagates them to viewContext.
Schema constraints
CloudKit imposes constraints that don’t apply to local-only Core Data:
- All attributes must be optional (or have a default value). CloudKit treats every field as nullable.
- All relationships must be optional and have an inverse. Required relationships fail.
- No constraints (uniqueness). CloudKit doesn’t enforce uniqueness; you handle it in app logic.
- No
Undefined-type attributes.
The model editor warns about CloudKit-incompatible features when you target a CloudKit configuration.
Initializing the schema
Before sync works, the CloudKit-side schema needs to exist. For development, you can ask Core Data to create it:
do {
try container.initializeCloudKitSchema(options: [])
} catch {
print("Schema init failed: \(error)")
}
Run this once during development (in a debug-only path). It creates the record types in CloudKit Dashboard. After that, the schema is in place; deploy to production via CloudKit Dashboard.
For production, never call initializeCloudKitSchema — only Dashboard operations should change production schema. You’d promote the development schema to production manually before shipping.
Public, private, shared databases
CloudKit has three database types:
- Private: the user’s own data, synced across their devices. The default for personal apps.
- Public: data shared with all users of the app. Read by anyone, write privileges configurable.
- Shared: data the user has been invited into.
By default, NSPersistentCloudKitContainer uses the private database. For public:
let publicDescription = NSPersistentStoreDescription(url: publicStoreURL)
publicDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
publicDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.MyApp")
publicDescription.cloudKitContainerOptions?.databaseScope = .public
container.persistentStoreDescriptions = [privateDescription, publicDescription]
Now you have two stores — one synced to private, one to public. You assign entities to configurations to control which goes where (in the model editor).
Sharing
For the shared database (per-user invites), you create NSShare objects via CloudKit and share specific objects:
let share = CKShare(rootRecord: rootRecord)
let modifyOp = CKModifyRecordsOperation(recordsToSave: [rootRecord, share], recordIDsToDelete: nil)
// configure & execute
Core Data has higher-level APIs for sharing (share(_:to:) on the container), but the implementation is involved. For most apps, the private database covers the use case; sharing is for collaborative apps.
Conflict resolution
When two devices edit the same object offline and reconnect:
- Without merge policy: the save fails and the conflict surfaces as an error.
- With
NSMergeByPropertyObjectTrumpMergePolicy: the in-memory state wins; the cloud is overwritten. - With Core Data + CloudKit’s defaults: last-write-wins per-property.
The merge is automatic. Each property is overwritten by whichever side has the newer change. Mostly fine for typical apps; for complex semantics (e.g., counter increments where last-write loses one), you need application-level resolution.
Sync timing
CloudKit isn’t instant. After a save, expect:
- Best case (foreground, good network): seconds.
- Typical: tens of seconds.
- Worst case (background, throttled): minutes or longer.
Don’t design UX that requires immediate cross-device updates. The app should work with eventually-consistent semantics.
Watching sync state
Monitor sync events:
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: nil
) { notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
return
}
switch event.type {
case .setup: print("Setup event: \(event.identifier)")
case .import: print("Import: \(event.startDate) -> \(event.endDate ?? Date())")
case .export: print("Export: \(event.startDate) -> \(event.endDate ?? Date())")
@unknown default: break
}
if let error = event.error {
print("Sync error: \(error)")
}
}
Useful for showing a “syncing…” indicator or logging errors.
Common errors
Account changes. If the user signs out of iCloud or switches accounts, the sync stops. You can check:
let status = await CKContainer.default().accountStatus()
switch status {
case .available: break
case .noAccount: // user not signed in
case .restricted: // parental controls
case .couldNotDetermine: // network issue
case .temporarilyUnavailable: break
@unknown default: break
}
Show appropriate UI (“Sign in to iCloud to sync”). Once they sign back in, sync resumes.
Quota exceeded. Each user has a free CloudKit quota; if exceeded, writes fail. Monitor event.error for CKError.Code.quotaExceeded.
Schema mismatches. If you ship a version with new fields and the production schema isn’t updated, sync fails for the new fields. Always update schema in CloudKit Dashboard before shipping a release with model changes.
Local-only entities
Sometimes you don’t want everything synced. Cached data, transient state, etc.
Solution: configurations. In the model editor, create a “Local” configuration and assign certain entities to it. Set up a second persistent store description that uses this configuration but no cloudKitContainerOptions. The entities in the Local configuration go to a local-only store; the rest sync.
Migrations with CloudKit
Schema migrations are trickier with CloudKit because the cloud schema must update in lockstep:
- Update your local Core Data model (new version).
- Update CloudKit Dashboard schema (or call
initializeCloudKitSchemain dev). - Promote schema to production.
- Ship the app update.
Older clients (with the old schema) keep working because CloudKit ignores fields they don’t know about. The catch: if you make incompatible changes (deleting a field other clients still write to), older clients break.
Plan model changes carefully. Add fields rather than rename. Never drop fields you’ve shipped without first migrating users to new ones.
Performance considerations
NSPersistentCloudKitContainer issues a lot of CloudKit operations. Each save can trigger uploads of records, fetches of zone changes, etc.
For large initial syncs (thousands of records), expect minutes. Show progress to the user. Sync continues in the background even when the app is backgrounded (as long as CloudKit pushes can wake it).
For high-volume writes (e.g., per-frame analytics), CloudKit isn’t the right tool. It’s designed for user-initiated data, not high-frequency machine writes.
What to internalize
NSPersistentCloudKitContainer adds iCloud sync with minimal code. All attributes/relationships must be optional. Persistent history tracking required. Three databases: private (default), public, shared. initializeCloudKitSchema for dev; promote to production via Dashboard. Conflict resolution is per-property last-write-wins by default; the merge policy controls behavior. Sync isn’t instant — design for eventual consistency. Monitor eventChangedNotification for sync state. Plan model migrations carefully across CloudKit’s schema lifecycle.
30. Performance Profiling and Debugging
When Core Data is slow, finding why is the hard part. Apple provides tooling — environment variables, Instruments templates, console logging — that exposes what Core Data is doing. Knowing the toolkit makes “why is this fetch slow” answerable in minutes instead of days.
SQL debug logging
Set this in your scheme’s environment variables:
-com.apple.CoreData.SQLDebug 1
The console now shows every SQL statement Core Data issues:
CoreData: sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZID, t0.ZTITLE, t0.ZBODY, t0.ZCREATEDAT FROM ZNOTE t0 WHERE t0.ZTITLE LIKE ?
CoreData: annotation: sql connection fetch time: 0.0024s
CoreData: annotation: total fetch execution time: 0.0029s for 12 rows.
You see the actual SQL, the time, the row count. Pin down slow fetches immediately.
Higher levels for more detail:
1: queries2: queries + bind variables3: queries + bind variables + result rows4: queries + plans (EXPLAIN)
Level 4 shows whether queries use indexes:
CoreData: annotation: sql plan:
SEARCH TABLE ZNOTE USING INDEX (ZCREATEDAT)
Or:
CoreData: annotation: sql plan:
SCAN TABLE ZNOTE
A SCAN means no index is used. That’s where to investigate adding one.
Concurrency debug
-com.apple.CoreData.ConcurrencyDebug 1
Crashes the app immediately if a managed object is accessed from the wrong queue. Always run with this in development.
Logging
-com.apple.CoreData.Logging.stderr 1
Routes Core Data’s internal logs to stderr (visible in console). Useful for catching warnings about model issues, CloudKit problems, etc.
Migration debug
-com.apple.CoreData.MigrationDebug 1
Logs migration progress. Helpful when a migration silently fails or is unexpectedly slow.
Instruments → Core Data
Open Instruments, choose the Core Data template. Run your app, perform the slow scenario, stop.
Instruments shows:
- Fetches: every fetch with predicate, sort, duration, row count.
- Saves: every save with breakdown.
- Faults: every fault firing.
- Cache misses: when the row cache is missed.
For a slow scrolling list, you’ll typically see a spike of faults during scrolling — each row firing a relationship fault. The fix: add to relationshipKeyPathsForPrefetching upstream.
Time Profiler integration
Combine Core Data instrument with Time Profiler. Now you see CPU time + Core Data events on the same timeline. The pattern: a long red bar in the main thread + a cluster of fault events at the same moment = main thread blocked on faults.
Debugging fault firings
To find what’s firing faults you didn’t expect, set a symbolic breakpoint:
Breakpoint:
Symbol: -[NSManagedObject _fireFault]
Run, the breakpoint hits whenever a fault fires. The stack trace shows exactly which line of your code triggered it.
Detecting concurrency issues without crashes
If ConcurrencyDebug 1 is too noisy (it crashes on the first violation), use lighter checks. Add this to your context creation:
context.shouldDeleteInaccessibleFaults = false // surface errors instead of silently zeroing
This makes inaccessible faults throw rather than returning empty data — easier to debug.
Memory profiling
For “why is my app using so much memory”:
- Run with Instruments → Allocations.
- Take heap snapshots before and after the suspect operation.
- See
NSManagedObjectinstances retained.
If the count grows unbounded, you have a leak — usually a context that’s accumulating objects without cleanup. Periodic refreshAllObjects() or context resets help.
Logging slow fetches
In production, you can’t enable SQLDebug. But you can wrap fetches:
extension NSManagedObjectContext {
func slowFetchLogged<T: NSFetchRequestResult>(_ request: NSFetchRequest<T>, threshold: TimeInterval = 0.1) throws -> [T] {
let start = Date()
let result = try fetch(request)
let elapsed = Date().timeIntervalSince(start)
if elapsed > threshold {
print("Slow fetch: \(request.predicate?.description ?? "no predicate"), \(result.count) rows, \(elapsed)s")
// Log to analytics
}
return result
}
}
Use this wrapper in production to gather telemetry on slow fetches without enabling verbose logging.
Common diagnoses
“My list scrolls choppy.”
- Profile with Time Profiler + Core Data instrument.
- Likely cause: faults during scroll. Add
relationshipKeyPathsForPrefetchingandfetchBatchSize. - Verify with the Faults instrument — should show no spike during scroll.
“My save takes seconds.”
- Profile with Time Profiler + Core Data instrument’s Saves.
- Likely cause: many objects with validation, or cascading deletes through huge graphs.
- Mitigation: batch saves; smaller delete cascades; defer side effects out of
willSave/prepareForDeletion.
“My fetch is fast on simulator but slow on device.”
- Device disk is slower than simulator’s SSD-backed disk.
- Likely cause: missing indexes that get masked by simulator speed.
- Add appropriate indexes; verify with SQLDebug 4.
“After update, app is using more memory.”
- Likely accumulated objects in viewContext.
- Periodic
refreshAllObjects()reclaims memory. - Or, use background contexts that get discarded after operations.
“User reports lost edits.”
- Likely missed save before crash or background.
- Add saves on backgrounding (
scenePhasechange to.background). - Save aggressively after user-visible actions (tap of “Save”, navigation away).
Testing strategy
Tests for Core Data should:
- Use an in-memory store (
url = URL(fileURLWithPath: "/dev/null")). - Verify behavior, not exact SQL.
- Test concurrency by running operations on background contexts and asserting via viewContext.
- Test migrations by snapshot databases.
@MainActor
final class NoteRepositoryTests: XCTestCase {
var container: NSPersistentContainer!
var repository: CoreDataNoteRepository!
override func setUp() {
container = NSPersistentContainer(name: "Model")
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
container.loadPersistentStores { _, _ in }
repository = CoreDataNoteRepository(container: container)
}
func test_createNote() async throws {
let dto = try await repository.createNote(title: "Hello", body: "World", folderID: nil)
XCTAssertEqual(dto.title, "Hello")
let all = try await repository.notes(in: nil, search: nil)
XCTAssertEqual(all.count, 1)
}
}
In-memory store, fast tests, clean state per test.
What to internalize
Set -com.apple.CoreData.SQLDebug 1 (or 4) and -com.apple.CoreData.ConcurrencyDebug 1 in development. Use Instruments → Core Data for performance profiling. Symbolic breakpoint on _fireFault to track unintended fault firings. Wrap fetches in production telemetry to catch regressions. Use in-memory stores for tests. The most common diagnoses: missing indexes, missing prefetching, accumulating contexts, missed saves on backgrounding.
31. Common Gotchas and Anti-Patterns
A summary of the mistakes most likely to bite you. We’ve covered most of these in context; this section collects them for reference. When you’re debugging strange behavior, scan this list first.
Schema-level mistakes
Forgetting inverse relationships. Every relationship needs an inverse. Without one, deletions don’t propagate, faults don’t fire, the graph silently corrupts. Even relationships you “only traverse one way” need an inverse — Apple is adamant on this. Add a test that asserts no relationship lacks an inverse.
Required attributes without defaults or awakeFromInsert. A required attribute with no default and no awakeFromInsert set means save fails the first time, with a confusing validation error. Either provide a default in the model or set the value in awakeFromInsert.
Heavy validation in validateForInsert/Update. Validation runs on every save. If it does network calls or expensive computation, every save is slow. Keep validation cheap. Run heavy validation explicitly in your repository before save.
No indexes on attributes you query. First fetch is slow. Add indexes for any column in a WHERE clause of a real query. Use SQLDebug 4 to verify queries hit indexes.
Optional flag mismatches with non-optional Swift types. “Use Scalar Type” gives you Int64 (non-optional) for a Boolean attribute. If the attribute is optional in the model, your Swift code can’t represent the nil state. Match your Swift type to your model: scalar non-optional ↔ required attribute with default.
Concurrency mistakes
Accessing managed objects from the wrong queue. The cardinal sin. Use perform blocks; pass objectIDs across queues, not objects. Run with ConcurrencyDebug 1.
Not enabling auto-merge. Background saves don’t show in viewContext. Set viewContext.automaticallyMergesChangesFromParent = true.
Long performAndWait from the main thread. Freezes the UI. Use async await context.perform { ... } for non-trivial work.
Caching background contexts forever. Accumulated state. Periodically reset, or create one per operation.
Mixing parent-child and queue concurrency carelessly. A child context’s queue is independent of its parent. Saves go to the parent (in-memory). The parent must save again to reach the store. Forgetting one or the other = lost data.
Save mistakes
Saving after every property change. Wasteful. Save at coherent moments — user actions, app backgrounding.
Not handling save errors. try? context.save() swallows failures silently. At least log the error. Better: handle it appropriately for the user.
Saves in willSave causing loops. Setting a property in willSave triggers another willSave. Use changedValuesForCurrentEvent() to detect “have I already changed this in this save” and short-circuit.
Save on every objectWillChange. If you observe viewContext.objectsDidChange and save in the handler, every keystroke saves. Save on user intent, not on edit.
Fetch mistakes
No sort descriptors. Fetches without sort have undefined order. Always set at least one sort descriptor.
No fetchBatchSize for large lists. Materializes thousands of objects up front. Set a batch size.
No relationshipKeyPathsForPrefetching for related access. N+1 fault firings during list display. Always prefetch.
Fetching to count. try? context.fetch(request).count materializes everything. Use try? context.count(for: request).
Predicate that can’t use indexes. CONTAINS or LIKE %x% does a full scan. For prefix/exact match, use BEGINSWITH or ==. For substring search at scale, denormalize (lowercase column + index) or use a search-specific library.
Predicates with NSManagedObject for foreign keys when using ID would be faster. NSPredicate(format: "folder == %@", folder) requires the folder object materialized. If you have the folder’s UUID, NSPredicate(format: "folder.id == %@", folderID as CVarArg) is sometimes faster.
SwiftUI mistakes
@FetchRequest with no batch size. Slow rendering for large lists. Use the manual init form to set batch size and prefetching.
Direct binding with per-keystroke save. TextField($note.title) triggers an update on every keystroke; if you save on objectWillChange, you save thousands of times. Use a draft state and copy on commit.
Holding references to deleted objects. When automaticallyMergesChangesFromParent is on, deletions in another context propagate. Your view might briefly hold a deleted object. Defensive checks: note.isDeleted, note.managedObjectContext != nil.
@FetchRequest predicate not updating. Setting nsPredicate on the wrapped value works at runtime; doing it in init requires SwiftUI to re-init the view. Use .id(...) to force re-init when the parameter changes.
Coupling views directly to NSManagedObject. Hard to test, hard to migrate. For complex apps, use the repository + DTO pattern.
CloudKit-specific
Missing optionality. All CloudKit-synced attributes/relationships must be optional or have defaults.
Missing constraint workaround. CloudKit doesn’t enforce uniqueness. Use unique constraints + merge policies in Core Data, knowing they don’t apply across devices — handle conflicts at the app level.
Schema changes without Dashboard updates. Schema must be in CloudKit before clients try to use it. Update Dashboard before shipping.
Calling initializeCloudKitSchema in production. Causes problems. Only call in development; promote to production via Dashboard.
Expecting instant sync. CloudKit is eventually consistent, sometimes minutes-eventual.
Performance and memory
Long-lived contexts accumulating objects. Memory grows. refreshAllObjects() periodically, or create new contexts per operation.
context.fetch(request).count instead of context.count(for: request). Materializes for nothing.
Forgetting autoreleasepool in batch loops on Objective-C bridges. Less common in Swift, but if you process huge batches in tight loops, wrapping in autoreleasepool can keep memory bounded.
Migration mistakes
Renaming attributes without Renaming ID. Lightweight migration drops the old column and creates a new empty one. User data lost.
Custom mapping without testing. Migrations are hard to verify in development. Snapshot tests in CI catch regressions.
Skipping versions. Migrating from v1 to v3 without going through v2 fails if the v1 → v3 mapping isn’t inferable. Use progressive migration.
Not backing up before migration. Failed migration leaves the store in a bad state. Copy first.
Architectural mistakes
Too much logic in NSManagedObject subclasses. They become god objects. Keep them simple data containers; logic lives in services.
Singletons for everything. Persistence controller as singleton is fine. Repository as singleton is fine for production but hard for tests. Inject instead.
No abstraction over Core Data. Every component knows about NSPredicate, fetch requests, contexts. Hard to refactor; impossible to test in isolation. Use repositories or services.
Debugging mistakes
Not using SQLDebug. Slow fetch? You can see the SQL. There’s no excuse for guessing.
Not running with ConcurrencyDebug. Random crashes will surface eventually; better to catch them deterministically in development.
Ignoring console warnings. Core Data logs many useful warnings (about missing inverses, schema issues, CloudKit problems). Read them.
What to internalize
Most of the bugs in Core Data come from a small number of patterns: missing inverses, missing prefetches, missing indexes, ignored save errors, cross-queue object access, no batch size on large fetches. When something’s wrong, check these first.
32. Where to Go Deeper
You’ve covered the ground. Here are the resources that helped me, ordered by impact, and what each gives you.
Apple’s documentation
The official Core Data documentation has improved dramatically. Worth reading:
- Core Data Programming Guide. Now mostly archived but still authoritative on fundamentals. Free at developer.apple.com.
- Persistent Store Coordinator and Persistent Stores. Explains the lower layers when the high-level API isn’t enough.
- Modernizing Your Use of Core Data. Apple’s WWDC guide to current best practices.
WWDC sessions
- Modernizing Your Use of Core Data (WWDC 2019). Excellent overview of
NSPersistentContainer, persistent history tracking, and modern patterns. - Bring Core Data Concurrency to Swift and SwiftUI (WWDC 2021). Specific to Swift Concurrency integration.
- Using Core Data with CloudKit (WWDC 2019). The CloudKit container introduction.
- What’s New in Core Data (annual). Each year covers new features. Worth catching up on the past few years’ incremental improvements.
- Optimizing Core Data Performance (WWDC 2018). Detailed performance walkthrough.
Books
- Practical Core Data by Donny Wals. The best modern Core Data book. Covers everything from fundamentals to CloudKit and migrations, with code-first explanations. If you read one Core Data book, this is it.
- Core Data by Marcus Zarra. Older but still classic. Deep on the architectural reasoning, the stack, and the historical context.
- Core Data by Tutorials (Ray Wenderlich/Kodeco). Approachable for newer developers, with hands-on tutorials.
Articles and blogs
- objc.io’s Core Data series. Articles by Daniel Eggert and Florian Kugler — among the deepest Core Data writing on the internet. Several are still relevant despite age.
- Donny Wals’s blog. Regular Core Data and SwiftData posts; modern, practical.
- Cocoa with Love (Matt Gallagher). Older, but the Core Data architectural pieces have aged well.
SwiftData
If you’re considering SwiftData (or you’re on iOS 17+ with the option):
- WWDC 2023: Meet SwiftData. The intro session.
- WWDC 2023: Model your schema with SwiftData. The schema and migration story.
- Donny Wals’s SwiftData articles. Same author as the Core Data book; great practical SwiftData coverage.
The mental model from this guide transfers. SwiftData is @Model (analogous to NSManagedObject), @Query (analogous to @FetchRequest), and ModelContainer (analogous to NSPersistentContainer). The persistence semantics are nearly identical.
Code resources
- Apple’s sample code. The “Loading and Displaying a Large Data Feed” sample shows performant Core Data with sync.
- GitHub: search “Core Data” + Swift + recent date. Many small open-source examples for specific patterns (CloudKit setup, complex migrations).
When to consider alternatives
A short list of cases where you might choose differently:
- GRDB. When you want SQL access, fine control over schema, and a lighter framework. Core Data’s abstraction helps for object-graph apps; GRDB helps when you think relationally.
- Realm. Cross-platform sync (iOS + Android), real-time UI updates, simpler concurrency model. Trade-off: license/business considerations, less Apple-platform-native.
- SwiftData. iOS 17+ only. Modern Swift API, less boilerplate. Still maturing.
- CoreData with @Model from SwiftData (iOS 17+). Yes, you can use SwiftData’s
@Modelmacros to define your schema, then use Core Data APIs underneath. Best of both worlds for some apps. - Plain
Codablefiles. For small datasets where Core Data is overkill.
Community
- Stack Overflow — search before posting; common Core Data questions are well-answered.
- Apple Developer Forums — official, sometimes Apple engineers chime in.
- Hacking with Swift forums — friendly, hands-on answers.
- iOS Dev Slack / Discord communities. Real-time help.
Things to build to internalize
If you’ve read this far, the next step is practice. Suggestions:
- A notes app with folders, tags, search, and CloudKit sync. Hits most of the framework.
- A budget tracker with categories, transactions, and date-range queries. Tests indexes and predicates.
- A media library with thumbnails, derived attributes, and external storage. Exercises the binary-data and derived-attribute features.
- A migration test app: ship v1, then v2 with a complex schema change, verify users’ data survives.
Building forces you to confront the trade-offs. Reading is half; building is the other half.
Final thoughts
Core Data has earned its reputation — for both power and opacity. It rewards understanding. The framework’s design isn’t accidental; once you see it as an object graph manager (not a database), the parts fit together.
The two SwiftUI paths we covered — @FetchRequest-driven and repository-driven — each have their place. Use @FetchRequest for direct, fast development. Use the repository pattern when testability and decoupling matter more. Both are valid; both are idiomatic in their context.
Treat the concurrency rules as inviolable. Use the debug environment variables. Read the SQL. Profile when things are slow. Migration is hard — test it.
You’ll write code that works. Then you’ll find an edge case. You’ll dig into the framework and understand it a little better. Repeat. Eventually Core Data feels like an old tool that you can pick up confidently for any persistence-shaped problem on Apple platforms.
Good luck, and have fun.