Complete Core Data Guide: From Beginner to Advanced
Complete Core Data Guide: From Beginner to Advanced
A Comprehensive Tutorial for iOS Development with Swift
Table of Contents
- Introduction to Core Data
- Setting Up Core Data in Xcode
- Core Data Stack Components
- Data Models and Entities
- CRUD Operations
- Fetching Data
- Relationships
- Predicates and Sorting
1. Introduction to Core Data
What is Core Data?
Core Data is Apple's object graph and persistence framework for iOS, macOS, watchOS, and tvOS. Despite common misconceptions, Core Data is not a database but rather an object graph management framework that can persist data to disk using SQLite, XML, or binary formats.
Key Benefits
Core Data provides automatic memory management for object graphs, undo/redo functionality, lazy loading of data, built-in data validation, schema migration tools, and integration with iCloud. It reduces the amount of code needed to manage the model layer of applications by 50-70% compared to manual implementations.
When to Use Core Data
Use Core Data when you need to persist complex object graphs, manage relationships between entities, support undo/redo functionality, work with large datasets that benefit from lazy loading, or need built-in data validation and migration support. For simple key-value storage, UserDefaults or Keychain may be sufficient. For large amounts of unstructured data, consider using file-based storage.
Core Data vs Other Solutions
Compared to SQLite directly, Core Data offers higher-level abstractions and automatic memory management but may have more overhead for simple queries. Compared to Realm, Core Data is Apple's first-party solution with better system integration but has a steeper learning curve. Compared to SwiftData (iOS 17+), Core Data offers more control and works with older iOS versions.
2. Setting Up Core Data in Xcode
Creating a New Project with Core Data
When creating a new iOS project in Xcode, check the "Use Core Data" checkbox. This generates a Core Data stack in your AppDelegate or SceneDelegate, creates a .xcdatamodeld file for your data model, and sets up a basic persistent container.
Adding Core Data to Existing Project
To add Core Data to an existing project, create a new Data Model file (File > New > File > Core Data > Data Model). Import CoreData framework in files that need it. Create a Core Data stack (we'll cover this next). Add the Core Data model file to your target's Compile Sources.
Project Structure Best Practices
Organize Core Data files in a dedicated folder containing the .xcdatamodeld file, Core Data stack manager, Entity extensions, and Repository/Service classes. Keep Core Data implementation details separate from UI code using the Repository pattern or similar architectural approach.
3. Core Data Stack Components
Understanding the Stack
The Core Data stack consists of several key components that work together. The Managed Object Model (NSManagedObjectModel) represents your data model schema. The Persistent Store Coordinator (NSPersistentStoreCoordinator) coordinates between the object model and persistent stores. The Managed Object Context (NSManagedObjectContext) is where you work with your data objects. The Persistent Container (NSPersistentContainer) wraps these components (iOS 10+).
Creating a Core Data Stack (Modern Approach)
import CoreData
class CoreDataStack {
static let shared = CoreDataStack()
private init() {}
// Persistent Container
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
// Configure container
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}()
// Main Context (UI Context)
var mainContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
// Background Context for heavy operations
func newBackgroundContext() -> NSManagedObjectContext {
return persistentContainer.newBackgroundContext()
}
// Save Context
func saveContext() {
let context = mainContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
Legacy Stack Implementation (Pre-iOS 10)
class LegacyCoreDataStack {
static let shared = LegacyCoreDataStack()
private init() {}
lazy var managedObjectModel: NSManagedObjectModel = {
guard let modelURL = Bundle.main.url(
forResource: "MyApp",
withExtension: "momd"
) else {
fatalError("Model not found")
}
guard let model = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Unable to load model")
}
return model
}()
lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = {
let coordinator = NSPersistentStoreCoordinator(
managedObjectModel: self.managedObjectModel
)
let urls = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)
let storeURL = urls.last!.appendingPathComponent("MyApp.sqlite")
do {
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
)
} catch {
fatalError("Error adding persistent store: \(error)")
}
return coordinator
}()
lazy var mainContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
context.persistentStoreCoordinator = self.persistentStoreCoordinator
return context
}()
}
Persistent Store Types
Core Data supports multiple store types. NSSQLiteStoreType is the default and recommended for most applications, offering good performance and compatibility. NSBinaryStoreType stores data as a binary file, loading the entire store into memory. NSInMemoryStoreType keeps data in memory only, useful for testing or temporary data. NSXMLStoreType (macOS only) stores data as XML.
4. Data Models and Entities
Creating Entities in Xcode
Open your .xcdatamodeld file in Xcode. Click the "Add Entity" button at the bottom. Name your entity using UpperCamelCase. Add attributes by clicking the "+" button under Attributes. Configure attribute properties including name, type, and optional settings.
Entity Configuration
// Example Entity: Person
// Attributes:
// - firstName: String
// - lastName: String
// - age: Integer 16
// - email: String (optional)
// - dateOfBirth: Date
// - isActive: Boolean (default: true)
Entity settings include the Module (Current Product Module for Swift), Class name, and Codegen options. Set Codegen to "Manual/None" if you want to write your own classes, "Class Definition" for automatic generation, or "Category/Extension" to add custom properties.
Attribute Types
Core Data supports multiple attribute types. Undefined is a placeholder that should be changed. Integer 16/32/64 for whole numbers of different sizes. Float and Double for decimal numbers. Decimal for precise decimal calculations (financial data). String for text data. Boolean for true/false values. Date for date and time. Binary Data for small binary objects. UUID for unique identifiers. URI for URLs. Transformable for custom types.
Transformable Attributes
For custom types not directly supported by Core Data, use Transformable attributes.
// Custom type
struct Address: Codable {
let street: String
let city: String
let zipCode: String
}
// ValueTransformer
@objc(AddressTransformer)
class AddressTransformer: NSSecureUnarchiveFromDataTransformer {
static let name = NSValueTransformerName(rawValue: "AddressTransformer")
override static var allowedTopLevelClasses: [AnyClass] {
return [Address.self, NSString.self]
}
static func register() {
let transformer = AddressTransformer()
ValueTransformer.setValueTransformer(transformer, forName: name)
}
}
// Register in AppDelegate
AddressTransformer.register()
// In the data model, set:
// - Type: Transformable
// - Custom Class: Address
// - Transformer: AddressTransformer
Creating NSManagedObject Subclasses
Generate managed object subclasses by selecting your entity, then going to Editor > Create NSManagedObject Subclass. Choose your data model and entities. Select a location and create.
Example generated code:
import CoreData
@objc(Person)
public class Person: NSManagedObject {
@NSManaged public var firstName: String?
@NSManaged public var lastName: String?
@NSManaged public var age: Int16
@NSManaged public var email: String?
@NSManaged public var dateOfBirth: Date?
@NSManaged public var isActive: Bool
}
// Add custom properties and methods in extension
extension Person {
var fullName: String {
let first = firstName ?? ""
let last = lastName ?? ""
return "\(first) \(last)".trimmingCharacters(in: .whitespaces)
}
var isAdult: Bool {
return age >= 18
}
func formattedBirthDate() -> String {
guard let date = dateOfBirth else { return "Unknown" }
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
5. CRUD Operations
Create (Insert)
Creating new objects in Core Data involves instantiating an NSManagedObject and setting its properties.
func createPerson(
firstName: String,
lastName: String,
age: Int,
email: String?
) -> Person? {
let context = CoreDataStack.shared.mainContext
let person = Person(context: context)
person.firstName = firstName
person.lastName = lastName
person.age = Int16(age)
person.email = email
person.dateOfBirth = Date()
person.isActive = true
do {
try context.save()
print("Person created successfully")
return person
} catch {
print("Failed to create person: \(error)")
return nil
}
}
// Usage
if let newPerson = createPerson(
firstName: "John",
lastName: "Doe",
age: 30,
email: "john@example.com"
) {
print("Created: \(newPerson.fullName)")
}
Read (Fetch)
Fetching objects from Core Data uses NSFetchRequest.
func fetchAllPeople() -> [Person] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
do {
let people = try context.fetch(fetchRequest)
return people
} catch {
print("Failed to fetch people: \(error)")
return []
}
}
// Fetch with predicate
func fetchPeopleByName(firstName: String) -> [Person] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "firstName == %@", firstName)
do {
let people = try context.fetch(fetchRequest)
return people
} catch {
print("Failed to fetch people: \(error)")
return []
}
}
// Fetch single object
func fetchPersonById(objectID: NSManagedObjectID) -> Person? {
let context = CoreDataStack.shared.mainContext
do {
let person = try context.existingObject(with: objectID) as? Person
return person
} catch {
print("Failed to fetch person: \(error)")
return nil
}
}
Update
Updating objects involves fetching them, modifying properties, and saving the context.
func updatePerson(
person: Person,
firstName: String?,
lastName: String?,
age: Int?
) -> Bool {
let context = CoreDataStack.shared.mainContext
if let firstName = firstName {
person.firstName = firstName
}
if let lastName = lastName {
person.lastName = lastName
}
if let age = age {
person.age = Int16(age)
}
do {
try context.save()
print("Person updated successfully")
return true
} catch {
print("Failed to update person: \(error)")
return false
}
}
// Update by ID
func updatePersonByID(
objectID: NSManagedObjectID,
newEmail: String
) -> Bool {
guard let person = fetchPersonById(objectID: objectID) else {
return false
}
person.email = newEmail
let context = CoreDataStack.shared.mainContext
do {
try context.save()
return true
} catch {
print("Failed to update: \(error)")
return false
}
}
Delete
Deleting objects removes them from the context and persistent store.
func deletePerson(person: Person) -> Bool {
let context = CoreDataStack.shared.mainContext
context.delete(person)
do {
try context.save()
print("Person deleted successfully")
return true
} catch {
print("Failed to delete person: \(error)")
return false
}
}
// Delete multiple objects
func deleteAllPeople() -> Bool {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Person.fetchRequest()
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try context.execute(batchDeleteRequest)
try context.save()
print("All people deleted")
return true
} catch {
print("Failed to delete all people: \(error)")
return false
}
}
// Delete with predicate
func deletePeopleByAge(minAge: Int) -> Bool {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "age >= %d", minAge)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs
do {
let result = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult
let objectIDArray = result?.result as? [NSManagedObjectID] ?? []
let changes = [NSDeletedObjectsKey: objectIDArray]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [context]
)
return true
} catch {
print("Batch delete failed: \(error)")
return false
}
}
6. Fetching Data
Basic Fetch Requests
// Simple fetch
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
let results = try? context.fetch(fetchRequest)
// Usage
func fetchPeople() -> Result<[Person], CoreDataError> {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
do {
let people = try context.fetch(fetchRequest)
return .success(people)
} catch {
return .failure(.fetchFailed(error))
}
}
Testing Core Data
class CoreDataTestStack {
static let shared = CoreDataTestStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load test store: \(error)")
}
}
return container
}()
var context: NS
// Fetch with limit
let limitedFetch: NSFetchRequest<Person> = Person.fetchRequest()
limitedFetch.fetchLimit = 10
let limitedResults = try? context.fetch(limitedFetch)
// Fetch with offset (pagination)
let paginatedFetch: NSFetchRequest<Person> = Person.fetchRequest()
paginatedFetch.fetchLimit = 20
paginatedFetch.fetchOffset = 40 // Skip first 40 results
let page3Results = try? context.fetch(paginatedFetch)
Fetch Request Builder Pattern
class PersonFetchRequestBuilder {
private var fetchRequest: NSFetchRequest<Person>
init() {
fetchRequest = Person.fetchRequest()
}
func withPredicate(_ predicate: NSPredicate) -> PersonFetchRequestBuilder {
fetchRequest.predicate = predicate
return self
}
func sorted(by sortDescriptors: [NSSortDescriptor]) -> PersonFetchRequestBuilder {
fetchRequest.sortDescriptors = sortDescriptors
return self
}
func limit(_ limit: Int) -> PersonFetchRequestBuilder {
fetchRequest.fetchLimit = limit
return self
}
func offset(_ offset: Int) -> PersonFetchRequestBuilder {
fetchRequest.fetchOffset = offset
return self
}
func build() -> NSFetchRequest<Person> {
return fetchRequest
}
}
// Usage
let fetchRequest = PersonFetchRequestBuilder()
.withPredicate(NSPredicate(format: "age > %d", 18))
.sorted(by: [NSSortDescriptor(key: "lastName", ascending: true)])
.limit(50)
.build()
Fetched Results Controller
NSFetchedResultsController monitors Core Data changes and updates UI automatically, perfect for UITableView and UICollectionView.
class PeopleViewController: UIViewController {
private var tableView: UITableView!
private lazy var fetchedResultsController: NSFetchedResultsController<Person> = {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "lastName", ascending: true),
NSSortDescriptor(key: "firstName", ascending: true)
]
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: CoreDataStack.shared.mainContext,
sectionNameKeyPath: nil,
cacheName: "PeopleCache"
)
controller.delegate = self
return controller
}()
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
performFetch()
}
private func performFetch() {
do {
try fetchedResultsController.performFetch()
tableView.reloadData()
} catch {
print("Failed to fetch: \(error)")
}
}
}
// MARK: - UITableViewDataSource
extension PeopleViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return fetchedResultsController.sections?.count ?? 0
}
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
return fetchedResultsController.sections?[section].numberOfObjects ?? 0
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: "PersonCell",
for: indexPath
)
let person = fetchedResultsController.object(at: indexPath)
cell.textLabel?.text = person.fullName
cell.detailTextLabel?.text = person.email
return cell
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension PeopleViewController: 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:
if let newIndexPath = newIndexPath {
tableView.insertRows(at: [newIndexPath], with: .automatic)
}
case .delete:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
}
case .update:
if let indexPath = indexPath {
tableView.reloadRows(at: [indexPath], with: .automatic)
}
case .move:
if let indexPath = indexPath, let newIndexPath = newIndexPath {
tableView.deleteRows(at: [indexPath], with: .automatic)
tableView.insertRows(at: [newIndexPath], with: .automatic)
}
@unknown default:
break
}
}
func controllerDidChangeContent(
_ controller: NSFetchedResultsController<NSFetchRequestResult>
) {
tableView.endUpdates()
}
}
Fetching Specific Properties
For better performance, fetch only the properties you need.
// Fetch specific attributes
func fetchPersonNames() -> [(firstName: String, lastName: String)] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<NSDictionary> = NSFetchRequest(entityName: "Person")
fetchRequest.resultType = .dictionaryResultType
fetchRequest.propertiesToFetch = ["firstName", "lastName"]
do {
let results = try context.fetch(fetchRequest)
return results.compactMap { dict in
guard let firstName = dict["firstName"] as? String,
let lastName = dict["lastName"] as? String else {
return nil
}
return (firstName, lastName)
}
} catch {
print("Failed to fetch names: \(error)")
return []
}
}
// Count without fetching objects
func countPeople() -> Int {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
do {
let count = try context.count(for: fetchRequest)
return count
} catch {
print("Failed to count: \(error)")
return 0
}
}
7. Relationships
Defining Relationships
Relationships connect entities. In the data model editor, select an entity, add a relationship, specify destination entity, set relationship type (to-one or to-many), configure inverse relationship (always recommended), and set delete rules.
Example data model with relationships:
Company
- name: String
- employees: To-Many -> Employee
Employee
- name: String
- employeeID: String
- company: To-One -> Company
- projects: To-Many -> Project
Project
- title: String
- deadline: Date
- employees: To-Many -> Employee
Delete Rules
No Action leaves related objects unchanged (can create inconsistencies). Nullify sets the inverse relationship to nil. Cascade deletes related objects. Deny prevents deletion if relationships exist.
Working with To-One Relationships
// Create company
let company = Company(context: context)
company.name = "Tech Corp"
// Create employee and assign to company
let employee = Employee(context: context)
employee.name = "Jane Smith"
employee.employeeID = "EMP001"
employee.company = company
try? context.save()
// Access relationship
if let employeeCompany = employee.company {
print("Employee works at: \(employeeCompany.name ?? "Unknown")")
}
Working with To-Many Relationships
// Add employee to company
company.addToEmployees(employee)
// Add multiple employees
let employees = NSSet(array: [employee1, employee2, employee3])
company.addToEmployees(employees)
// Remove employee
company.removeFromEmployees(employee)
// Access all employees
if let employees = company.employees as? Set<Employee> {
print("Company has \(employees.count) employees")
for employee in employees {
print("- \(employee.name ?? "Unknown")")
}
}
// Many-to-many relationship
let project = Project(context: context)
project.title = "iOS App Redesign"
project.addToEmployees(employee)
employee.addToProjects(project)
try? context.save()
Fetching with Relationship Predicates
// Fetch employees from specific company
func fetchEmployees(forCompanyName name: String) -> [Employee] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Employee> = Employee.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "company.name == %@", name)
return (try? context.fetch(fetchRequest)) ?? []
}
// Fetch companies with more than X employees
func fetchCompanies(withMinEmployees min: Int) -> [Company] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Company> = Company.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "employees.@count >= %d",
min
)
return (try? context.fetch(fetchRequest)) ?? []
}
// Fetch employees working on specific project
func fetchEmployees(forProjectTitle title: String) -> [Employee] {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<Employee> = Employee.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "ANY projects.title == %@",
title
)
return (try? context.fetch(fetchRequest)) ?? []
}
Faulting and Relationship Performance
Core Data uses faulting to improve memory usage. A fault is a placeholder for an object or relationship that hasn't been loaded yet.
// Prefetch relationships to avoid multiple round trips
let fetchRequest: NSFetchRequest<Company> = Company.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["employees", "employees.projects"]
let companies = try? context.fetch(fetchRequest)
// Now accessing employees won't trigger additional fetches
for company in companies ?? [] {
if let employees = company.employees as? Set<Employee> {
for employee in employees {
// Already prefetched
print(employee.projects?.count ?? 0)
}
}
}
8. Predicates and Sorting
Basic Predicates
// Exact match
let exactMatch = NSPredicate(format: "firstName == %@", "John")
// Case-insensitive comparison
let caseInsensitive = NSPredicate(format: "firstName ==[c] %@", "john")
// Not equal
let notEqual = NSPredicate(format: "firstName != %@", "John")
// Greater than, less than
let olderThan30 = NSPredicate(format: "age > %d", 30)
let youngerThan18 = NSPredicate(format: "age < %d", 18)
let between = NSPredicate(format: "age >= %d AND age <= %d", 18, 65)
// Contains
let containsString = NSPredicate(format: "firstName CONTAINS[c] %@", "oh")
// Begins with, ends with
let beginsWith = NSPredicate(format: "firstName BEGINSWITH[c] %@", "Jo")
let endsWith = NSPredicate(format: "lastName ENDSWITH[c] %@", "son")
// LIKE wildcard
let likePattern = NSPredicate(format: "email LIKE %@", "*@gmail.com")
// IN operator
let inList = NSPredicate(
format: "firstName IN %@",
["John", "Jane", "Bob"]
)
// NULL checks
let hasEmail = NSPredicate(format: "email != nil")
let noEmail = NSPredicate(format: "email == nil")
Compound Predicates
// AND
let andPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "age > %d", 18),
NSPredicate(format: "isActive == true")
])
// OR
let orPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "firstName == %@", "John"),
NSPredicate(format: "firstName == %@", "Jane")
])
// NOT
let notPredicate = NSCompoundPredicate(notPredicateWithSubpredicate:
NSPredicate(format: "age < %d", 18)
)
// Complex combination
let complexPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "age >= %d", 18),
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "city == %@", "New York"),
NSPredicate(format: "city == %@", "Los Angeles")
])
])
Date Predicates
// Today
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
let todayPredicate = NSPredicate(
format: "dateOfBirth >= %@ AND dateOfBirth < %@",
startOfDay as NSDate,
endOfDay as NSDate
)
// Last 7 days
let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date())!
let lastWeekPredicate = NSPredicate(
format: "dateOfBirth >= %@",
sevenDaysAgo as NSDate
)
// Between dates
func predicateBetween(start: Date, end: Date) -> NSPredicate {
return NSPredicate(
format: "dateOfBirth >= %@ AND dateOfBirth <= %@",
start as NSDate,
end as NSDate
)
}
Collection Predicates
// ANY - at least one object in relationship matches
let anyPredicate = NSPredicate(
format: "ANY projects.title CONTAINS[c] %@",
"iOS"
)
// ALL - all objects in relationship match
let allPredicate = NSPredicate(
format: "ALL employees.age >= %d",
18
)
// NONE - no objects match
let nonePredicate = NSPredicate(
format: "NONE projects.deadline < %@",
Date() as NSDate
)
// Count
let countPredicate = NSPredicate(
format: "employees.@count > %d",
10
)
// Aggregation
let avgAgePredicate = NSPredicate(
format: "employees.@avg.age > %f",
30.0
)
let sumPredicate = NSPredicate(
format: "projects.@sum.budget > %f",
100000.0
)
Sort Descriptors
// Single sort
let sortByLastName = NSSortDescriptor(key: "lastName", ascending: true)
// Multiple sorts (priority order)
let sortDescriptors = [
NSSortDescriptor(key: "lastName", ascending: true),
NSSortDescriptor(key: "firstName", ascending: true)
]
// Case-insensitive sort
let caseInsensitiveSort = NSSortDescriptor(
key: "firstName",
ascending: true,
selector: #selector(NSString.caseInsensitiveCompare(_:))
)
// Localized sort (respects language rules)
let localizedSort = NSSortDescriptor(
key: "firstName",
ascending: true,
selector: #selector(NSString.localizedStandardCompare(_:))
)
// Sort by relationship property
let sortByCompanyName = NSSortDescriptor(
key: "company.name",
ascending: true
)
// Custom comparator
let customSort = NSSortDescriptor(
key: "age",
ascending: true,
comparator: { obj1, obj2 in
guard let age1 = obj1 as? Int16,
let age2 = obj2 as? Int16 else {
return .orderedSame
}
if age1 < age2 { return .orderedAscending }
if age1 > age2 { return .orderedDescending }
return .orderedSame
}
)
// Using sort descriptors in fetch request
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.sortDescriptors = sortDescriptors
fetchRequest.predicate = NSPredicate(format: "age > %d", 18)
let results = try? context.fetch(fetchRequest)
9. Core Data Concurrency
Understanding Concurrency
Core Data is not thread-safe. Each NSManagedObjectContext is bound to a specific queue. The main context runs on the main queue (for UI updates). Background contexts run on private queues (for heavy operations). Never pass managed objects between contexts directly. Always use object IDs or context.perform methods.
Concurrency Types
Main Queue Concurrency Type (.mainQueueConcurrencyType) is used for UI-related operations and runs on the main thread. Private Queue Concurrency Type (.privateQueueConcurrencyType) is used for background operations and manages its own private queue. Confinement Queue Concurrency Type (legacy, deprecated) was the old approach before iOS 5.
Background Context Pattern
// Performing work in background
func importLargeDataset(jsonData: [[String: Any]]) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
for data in jsonData {
let person = Person(context: backgroundContext)
person.firstName = data["firstName"] as? String
person.lastName = data["lastName"] as? String
person.age = Int16(data["age"] as? Int ?? 0)
}
do {
try backgroundContext.save()
print("Import completed")
} catch {
print("Failed to import: \(error)")
}
}
}
// Alternative with completion handler
func importData(
completion: @escaping (Result<Void, Error>) -> Void
) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
// Heavy work here
do {
try backgroundContext.save()
DispatchQueue.main.async {
completion(.success(()))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
Parent-Child Context Pattern
class CoreDataStack {
// Parent context (private queue)
lazy var privateManagedObjectContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(
concurrencyType: .privateQueueConcurrencyType
)
context.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
return context
}()
// Child context (main queue)
lazy var mainContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(
concurrencyType: .mainQueueConcurrencyType
)
context.parent = privateManagedObjectContext
return context
}()
// Save chain: child -> parent -> disk
func saveContext() {
let mainContext = self.mainContext
let privateContext = self.privateManagedObjectContext
mainContext.performAndWait {
if mainContext.hasChanges {
do {
try mainContext.save()
} catch {
print("Main context save failed: \(error)")
return
}
}
}
privateContext.perform {
if privateContext.hasChanges {
do {
try privateContext.save()
print("Saved to disk")
} catch {
print("Private context save failed: \(error)")
}
}
}
}
}
Passing Objects Between Contexts
// WRONG - Never do this
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
// DON'T: person belongs to different context
person.firstName = "New Name"
}
// CORRECT - Use object IDs
let objectID = person.objectID
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
do {
let personInBackground = try backgroundContext.existingObject(
with: objectID
) as? Person
personInBackground?.firstName = "New Name"
try backgroundContext.save()
} catch {
print("Error: \(error)")
}
}
// CORRECT - Use context.fetch with same predicate
func updatePersonInBackground(personID: String) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "employeeID == %@",
personID
)
do {
if let person = try backgroundContext.fetch(fetchRequest).first {
person.firstName = "Updated"
try backgroundContext.save()
}
} catch {
print("Error: \(error)")
}
}
}
Handling Context Changes
class DataManager {
init() {
setupContextNotifications()
}
private func setupContextNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidSave(_:)),
name: .NSManagedObjectContextDidSave,
object: nil
)
}
@objc private func contextDidSave(_ notification: Notification) {
guard let context = notification.object as? NSManagedObjectContext else {
return
}
// Don't merge changes from the main context into itself
let mainContext = CoreDataStack.shared.mainContext
if context != mainContext {
mainContext.perform {
mainContext.mergeChanges(fromContextDidSave: notification)
}
}
}
}
// Automatic merging (iOS 10+)
let container = NSPersistentContainer(name: "MyApp")
container.viewContext.automaticallyMergesChangesFromParent = true
Batch Operations (Non-Context)
Batch operations bypass the context for better performance but don't update objects in memory.
// Batch update
func batchUpdateInactiveUsers() {
let context = CoreDataStack.shared.mainContext
let batchUpdate = NSBatchUpdateRequest(entityName: "Person")
batchUpdate.predicate = NSPredicate(format: "lastLoginDate < %@", thirtyDaysAgo as NSDate)
batchUpdate.propertiesToUpdate = ["isActive": false]
batchUpdate.resultType = .updatedObjectIDsResultType
do {
let result = try context.execute(batchUpdate) as? NSBatchUpdateResult
let objectIDArray = result?.result as? [NSManagedObjectID] ?? []
let changes = [NSUpdatedObjectsKey: objectIDArray]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [context]
)
} catch {
print("Batch update failed: \(error)")
}
}
// Batch delete (covered earlier)
func batchDeleteOldRecords() {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "createdDate < %@",
oneYearAgo as NSDate
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeObjectIDs
// Execute and merge changes...
}
10. Migration and Versioning
When You Need Migration
You need migration when adding or removing entities or attributes, changing attribute types, modifying relationships, renaming entities or attributes, or changing entity hierarchies. Always create a new model version before making changes to production data.
Creating Model Versions
In Xcode, select your .xcdatamodeld file. Go to Editor > Add Model Version. Name the new version (usually v2, v3, etc.). Make your changes in the new version. Set the new version as current in the model inspector.
Lightweight Migration
Lightweight migration handles simple changes automatically, including adding or removing attributes (with default values), adding or removing entities, renaming attributes or entities (with renaming identifier), and changing optional to required (with default value).
// Enable lightweight migration
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let description = container.persistentStoreDescriptions.first
description?.shouldMigrateStoreAutomatically = true
description?.shouldInferMappingModelAutomatically = true
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
return container
}()
// Legacy approach
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)
Renaming Attributes
Set renaming identifier in the model editor. Select the renamed attribute. In the inspector, set the Renaming ID to the old name. This tells Core Data that "newName" is actually "oldName" renamed.
Manual Migration
For complex changes, create a custom mapping model.
// Create mapping model
// File > New > File > Mapping Model
// Select source and destination models
// Custom migration policy
import CoreData
class PersonToEmployeeMigrationPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
try super.createDestinationInstances(
forSource: sInstance,
in: mapping,
manager: manager
)
guard let destObject = manager.destinationInstances(
forEntityMappingName: mapping.name,
sourceInstances: [sInstance]
).first else {
return
}
// Custom migration logic
if let fullName = sInstance.value(forKey: "fullName") as? String {
let components = fullName.components(separatedBy: " ")
destObject.setValue(components.first, forKey: "firstName")
destObject.setValue(components.last, forKey: "lastName")
}
}
}
// Perform migration
func migrateStore(at storeURL: URL) {
let sourceModel = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: "MyApp",
withExtension: "momd/MyApp.mom"
)!
)!
let destinationModel = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: "MyApp",
withExtension: "momd/MyApp 2.mom"
)!
)!
let mappingModel = NSMappingModel(
from: [Bundle.main],
forSourceModel: sourceModel,
destinationModel: destinationModel
)!
let manager = NSMigrationManager(
sourceModel: sourceModel,
destinationModel: destinationModel
)
let destURL = storeURL.deletingLastPathComponent()
.appendingPathComponent("MyApp_new.sqlite")
try? manager.migrateStore(
from: storeURL,
sourceType: NSSQLiteStoreType,
options: nil,
with: mappingModel,
toDestinationURL: destURL,
destinationType: NSSQLiteStoreType,
destinationOptions: nil
)
// Replace old store with new one
try? FileManager.default.removeItem(at: storeURL)
try? FileManager.default.moveItem(at: destURL, to: storeURL)
}
Progressive Migration
For apps with multiple versions, migrate progressively through each version.
func progressiveMigration(from sourceURL: URL, to destinationURL: URL) throws {
let sourceMetadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: sourceURL,
options: nil
)
let modelVersions = ["MyApp", "MyApp 2", "MyApp 3", "MyApp 4"]
var currentURL = sourceURL
for (index, version) in modelVersions.enumerated() {
guard let model = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: version,
withExtension: "mom",
subdirectory: "MyApp.momd"
)!
) else {
continue
}
if model.isConfiguration(withName: nil, compatibleWithStoreMetadata: sourceMetadata) {
// This is our source model
continue
}
// Migrate to this version
let nextURL = destinationURL.deletingLastPathComponent()
.appendingPathComponent("temp_\(index).sqlite")
try migrateStore(from: currentURL, to: nextURL, targetVersion: version)
currentURL = nextURL
}
// Final move
try FileManager.default.moveItem(at: currentURL, to: destinationURL)
}
Testing Migration
func testMigration() {
// Create test store with old model
let oldStoreURL = FileManager.default.temporaryDirectory
.appendingPathComponent("test_old.sqlite")
// Populate with test data
let oldContainer = NSPersistentContainer(name: "MyApp")
// ... add test data ...
// Attempt migration
let newContainer = NSPersistentContainer(name: "MyApp")
let description = NSPersistentStoreDescription(url: oldStoreURL)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
newContainer.persistentStoreDescriptions = [description]
newContainer.loadPersistentStores { description, error in
if let error = error {
XCTFail("Migration failed: \(error)")
} else {
print("Migration successful")
}
}
}
11. Performance Optimization
Fetch Request Optimization
// BAD: Fetching all objects
let allPeople = try? context.fetch(Person.fetchRequest())
// GOOD: Use batch sizes
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.fetchBatchSize = 20
// GOOD: Fetch only needed properties
fetchRequest.propertiesToFetch = ["firstName", "lastName"]
fetchRequest.resultType = .dictionaryResultType
// GOOD: Use faulting strategically
fetchRequest.returnsObjectsAsFaults = false // Load objects immediately
// OR
fetchRequest.returnsObjectsAsFaults = true // Load as faults (default)
// GOOD: Prefetch relationships
fetchRequest.relationshipKeyPathsForPrefetching = ["company", "projects"]
Batch Faulting
// Fetch with relationships efficiently
let fetchRequest: NSFetchRequest<Company> = Company.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["employees"]
fetchRequest.fetchBatchSize = 20
let companies = try? context.fetch(fetchRequest)
// Now accessing employees is efficient
companies?.forEach { company in
// Already prefetched, no additional fetch
print("\(company.name): \(company.employees?.count ?? 0) employees")
}
Using Faulting Strategically
// For large lists where details aren't immediately needed
fetchRequest.returnsObjectsAsFaults = true
let people = try? context.fetch(fetchRequest)
// Objects are loaded on demand
people?.forEach { person in
// Properties accessed here trigger fault firing
print(person.firstName)
}
// For immediate use of all data
fetchRequest.returnsObjectsAsFaults = false
let peopleFullyLoaded = try? context.fetch(fetchRequest)
// All data immediately available, no lazy loading
Memory Management
// Process large datasets in batches
func processLargeDataset() {
let context = CoreDataStack.shared.newBackgroundContext()
context.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.fetchBatchSize = 100
do {
let people = try context.fetch(fetchRequest)
for (index, person) in people.enumerated() {
// Process person
person.processData()
// Reset context every 100 objects
if index % 100 == 0 {
try context.save()
context.reset() // Clear memory
}
}
// Final save
try context.save()
} catch {
print("Error: \(error)")
}
}
}
Asynchronous Fetch Requests
func performAsyncFetch() {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
let asyncFetchRequest = NSAsynchronousFetchRequest(fetchRequest: fetchRequest) { result in
guard let people = result.finalResult else { return }
DispatchQueue.main.async {
// Update UI with results
self.updateUI(with: people)
}
}
do {
try context.execute(asyncFetchRequest)
} catch {
print("Async fetch failed: \(error)")
}
}
Persistent History Tracking
For apps with multiple contexts or sync requirements, track changes over time.
// Enable persistent history
let description = container.persistentStoreDescriptions.first
description?.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
// Fetch changes since last sync
func fetchChangesSinceLastSync(token: NSPersistentHistoryToken?) {
let context = CoreDataStack.shared.newBackgroundContext()
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
do {
let result = try context.execute(request) as? NSPersistentHistoryResult
guard let transactions = result?.result as? [NSPersistentHistoryTransaction] else {
return
}
for transaction in transactions {
// Process changes
processTransaction(transaction)
}
// Save last token
if let lastToken = transactions.last?.token {
saveToken(lastToken)
}
} catch {
print("Failed to fetch history: \(error)")
}
}
Indexing
Add indexes to frequently queried attributes.
// In data model editor:
// Select attribute > Data Model Inspector
// Check "Indexed" for frequently searched attributes
// Good candidates for indexing:
// - Primary keys or unique identifiers
// - Attributes used in WHERE clauses
// - Attributes used for sorting
// - Foreign keys in relationships
// Note: Indexes speed up reads but slow down writes
// Only index attributes that are queried frequently
Query Performance Tips
// Count without fetching
let count = try? context.count(for: fetchRequest)
// Fetch object IDs only
fetchRequest.resultType = .managedObjectIDResultType
let objectIDs = try? context.fetch(fetchRequest) as? [NSManagedObjectID]
// Use batch limits for pagination
fetchRequest.fetchLimit = 50
fetchRequest.fetchOffset = currentPage * 50
// Avoid expensive predicates
// BAD: Regex or complex string operations
NSPredicate(format: "name MATCHES %@", ".*John.*")
// GOOD: Simple comparisons
NSPredicate(format: "name CONTAINS[c] %@", "John")
12. Advanced Patterns
Repository Pattern
Separate Core Data logic from the rest of your app.
protocol PersonRepository {
func create(firstName: String, lastName: String, age: Int) -> Person?
func fetch(predicate: NSPredicate?) -> [Person]
func update(person: Person, changes: [String: Any]) -> Bool
func delete(person: Person) -> Bool
}
class CoreDataPersonRepository: PersonRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.mainContext) {
self.context = context
}
func create(firstName: String, lastName: String, age: Int) -> Person? {
let person = Person(context: context)
person.firstName = firstName
person.lastName = lastName
person.age = Int16(age)
do {
try context.save()
return person
} catch {
print("Failed to create: \(error)")
return nil
}
}
func fetch(predicate: NSPredicate? = nil) -> [Person] {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = predicate
return (try? context.fetch(fetchRequest)) ?? []
}
func update(person: Person, changes: [String: Any]) -> Bool {
for (key, value) in changes {
person.setValue(value, forKey: key)
}
do {
try context.save()
return true
} catch {
print("Failed to update: \(error)")
return false
}
}
func delete(person: Person) -> Bool {
context.delete(person)
do {
try context.save()
return true
} catch {
print("Failed to delete: \(error)")
return false
}
}
}
// Usage in ViewModel or Controller
class PeopleViewModel {
private let repository: PersonRepository
init(repository: PersonRepository = CoreDataPersonRepository()) {
self.repository = repository
}
func loadPeople() -> [Person] {
return repository.fetch(predicate: nil)
}
func addPerson(firstName: String, lastName: String, age: Int) {
_ = repository.create(firstName: firstName, lastName: lastName, age: age)
}
}
Unit of Work Pattern
Group related changes together.
class UnitOfWork {
private let context: NSManagedObjectContext
private var operations: [() -> Void] = []
init(context: NSManagedObjectContext) {
self.context = context
}
func register(operation: @escaping () -> Void) {
operations.append(operation)
}
func commit() -> Result<Void, Error> {
operations.forEach { $0() }
do {
try context.save()
operations.removeAll()
return .success(())
} catch {
context.rollback()
return .failure(error)
}
}
func rollback() {
context.rollback()
operations.removeAll()
}
}
// Usage
let unitOfWork = UnitOfWork(context: context)
unitOfWork.register {
let person = Person(context: self.context)
person.firstName = "John"
}
unitOfWork.register {
let company = Company(context: self.context)
company.name = "Tech Corp"
}
switch unitOfWork.commit() {
case .success:
print("All changes saved")
case .failure(let error):
print("Failed: \(error)")
}
Generic Core Data Manager
protocol ManagedObjectConvertible {
associatedtype ManagedObject: NSManagedObject
func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject
}
class CoreDataManager<T: NSManagedObject> {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.mainContext) {
self.context = context
}
func fetch(
predicate: NSPredicate? = nil,
sortDescriptors: [NSSortDescriptor]? = nil,
fetchLimit: Int? = nil
) -> [T] {
let fetchRequest = NSFetchRequest<T>(entityName: String(describing: T.self))
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = sortDescriptors
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
return (try? context.fetch(fetchRequest)) ?? []
}
func create() -> T {
return T(context: context)
}
func save() -> Bool {
guard context.hasChanges else { return true }
do {
try context.save()
return true
} catch {
print("Save failed: \(error)")
return false
}
}
func delete(_ object: T) -> Bool {
context.delete(object)
return save()
}
func deleteAll() -> Bool {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(
entityName: String(describing: T.self)
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try context.execute(batchDelete)
return true
} catch {
print("Batch delete failed: \(error)")
return false
}
}
}
// Usage
let personManager = CoreDataManager<Person>()
let people = personManager.fetch(
predicate: NSPredicate(format: "age > %d", 18),
sortDescriptors: [NSSortDescriptor(key: "lastName", ascending: true)]
)
let newPerson = personManager.create()
newPerson.firstName = "Alice"
personManager.save()
Sync Manager Pattern
For syncing with remote servers.
class SyncManager {
private let context: NSManagedObjectContext
private let apiClient: APIClient
init() {
self.context = CoreDataStack.shared.newBackgroundContext()
self.apiClient = APIClient()
}
func syncPeople() async throws {
// Fetch from server
let remotePeople = try await apiClient.fetchPeople()
// Update local database
context.perform {
for remoteData in remotePeople {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "remoteID == %@",
remoteData.id
)
let person: Person
if let existing = try? self.context.fetch(fetchRequest).first {
person = existing
} else {
person = Person(context: self.context)
person.remoteID = remoteData.id
}
person.firstName = remoteData.firstName
person.lastName = remoteData.lastName
person.age = Int16(remoteData.age)
person.lastSyncDate = Date()
}
do {
try self.context.save()
} catch {
print("Sync failed: \(error)")
}
}
}
func pushLocalChanges() async throws {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "lastModifiedDate > lastSyncDate OR lastSyncDate == nil"
)
let changedPeople = try context.fetch(fetchRequest)
for person in changedPeople {
let data = PersonDTO(from: person)
try await apiClient.updatePerson(data)
person.lastSyncDate = Date()
}
try context.save()
}
}
13. Best Practices
Naming Conventions
Use descriptive entity names in UpperCamelCase. Use clear attribute names in lowerCamelCase. Avoid abbreviations unless universally understood. Use relationship names that describe the connection. Add inverse relationships for all relationships. Use plural names for to-many relationships.
Data Validation
extension Person {
override func awakeFromInsert() {
super.awakeFromInsert()
// Set default values
self.isActive = true
self.createdDate = Date()
}
override func validateForInsert() throws {
try super.validateForInsert()
try validatePerson()
}
override func validateForUpdate() throws {
try super.validateForUpdate()
try validatePerson()
}
private func validatePerson() throws {
if let firstName = firstName, firstName.trimmingCharacters(in: .whitespaces).isEmpty {
throw NSError(
domain: "PersonValidation",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "First name cannot be empty"]
)
}
if age < 0 || age > 150 {
throw NSError(
domain: "PersonValidation",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid age"]
)
}
if let email = email, !email.contains("@") {
throw NSError(
domain: "PersonValidation",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid email format"]
)
}
}
}
// Handle validation errors
do {
try context.save()
} catch let error as NSError {
if error.domain == "PersonValidation" {
// Handle validation error
print("Validation failed: \(error.localizedDescription)")
}
}
Error Handling
enum CoreDataError: Error {
case fetchFailed(Error)
case saveFailed(Error)
case deleteFailed(Error)
case invalidObject
case contextNotAvailable
var localizedDescription: String {
switch self {
case .fetchFailed(let error):
return "Failed to fetch data: \(error.localizedDescription)"
case .saveFailed(let error):
return "Failed to save data: \(error.localizedDescription)"
case .deleteFailed(let error):
return "Failed to delete data: \(error.localizedDescription)"
case .invalidObject:
return "The object is invalid"
case .contextNotAvailable:
return "Core Data context is not available"
}
}
}
Table of Contents
- Core Data Concurrency
- Migration and Versioning
- Performance Optimization
- Advanced Patterns
- Best Practices
- Troubleshooting
1. Core Data Concurrency
Understanding Concurrency
Core Data is not thread-safe. Each NSManagedObjectContext is bound to a specific queue. The main context runs on the main queue (for UI updates). Background contexts run on private queues (for heavy operations). Never pass managed objects between contexts directly. Always use object IDs or context.perform methods.
Concurrency Types
Main Queue Concurrency Type (.mainQueueConcurrencyType) is used for UI-related operations and runs on the main thread.
Private Queue Concurrency Type (.privateQueueConcurrencyType) is used for background operations and manages its own private queue.
Confinement Queue Concurrency Type (legacy, deprecated) was the old approach before iOS 5.
Background Context Pattern
// Performing work in background
func importLargeDataset(jsonData: [[String: Any]]) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
for data in jsonData {
let person = Person(context: backgroundContext)
person.firstName = data["firstName"] as? String
person.lastName = data["lastName"] as? String
person.age = Int16(data["age"] as? Int ?? 0)
}
do {
try backgroundContext.save()
print("Import completed")
} catch {
print("Failed to import: \(error)")
}
}
}
// Alternative with completion handler
func importData(completion: @escaping (Result<Void, Error>) -> Void) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
// Heavy work here
do {
try backgroundContext.save()
DispatchQueue.main.async {
completion(.success(()))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
Parent-Child Context Pattern
class CoreDataStack {
// Parent context (private queue)
lazy var privateManagedObjectContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(
concurrencyType: .privateQueueConcurrencyType
)
context.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator
return context
}()
// Child context (main queue)
lazy var mainContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(
concurrencyType: .mainQueueConcurrencyType
)
context.parent = privateManagedObjectContext
return context
}()
// Save chain: child -> parent -> disk
func saveContext() {
let mainContext = self.mainContext
let privateContext = self.privateManagedObjectContext
mainContext.performAndWait {
if mainContext.hasChanges {
do {
try mainContext.save()
} catch {
print("Main context save failed: \(error)")
return
}
}
}
privateContext.perform {
if privateContext.hasChanges {
do {
try privateContext.save()
print("Saved to disk")
} catch {
print("Private context save failed: \(error)")
}
}
}
}
}
Passing Objects Between Contexts
// WRONG - Never do this
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
// DON'T: person belongs to different context
person.firstName = "New Name"
}
// CORRECT - Use object IDs
let objectID = person.objectID
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
do {
let personInBackground = try backgroundContext.existingObject(
with: objectID
) as? Person
personInBackground?.firstName = "New Name"
try backgroundContext.save()
} catch {
print("Error: \(error)")
}
}
// CORRECT - Use context.fetch with same predicate
func updatePersonInBackground(personID: String) {
let backgroundContext = CoreDataStack.shared.newBackgroundContext()
backgroundContext.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "employeeID == %@", personID)
do {
if let person = try backgroundContext.fetch(fetchRequest).first {
person.firstName = "Updated"
try backgroundContext.save()
}
} catch {
print("Error: \(error)")
}
}
}
Handling Context Changes
class DataManager {
init(context: NSManagedObjectContext = CoreDataStack.shared.mainContext) {
self.context = context
}
func getAll() async throws -> [Person] {
return try await context.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
return try self.context.fetch(fetchRequest)
}
}
func getById(_ id: NSManagedObjectID) async throws -> Person? {
return try await context.perform {
try self.context.existingObject(with: id) as? Person
}
}
func create(_ object: Person) async throws {
try await context.perform {
try self.context.save()
}
}
func update(_ object: Person) async throws {
try await context.perform {
if self.context.hasChanges {
try self.context.save()
}
}
}
func delete(_ object: Person) async throws {
try await context.perform {
self.context.delete(object)
try self.context.save()
}
}
}
5. Best Practices
Naming Conventions
- Use descriptive entity names in UpperCamelCase
- Use clear attribute names in lowerCamelCase
- Avoid abbreviations unless universally understood
- Use relationship names that describe the connection
- Add inverse relationships for all relationships
- Use plural names for to-many relationships
Data Validation
extension Person {
override func awakeFromInsert() {
super.awakeFromInsert()
// Set default values
self.isActive = true
self.createdDate = Date()
}
override func validateForInsert() throws {
try super.validateForInsert()
try validatePerson()
}
override func validateForUpdate() throws {
try super.validateForUpdate()
try validatePerson()
}
private func validatePerson() throws {
if let firstName = firstName,
firstName.trimmingCharacters(in: .whitespaces).isEmpty {
throw NSError(
domain: "PersonValidation",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "First name cannot be empty"]
)
}
if age < 0 || age > 150 {
throw NSError(
domain: "PersonValidation",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid age"]
)
}
if let email = email, !email.contains("@") {
throw NSError(
domain: "PersonValidation",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "Invalid email format"]
)
}
}
}
// Handle validation errors
do {
try context.save()
} catch let error as NSError {
if error.domain == "PersonValidation" {
// Handle validation error
print("Validation failed: \(error.localizedDescription)")
}
}
Error Handling
enum CoreDataError: Error {
case fetchFailed(Error)
case saveFailed(Error)
case deleteFailed(Error)
case invalidObject
case contextNotAvailable
var localizedDescription: String {
switch self {
case .fetchFailed(let error):
return "Failed to fetch data: \(error.localizedDescription)"
case .saveFailed(let error):
return "Failed to save data: \(error.localizedDescription)"
case .deleteFailed(let error):
return "Failed to delete data: \(error.localizedDescription)"
case .invalidObject:
return "The object is invalid"
case .contextNotAvailable:
return "Core Data context is not available"
}
}
}
// Usage
func fetchPeople() -> Result<[Person], CoreDataError> {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
do {
let people = try context.fetch(fetchRequest)
return .success(people)
} catch {
return .failure(.fetchFailed(error))
}
}
// With async/await
func fetchPeopleAsync() async throws -> [Person] {
return try await context.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
return try self.context.fetch(fetchRequest)
}
}
Testing Core Data
class CoreDataTestStack {
static let shared = CoreDataTestStack()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load test store: \(error)")
}
}
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
func reset() {
let entities = persistentContainer.managedObjectModel.entities
for entity in entities {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entity.name!)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
try? context.execute(batchDelete)
}
}
}
// XCTest example
import XCTest
class PersonRepositoryTests: XCTestCase {
var repository: CoreDataPersonRepository!
var context: NSManagedObjectContext!
override func setUp() {
super.setUp()
context = CoreDataTestStack.shared.context
repository = CoreDataPersonRepository(context: context)
CoreDataTestStack.shared.reset()
}
override func tearDown() {
repository = nil
context = nil
super.tearDown()
}
func testCreatePerson() {
// Given
let firstName = "John"
let lastName = "Doe"
let age = 30
// When
let person = repository.create(
firstName: firstName,
lastName: lastName,
age: age
)
// Then
XCTAssertNotNil(person)
XCTAssertEqual(person?.firstName, firstName)
XCTAssertEqual(person?.lastName, lastName)
XCTAssertEqual(person?.age, Int16(age))
}
func testFetchPeople() {
// Given
_ = repository.create(firstName: "John", lastName: "Doe", age: 30)
_ = repository.create(firstName: "Jane", lastName: "Smith", age: 25)
// When
let people = repository.fetch(predicate: nil)
// Then
XCTAssertEqual(people.count, 2)
}
func testUpdatePerson() {
// Given
guard let person = repository.create(
firstName: "John",
lastName: "Doe",
age: 30
) else {
XCTFail("Failed to create person")
return
}
// When
let success = repository.update(person, changes: ["firstName": "Johnny"])
// Then
XCTAssertTrue(success)
XCTAssertEqual(person.firstName, "Johnny")
}
func testDeletePerson() {
// Given
guard let person = repository.create(
firstName: "John",
lastName: "Doe",
age: 30
) else {
XCTFail("Failed to create person")
return
}
// When
let success = repository.delete(person: person)
// Then
XCTAssertTrue(success)
XCTAssertEqual(repository.fetch(predicate: nil).count, 0)
}
}
Memory Management Best Practices
// Always reset contexts after processing large batches
func processLargeImport() {
let context = CoreDataStack.shared.newBackgroundContext()
context.perform {
autoreleasepool {
for batch in dataBatches {
for item in batch {
let person = Person(context: context)
// Set properties
}
try? context.save()
context.reset() // Clear memory
}
}
}
}
// Use weak references for managed objects
class PersonViewModel {
weak var person: Person? // Use weak to avoid retain cycles
init(person: Person) {
self.person = person
}
}
// Disable undo manager if not needed
context.undoManager = nil
Security Considerations
// Enable file protection for sensitive data
let description = container.persistentStoreDescriptions.first
description?.setOption(
FileProtectionType.complete as NSObject,
forKey: NSPersistentStoreFileProtectionKey
)
// Validate input before saving
func createSecurePerson(firstName: String, lastName: String) -> Person? {
// Sanitize input
let cleanFirstName = firstName.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanLastName = lastName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanFirstName.isEmpty && !cleanLastName.isEmpty else {
return nil
}
// Additional validation
guard cleanFirstName.count <= 100 && cleanLastName.count <= 100 else {
return nil
}
let person = Person(context: context)
person.firstName = cleanFirstName
person.lastName = cleanLastName
try? context.save()
return person
}
// Encrypt sensitive attributes using Transformable
class EncryptedStringTransformer: ValueTransformer {
override func transformedValue(_ value: Any?) -> Any? {
guard let string = value as? String else { return nil }
// Implement encryption
return encrypt(string)
}
override func reverseTransformedValue(_ value: Any?) -> Any? {
guard let data = value as? Data else { return nil }
// Implement decryption
return decrypt(data)
}
}
Coding Guidelines
// 1. Always check context.hasChanges before saving
if context.hasChanges {
try context.save()
}
// 2. Use meaningful entity and attribute names
// Good: Person, firstName, dateOfBirth
// Bad: P, fName, dob
// 3. Always set inverse relationships
// In Company: employees (to-many) -> Employee
// In Employee: company (to-one) -> Company
// 4. Use appropriate delete rules
// Cascade: Delete related objects
// Nullify: Set relationship to nil
// Deny: Prevent deletion if relationships exist
// 5. Implement proper error handling
do {
try context.save()
} catch let error as NSError {
print("Save failed: \(error), \(error.userInfo)")
}
// 6. Use background contexts for heavy operations
let backgroundContext = container.newBackgroundContext()
backgroundContext.perform {
// Heavy work here
}
// 7. Prefetch relationships to avoid N+1 queries
fetchRequest.relationshipKeyPathsForPrefetching = ["company", "projects"]
// 8. Use batch operations for bulk updates/deletes
let batchUpdate = NSBatchUpdateRequest(entityName: "Person")
batchUpdate.propertiesToUpdate = ["isActive": false]
6. Troubleshooting
Common Issues and Solutions
Issue 1: Context has no changes but save() is called repeatedly
Solution: Always check context.hasChanges before saving.
if context.hasChanges {
try context.save()
}
Issue 2: Thread violations and crashes
Symptoms: App crashes with "CoreData was not used on the correct thread"
Solution: Always use perform or performAndWait for context operations.
// Wrong
let results = try? context.fetch(fetchRequest)
// Correct
context.perform {
let results = try? self.context.fetch(fetchRequest)
}
Issue 3: Faulting errors when accessing relationships
Symptoms: "CoreData could not fulfill a fault"
Solutions:
// Solution 1: Prefetch relationships
fetchRequest.relationshipKeyPathsForPrefetching = ["company", "employees"]
// Solution 2: Check if object is deleted
if !person.isDeleted {
print(person.company?.name)
}
// Solution 3: Refresh object
context.refresh(person, mergeChanges: true)
Issue 4: Memory growing indefinitely
Symptoms: Memory usage increases continuously during data processing
Solution: Reset context periodically and use batch processing.
func processLargeDataset() {
let context = CoreDataStack.shared.newBackgroundContext()
context.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.fetchBatchSize = 100
do {
let people = try context.fetch(fetchRequest)
for (index, person) in people.enumerated() {
// Process person
if index % 100 == 0 {
try context.save()
context.reset() // Clear memory
}
}
try context.save()
} catch {
print("Error: \(error)")
}
}
}
Issue 5: Merge conflicts between contexts
Symptoms: Data inconsistencies or crashes when multiple contexts modify same data
Solution: Set appropriate merge policy.
// Last write wins (object properties)
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Store data wins
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
// Overwrite everything
context.mergePolicy = NSOverwriteMergePolicy
// Rollback on conflict
context.mergePolicy = NSRollbackMergePolicy
Issue 6: Migration fails with no error message
Solution: Enable verbose logging and check model compatibility.
// Enable SQL debugging (add to scheme arguments)
-com.apple.CoreData.SQLDebug 1
// Check compatibility
let sourceMetadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: storeURL,
options: nil
)
let isCompatible = managedObjectModel.isConfiguration(
withName: nil,
compatibleWithStoreMetadata: sourceMetadata ?? [:]
)
print("Store is compatible: \(isCompatible)")
Issue 7: Slow fetch performance
Symptoms: Fetches taking several seconds
Solutions:
// Add indexes in data model
// Select attribute > Data Model Inspector > Check "Indexed"
// Use batch sizes
fetchRequest.fetchBatchSize = 20
// Optimize predicates
// Bad: MATCHES with regex
NSPredicate(format: "name MATCHES %@", ".*John.*")
// Good: CONTAINS
NSPredicate(format: "name CONTAINS[c] %@", "John")
// Fetch only needed properties
fetchRequest.propertiesToFetch = ["firstName", "lastName"]
fetchRequest.resultType = .dictionaryResultType
Issue 8: Unique constraint violations
Symptoms: "Constraint violation" errors when saving
Solution: Use unique constraints or check before inserting.
// In data model: select attribute > check "Unique"
// Or check programmatically
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "email == %@", email)
fetchRequest.fetchLimit = 1
if let existing = try? context.fetch(fetchRequest).first {
// Update existing
existing.firstName = firstName
} else {
// Create new
let person = Person(context: context)
person.email = email
person.firstName = firstName
}
try? context.save()
Issue 9: Binary data consuming too much memory
Symptoms: Large memory spikes when loading objects with binary data
Solution: Use external storage for large binary data.
// In data model:
// 1. Select Binary Data attribute
// 2. Check "Allows External Storage"
// 3. This automatically stores large data outside the SQLite file
// For manual control
let data = largeImageData
person.imageData = data // Core Data handles external storage automatically
Issue 10: Undo manager affecting performance
Symptoms: Slow saves and high memory usage
Solution: Disable undo manager if not needed.
context.undoManager = nil
Detecting Store Corruption
func verifyStore(at url: URL) -> Bool {
do {
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: url,
options: nil
)
let model = CoreDataStack.shared.persistentContainer.managedObjectModel
return model.isConfiguration(
withName: nil,
compatibleWithStoreMetadata: metadata
)
} catch {
print("Store verification failed: \(error)")
return false
}
}
func recoverFromCorruption(storeURL: URL) {
// Backup corrupted store
let backupURL = storeURL.appendingPathExtension("corrupted")
try? FileManager.default.moveItem(at: storeURL, to: backupURL)
// Create fresh store
let container = NSPersistentContainer(name: "MyApp")
container.loadPersistentStores { description, error in
if error == nil {
print("Fresh store created")
// Optionally attempt data recovery from backup
}
}
}
Debugging Tools
Enable Core Data SQL Logging
// Add to scheme arguments in Xcode
-com.apple.CoreData.SQLDebug 1
// Or in code (before loading stores)
UserDefaults.standard.set(["com.apple.CoreData.SQLDebug": "1"],
forKey: "com.apple.CoreData.SQLDebug")
Enable Threading Assertions
// Add to scheme arguments
-com.apple.CoreData.ConcurrencyDebug 1
Enable Migration Debug
// Add to scheme arguments
-com.apple.CoreData.MigrationDebug 1
Using Instruments
- Open Instruments (Cmd + I in Xcode)
- Select "Core Data" instrument
- Profile your app
- Analyze faults, fetches, and saves
- Monitor memory usage with "Allocations" and "Leaks"
Store File Inspection
func printStoreLocation() {
let container = CoreDataStack.shared.persistentContainer
if let storeURL = container.persistentStoreDescriptions.first?.url {
print("Core Data Store: \(storeURL.path)")
}
}
// Access store file for inspection
// Terminal commands:
// sqlite3 /path/to/store.sqlite
// .tables # List all tables
// .schema ZTABLENAME # Show table structure
// SELECT * FROM ZTABLENAME; # Query data
Debug Fetch Requests
func debugFetchRequest<T: NSManagedObject>(_ fetchRequest: NSFetchRequest<T>) {
print("Entity: \(fetchRequest.entityName ?? "Unknown")")
print("Predicate: \(fetchRequest.predicate?.description ?? "None")")
print("Sort Descriptors: \(fetchRequest.sortDescriptors?.description ?? "None")")
print("Fetch Limit: \(fetchRequest.fetchLimit)")
print("Fetch Offset: \(fetchRequest.fetchOffset)")
print("Batch Size: \(fetchRequest.fetchBatchSize)")
print("Returns Objects As Faults: \(fetchRequest.returnsObjectsAsFaults)")
let startTime = Date()
do {
let results = try context.fetch(fetchRequest)
let duration = Date().timeIntervalSince(startTime)
print("Fetch completed in \(duration)s, returned \(results.count) objects")
} catch {
print("Fetch failed: \(error)")
}
}
Common Error Messages
"The model used to open the store is incompatible"
- Solution: Run migration or delete and recreate store
"Object's persistent store is not reachable"
- Solution: Object was deleted or store was removed
"Illegal attempt to establish a relationship"
- Solution: Ensure both objects belong to same context
"Context already has a coordinator"
- Solution: Don't reassign coordinator after it's set
"Optimistic locking failure"
- Solution: Handle merge conflicts with appropriate merge policy
Best Debugging Practices
- Enable all Core Data debugging flags during development
- Use breakpoints in save operations
- Log all Core Data operations in debug builds
- Monitor memory usage regularly
- Test with large datasets
- Test migration paths thoroughly
- Use unit tests for data operations
- Profile with Instruments regularly
Conclusion
This guide covered the advanced aspects of Core Data including concurrency management, migration strategies, performance optimization techniques, advanced architectural patterns, best practices, and comprehensive troubleshooting.
Key Takeaways
- Concurrency: Always use contexts on appropriate queues
- Migration: Test thoroughly and use progressive migration for complex changes
- Performance: Optimize fetch requests, use indexing, and batch operations
- Patterns: Implement Repository and Service layers for clean architecture
- Best Practices: Validate data, handle errors properly, and write tests
- Troubleshooting: Enable debugging tools and monitor performance
Next Steps
- Explore SwiftUI integration with @FetchRequest
- Learn CloudKit sync with Core Data
- Study Combine framework integration
- Investigate Core Spotlight for search
- Consider SwiftData for iOS 17+ projects
Master these advanced topics to build robust, performant, and maintainable Core Data applications.() { setupContextNotifications() }
private func setupContextNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidSave(_:)),
name: .NSManagedObjectContextDidSave,
object: nil
)
}
@objc private func contextDidSave(_ notification: Notification) {
guard let context = notification.object as? NSManagedObjectContext else {
return
}
// Don't merge changes from the main context into itself
let mainContext = CoreDataStack.shared.mainContext
if context != mainContext {
mainContext.perform {
mainContext.mergeChanges(fromContextDidSave: notification)
}
}
}
}
// Automatic merging (iOS 10+) let container = NSPersistentContainer(name: "MyApp") container.viewContext.automaticallyMergesChangesFromParent = true
### Batch Operations (Non-Context)
Batch operations bypass the context for better performance but don't update objects in memory.
```swift
// Batch update
func batchUpdateInactiveUsers() {
let context = CoreDataStack.shared.mainContext
let batchUpdate = NSBatchUpdateRequest(entityName: "Person")
batchUpdate.predicate = NSPredicate(format: "lastLoginDate < %@", thirtyDaysAgo as NSDate)
batchUpdate.propertiesToUpdate = ["isActive": false]
batchUpdate.resultType = .updatedObjectIDsResultType
do {
let result = try context.execute(batchUpdate) as? NSBatchUpdateResult
let objectIDArray = result?.result as? [NSManagedObjectID] ?? []
let changes = [NSUpdatedObjectsKey: objectIDArray]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [context]
)
} catch {
print("Batch update failed: \(error)")
}
}
// Batch delete
func batchDeleteOldRecords() {
let context = CoreDataStack.shared.mainContext
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "createdDate < %@",
oneYearAgo as NSDate
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDelete.resultType = .resultTypeObjectIDs
do {
let result = try context.execute(batchDelete) as? NSBatchDeleteResult
let objectIDArray = result?.result as? [NSManagedObjectID] ?? []
let changes = [NSDeletedObjectsKey: objectIDArray]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [context]
)
} catch {
print("Batch delete failed: \(error)")
}
}
Concurrency Best Practices
Always use perform or performAndWait for context operations. Never share managed objects between threads or contexts. Use object IDs to reference objects across contexts. Enable automatic merging for better sync between contexts. Use background contexts for imports and heavy processing. Always dispatch UI updates to the main queue.
2. Migration and Versioning
When You Need Migration
You need migration when:
- Adding or removing entities or attributes
- Changing attribute types
- Modifying relationships
- Renaming entities or attributes
- Changing entity hierarchies
Always create a new model version before making changes to production data.
Creating Model Versions
In Xcode:
- Select your .xcdatamodeld file
- Go to Editor > Add Model Version
- Name the new version (usually v2, v3, etc.)
- Make your changes in the new version
- Set the new version as current in the model inspector
Lightweight Migration
Lightweight migration handles simple changes automatically, including:
- Adding or removing attributes (with default values)
- Adding or removing entities
- Renaming attributes or entities (with renaming identifier)
- Changing optional to required (with default value)
// Enable lightweight migration
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let description = container.persistentStoreDescriptions.first
description?.shouldMigrateStoreAutomatically = true
description?.shouldInferMappingModelAutomatically = true
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}
return container
}()
// Legacy approach
let options = [
NSMigratePersistentStoresAutomaticallyOption: true,
NSInferMappingModelAutomaticallyOption: true
]
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: storeURL,
options: options
)
Renaming Attributes
Set renaming identifier in the model editor:
- Select the renamed attribute
- In the inspector, set the Renaming ID to the old name
- This tells Core Data that "newName" is actually "oldName" renamed
Manual Migration
For complex changes, create a custom mapping model.
// Create mapping model
// File > New > File > Mapping Model
// Select source and destination models
// Custom migration policy
import CoreData
class PersonToEmployeeMigrationPolicy: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
try super.createDestinationInstances(
forSource: sInstance,
in: mapping,
manager: manager
)
guard let destObject = manager.destinationInstances(
forEntityMappingName: mapping.name,
sourceInstances: [sInstance]
).first else {
return
}
// Custom migration logic
if let fullName = sInstance.value(forKey: "fullName") as? String {
let components = fullName.components(separatedBy: " ")
destObject.setValue(components.first, forKey: "firstName")
destObject.setValue(components.last, forKey: "lastName")
}
}
}
// Perform migration
func migrateStore(at storeURL: URL) {
let sourceModel = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: "MyApp",
withExtension: "momd/MyApp.mom"
)!
)!
let destinationModel = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: "MyApp",
withExtension: "momd/MyApp 2.mom"
)!
)!
let mappingModel = NSMappingModel(
from: [Bundle.main],
forSourceModel: sourceModel,
destinationModel: destinationModel
)!
let manager = NSMigrationManager(
sourceModel: sourceModel,
destinationModel: destinationModel
)
let destURL = storeURL.deletingLastPathComponent()
.appendingPathComponent("MyApp_new.sqlite")
try? manager.migrateStore(
from: storeURL,
sourceType: NSSQLiteStoreType,
options: nil,
with: mappingModel,
toDestinationURL: destURL,
destinationType: NSSQLiteStoreType,
destinationOptions: nil
)
// Replace old store with new one
try? FileManager.default.removeItem(at: storeURL)
try? FileManager.default.moveItem(at: destURL, to: storeURL)
}
Progressive Migration
For apps with multiple versions, migrate progressively through each version.
func progressiveMigration(from sourceURL: URL, to destinationURL: URL) throws {
let sourceMetadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
ofType: NSSQLiteStoreType,
at: sourceURL,
options: nil
)
let modelVersions = ["MyApp", "MyApp 2", "MyApp 3", "MyApp 4"]
var currentURL = sourceURL
for (index, version) in modelVersions.enumerated() {
guard let model = NSManagedObjectModel(
contentsOf: Bundle.main.url(
forResource: version,
withExtension: "mom",
subdirectory: "MyApp.momd"
)!
) else {
continue
}
if model.isConfiguration(withName: nil, compatibleWithStoreMetadata: sourceMetadata) {
// This is our source model
continue
}
// Migrate to this version
let nextURL = destinationURL.deletingLastPathComponent()
.appendingPathComponent("temp_\(index).sqlite")
try migrateStore(from: currentURL, to: nextURL, targetVersion: version)
currentURL = nextURL
}
// Final move
try FileManager.default.moveItem(at: currentURL, to: destinationURL)
}
Testing Migration
func testMigration() {
// Create test store with old model
let oldStoreURL = FileManager.default.temporaryDirectory
.appendingPathComponent("test_old.sqlite")
// Populate with test data
let oldContainer = NSPersistentContainer(name: "MyApp")
// ... add test data ...
// Attempt migration
let newContainer = NSPersistentContainer(name: "MyApp")
let description = NSPersistentStoreDescription(url: oldStoreURL)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
newContainer.persistentStoreDescriptions = [description]
newContainer.loadPersistentStores { description, error in
if let error = error {
XCTFail("Migration failed: \(error)")
} else {
print("Migration successful")
}
}
}
Migration Best Practices
Always test migrations with production data copies. Create backups before migrating. Use progressive migration for multiple versions. Provide user feedback during long migrations. Handle migration failures gracefully. Document all model changes. Use version numbers in model names.
3. Performance Optimization
Fetch Request Optimization
// BAD: Fetching all objects
let allPeople = try? context.fetch(Person.fetchRequest())
// GOOD: Use batch sizes
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.fetchBatchSize = 20
// GOOD: Fetch only needed properties
fetchRequest.propertiesToFetch = ["firstName", "lastName"]
fetchRequest.resultType = .dictionaryResultType
// GOOD: Use faulting strategically
fetchRequest.returnsObjectsAsFaults = false // Load objects immediately
// OR
fetchRequest.returnsObjectsAsFaults = true // Load as faults (default)
// GOOD: Prefetch relationships
fetchRequest.relationshipKeyPathsForPrefetching = ["company", "projects"]
Batch Faulting
// Fetch with relationships efficiently
let fetchRequest: NSFetchRequest<Company> = Company.fetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = ["employees"]
fetchRequest.fetchBatchSize = 20
let companies = try? context.fetch(fetchRequest)
// Now accessing employees is efficient
companies?.forEach { company in
// Already prefetched, no additional fetch
print("\(company.name): \(company.employees?.count ?? 0) employees")
}
Using Faulting Strategically
// For large lists where details aren't immediately needed
fetchRequest.returnsObjectsAsFaults = true
let people = try? context.fetch(fetchRequest)
// Objects are loaded on demand
people?.forEach { person in
// Properties accessed here trigger fault firing
print(person.firstName)
}
// For immediate use of all data
fetchRequest.returnsObjectsAsFaults = false
let peopleFullyLoaded = try? context.fetch(fetchRequest)
// All data immediately available, no lazy loading
Memory Management
// Process large datasets in batches
func processLargeDataset() {
let context = CoreDataStack.shared.newBackgroundContext()
context.perform {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.fetchBatchSize = 100
do {
let people = try context.fetch(fetchRequest)
for (index, person) in people.enumerated() {
// Process person
person.processData()
// Reset context every 100 objects
if index % 100 == 0 {
try context.save()
context.reset() // Clear memory
}
}
// Final save
try context.save()
} catch {
print("Error: \(error)")
}
}
}
Asynchronous Fetch Requests
func performAsyncFetch() {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
let asyncFetchRequest = NSAsynchronousFetchRequest(
fetchRequest: fetchRequest
) { result in
guard let people = result.finalResult else { return }
DispatchQueue.main.async {
// Update UI with results
self.updateUI(with: people)
}
}
do {
try context.execute(asyncFetchRequest)
} catch {
print("Async fetch failed: \(error)")
}
}
Persistent History Tracking
For apps with multiple contexts or sync requirements, track changes over time.
// Enable persistent history
let description = container.persistentStoreDescriptions.first
description?.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
// Fetch changes since last sync
func fetchChangesSinceLastSync(token: NSPersistentHistoryToken?) {
let context = CoreDataStack.shared.newBackgroundContext()
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
do {
let result = try context.execute(request) as? NSPersistentHistoryResult
guard let transactions = result?.result as? [NSPersistentHistoryTransaction] else {
return
}
for transaction in transactions {
// Process changes
processTransaction(transaction)
}
// Save last token
if let lastToken = transactions.last?.token {
saveToken(lastToken)
}
} catch {
print("Failed to fetch history: \(error)")
}
}
Indexing
Add indexes to frequently queried attributes.
// In data model editor:
// Select attribute > Data Model Inspector
// Check "Indexed" for frequently searched attributes
// Good candidates for indexing:
// - Primary keys or unique identifiers
// - Attributes used in WHERE clauses
// - Attributes used for sorting
// - Foreign keys in relationships
// Note: Indexes speed up reads but slow down writes
// Only index attributes that are queried frequently
Query Performance Tips
// Count without fetching
let count = try? context.count(for: fetchRequest)
// Fetch object IDs only
fetchRequest.resultType = .managedObjectIDResultType
let objectIDs = try? context.fetch(fetchRequest) as? [NSManagedObjectID]
// Use batch limits for pagination
fetchRequest.fetchLimit = 50
fetchRequest.fetchOffset = currentPage * 50
// Avoid expensive predicates
// BAD: Regex or complex string operations
NSPredicate(format: "name MATCHES %@", ".*John.*")
// GOOD: Simple comparisons
NSPredicate(format: "name CONTAINS[c] %@", "John")
Performance Monitoring
class CoreDataPerformanceMonitor {
static let shared = CoreDataPerformanceMonitor()
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(contextWillSave(_:)),
name: .NSManagedObjectContextWillSave,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(contextDidSave(_:)),
name: .NSManagedObjectContextDidSave,
object: nil
)
}
private var saveStartTime: Date?
@objc private func contextWillSave(_ notification: Notification) {
saveStartTime = Date()
}
@objc private func contextDidSave(_ notification: Notification) {
guard let startTime = saveStartTime else { return }
let duration = Date().timeIntervalSince(startTime)
if duration > 0.1 { // Warn if save takes more than 100ms
print("⚠️ Slow save detected: \(duration)s")
if let userInfo = notification.userInfo {
let inserted = (userInfo[NSInsertedObjectsKey] as? Set<NSManagedObject>)?.count ?? 0
let updated = (userInfo[NSUpdatedObjectsKey] as? Set<NSManagedObject>)?.count ?? 0
let deleted = (userInfo[NSDeletedObjectsKey] as? Set<NSManagedObject>)?.count ?? 0
print(" Inserted: \(inserted), Updated: \(updated), Deleted: \(deleted)")
}
}
saveStartTime = nil
}
}
4. Advanced Patterns
Repository Pattern
Separate Core Data logic from the rest of your app.
protocol PersonRepository {
func create(firstName: String, lastName: String, age: Int) -> Person?
func fetch(predicate: NSPredicate?) -> [Person]
func update(person: Person, changes: [String: Any]) -> Bool
func delete(person: Person) -> Bool
}
class CoreDataPersonRepository: PersonRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.mainContext) {
self.context = context
}
func create(firstName: String, lastName: String, age: Int) -> Person? {
let person = Person(context: context)
person.firstName = firstName
person.lastName = lastName
person.age = Int16(age)
do {
try context.save()
return person
} catch {
print("Failed to create: \(error)")
return nil
}
}
func fetch(predicate: NSPredicate? = nil) -> [Person] {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = predicate
return (try? context.fetch(fetchRequest)) ?? []
}
func update(person: Person, changes: [String: Any]) -> Bool {
for (key, value) in changes {
person.setValue(value, forKey: key)
}
do {
try context.save()
return true
} catch {
print("Failed to update: \(error)")
return false
}
}
func delete(person: Person) -> Bool {
context.delete(person)
do {
try context.save()
return true
} catch {
print("Failed to delete: \(error)")
return false
}
}
}
// Usage in ViewModel or Controller
class PeopleViewModel {
private let repository: PersonRepository
init(repository: PersonRepository = CoreDataPersonRepository()) {
self.repository = repository
}
func loadPeople() -> [Person] {
return repository.fetch(predicate: nil)
}
func addPerson(firstName: String, lastName: String, age: Int) {
_ = repository.create(firstName: firstName, lastName: lastName, age: age)
}
}
Unit of Work Pattern
Group related changes together.
class UnitOfWork {
private let context: NSManagedObjectContext
private var operations: [() -> Void] = []
init(context: NSManagedObjectContext) {
self.context = context
}
func register(operation: @escaping () -> Void) {
operations.append(operation)
}
func commit() -> Result<Void, Error> {
operations.forEach { $0() }
do {
try context.save()
operations.removeAll()
return .success(())
} catch {
context.rollback()
return .failure(error)
}
}
func rollback() {
context.rollback()
operations.removeAll()
}
}
// Usage
let unitOfWork = UnitOfWork(context: context)
unitOfWork.register {
let person = Person(context: self.context)
person.firstName = "John"
}
unitOfWork.register {
let company = Company(context: self.context)
company.name = "Tech Corp"
}
switch unitOfWork.commit() {
case .success:
print("All changes saved")
case .failure(let error):
print("Failed: \(error)")
}
Generic Core Data Manager
protocol ManagedObjectConvertible {
associatedtype ManagedObject: NSManagedObject
func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject
}
class CoreDataManager<T: NSManagedObject> {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.mainContext) {
self.context = context
}
func fetch(
predicate: NSPredicate? = nil,
sortDescriptors: [NSSortDescriptor]? = nil,
fetchLimit: Int? = nil
) -> [T] {
let fetchRequest = NSFetchRequest<T>(entityName: String(describing: T.self))
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = sortDescriptors
if let limit = fetchLimit {
fetchRequest.fetchLimit = limit
}
return (try? context.fetch(fetchRequest)) ?? []
}
func create() -> T {
return T(context: context)
}
func save() -> Bool {
guard context.hasChanges else { return true }
do {
try context.save()
return true
} catch {
print("Save failed: \(error)")
return false
}
}
func delete(_ object: T) -> Bool {
context.delete(object)
return save()
}
func deleteAll() -> Bool {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(
entityName: String(describing: T.self)
)
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try context.execute(batchDelete)
return true
} catch {
print("Batch delete failed: \(error)")
return false
}
}
}
// Usage
let personManager = CoreDataManager<Person>()
let people = personManager.fetch(
predicate: NSPredicate(format: "age > %d", 18),
sortDescriptors: [NSSortDescriptor(key: "lastName", ascending: true)]
)
let newPerson = personManager.create()
newPerson.firstName = "Alice"
personManager.save()
Sync Manager Pattern
For syncing with remote servers.
class SyncManager {
private let context: NSManagedObjectContext
private let apiClient: APIClient
init() {
self.context = CoreDataStack.shared.newBackgroundContext()
self.apiClient = APIClient()
}
func syncPeople() async throws {
// Fetch from server
let remotePeople = try await apiClient.fetchPeople()
// Update local database
context.perform {
for remoteData in remotePeople {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "remoteID == %@",
remoteData.id
)
let person: Person
if let existing = try? self.context.fetch(fetchRequest).first {
person = existing
} else {
person = Person(context: self.context)
person.remoteID = remoteData.id
}
person.firstName = remoteData.firstName
person.lastName = remoteData.lastName
person.age = Int16(remoteData.age)
person.lastSyncDate = Date()
}
do {
try self.context.save()
} catch {
print("Sync failed: \(error)")
}
}
}
func pushLocalChanges() async throws {
let fetchRequest: NSFetchRequest<Person> = Person.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "lastModifiedDate > lastSyncDate OR lastSyncDate == nil"
)
let changedPeople = try context.fetch(fetchRequest)
for person in changedPeople {
let data = PersonDTO(from: person)
try await apiClient.updatePerson(data)
person.lastSyncDate = Date()
}
try context.save()
}
}
Service Layer Pattern
protocol DataService {
associatedtype Entity: NSManagedObject
func getAll() async throws -> [Entity]
func getById(_ id: NSManagedObjectID) async throws -> Entity?
func create(_ object: Entity) async throws
func update(_ object: Entity) async throws
func delete(_ object: Entity) async throws
}
class PersonService: DataService {
typealias Entity = Person
private let context: NSManagedObjectContext
init
Comments
Post a Comment