iOS Data Persistence: Core Data, SwiftData, and Beyond
May 13, 2025 · 10 min read
iOS, Core Data, SwiftData, Persistence, Interview Prep
Introduction
Local persistence is essential for offline-first apps and smooth user experiences. Understanding when to use Core Data vs SwiftData vs simpler alternatives is a common interview topic. With iOS 17+ targeting becoming standard in 2025, SwiftData knowledge is increasingly important, though Core Data remains relevant for enterprise apps.
Building a nutrition tracking app required careful consideration of persistence strategies - some data needs to survive app restarts, some needs security, and some needs to sync. Here's what matters.
Core Data Fundamentals
Core Data is Apple's mature object graph and persistence framework:
import CoreData
class CoreDataStack {
static let shared = CoreDataStack()
let persistentContainer: NSPersistentContainer
var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
private init() {
persistentContainer = NSPersistentContainer(name: "Glucoplate")
// Configure for performance
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
persistentContainer.loadPersistentStores { description, error in
if let error = error {
fatalError("Core Data failed to load: \(error)")
}
}
}
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}
func performBackgroundTask<T>(
_ block: @escaping (NSManagedObjectContext) throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
persistentContainer.performBackgroundTask { context in
do {
let result = try block(context)
continuation.resume(returning: result)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
Entity Definition
// MealEntity in .xcdatamodeld
@objc(MealEntity)
public class MealEntity: NSManagedObject {
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var calories: Int32
@NSManaged public var protein: Double
@NSManaged public var loggedAt: Date
@NSManaged public var syncStatus: Int16
}
extension MealEntity {
@nonobjc public class func fetchRequest() -> NSFetchRequest<MealEntity> {
return NSFetchRequest<MealEntity>(entityName: "MealEntity")
}
// Convert to domain model
func toMeal() -> Meal {
Meal(
id: id,
name: name,
calories: Int(calories),
protein: protein,
loggedAt: loggedAt
)
}
func update(from meal: Meal) {
id = meal.id
name = meal.name
calories = Int32(meal.calories)
protein = meal.protein
loggedAt = meal.loggedAt
}
}
Repository Pattern
protocol MealRepositoryProtocol {
func fetchAll() async throws -> [Meal]
func fetch(for date: Date) async throws -> [Meal]
func save(_ meal: Meal) async throws -> Meal
func delete(_ meal: Meal) async throws
}
class CoreDataMealRepository: MealRepositoryProtocol {
private let coreDataStack: CoreDataStack
init(coreDataStack: CoreDataStack = .shared) {
self.coreDataStack = coreDataStack
}
func fetchAll() async throws -> [Meal] {
try await coreDataStack.performBackgroundTask { context in
let request = MealEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \MealEntity.loggedAt, ascending: false)
]
let entities = try context.fetch(request)
return entities.map { $0.toMeal() }
}
}
func save(_ meal: Meal) async throws -> Meal {
try await coreDataStack.performBackgroundTask { context in
let request = MealEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", meal.id as CVarArg)
let entity: MealEntity
if let existing = try context.fetch(request).first {
entity = existing
} else {
entity = MealEntity(context: context)
}
entity.update(from: meal)
try context.save()
return entity.toMeal()
}
}
func delete(_ meal: Meal) async throws {
try await coreDataStack.performBackgroundTask { context in
let request = MealEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", meal.id as CVarArg)
if let entity = try context.fetch(request).first {
context.delete(entity)
try context.save()
}
}
}
}
SwiftData (iOS 17+)
SwiftData dramatically simplifies persistence with Swift-native syntax:
import SwiftData
@Model
final class MealModel {
@Attribute(.unique) var id: UUID
var name: String
var calories: Int
var protein: Double
var loggedAt: Date
@Relationship(deleteRule: .cascade)
var ingredients: [IngredientModel]?
init(
id: UUID = UUID(),
name: String,
calories: Int,
protein: Double,
loggedAt: Date = Date()
) {
self.id = id
self.name = name
self.calories = calories
self.protein = protein
self.loggedAt = loggedAt
}
}
// Setup in App
@main
struct GlucoplateApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [MealModel.self, IngredientModel.self])
}
}
Using @Query in Views
struct MealListView: View {
@Query(sort: \MealModel.loggedAt, order: .reverse)
private var meals: [MealModel]
@Environment(\.modelContext) private var context
var body: some View {
List(meals) { meal in
MealRow(meal: meal)
}
}
func addMeal(_ meal: MealModel) {
context.insert(meal)
}
func deleteMeal(_ meal: MealModel) {
context.delete(meal)
}
}
// Filtered query with predicate
struct TodaysMeals: View {
@Query(filter: #Predicate<MealModel> { meal in
Calendar.current.isDateInToday(meal.loggedAt)
}, sort: \MealModel.loggedAt)
private var todaysMeals: [MealModel]
var body: some View {
List(todaysMeals) { meal in
Text(meal.name)
}
}
}
SwiftData vs Core Data
SwiftData isn't just "Core Data 2.0" - it's built for Swift-first development. The @Model macro eliminates boilerplate, @Query replaces @FetchRequest with type-safe predicates, and you never touch NSManagedObjectContext. For new iOS 17+ projects, it's the clear choice unless you need Core Data's advanced features like custom migrations.
When to Use What
| Storage | Use Case | Example |
|---|---|---|
| SwiftData | Structured data, iOS 17+ | Meals, recipes, user content |
| Core Data | Complex queries, iOS 16 support | Enterprise apps, complex relationships |
| UserDefaults | Simple preferences | Theme, settings flags |
| Keychain | Sensitive data | Tokens, passwords |
| File System | Large files, media | Images, documents |
UserDefaults for Preferences
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
let container: UserDefaults
init(key: String, defaultValue: T, container: UserDefaults = .standard) {
self.key = key
self.defaultValue = defaultValue
self.container = container
}
var wrappedValue: T {
get { container.object(forKey: key) as? T ?? defaultValue }
set { container.set(newValue, forKey: key) }
}
}
class UserPreferences {
static let shared = UserPreferences()
@UserDefault(key: "hasCompletedOnboarding", defaultValue: false)
var hasCompletedOnboarding: Bool
@UserDefault(key: "calorieGoal", defaultValue: 2000)
var calorieGoal: Int
@UserDefault(key: "selectedTheme", defaultValue: "system")
var selectedTheme: String
}
Keychain for Sensitive Data
import Security
enum KeychainError: Error {
case itemNotFound
case unexpectedStatus(OSStatus)
}
class KeychainService {
static let shared = KeychainService()
private let service = "com.glucoplate.app"
func save(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
func load(for key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
throw KeychainError.itemNotFound
}
return result as! Data
}
func delete(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
// Usage
class AuthService {
private let keychain = KeychainService.shared
func saveRefreshToken(_ token: String) {
guard let data = token.data(using: .utf8) else { return }
try? keychain.save(data, for: "refreshToken")
}
func getRefreshToken() -> String? {
guard let data = try? keychain.load(for: "refreshToken") else { return nil }
return String(data: data, encoding: .utf8)
}
}
Never Store Secrets in UserDefaults
UserDefaults is not encrypted. API tokens, passwords, and any sensitive data must go in Keychain. I've seen apps rejected for storing authentication tokens in UserDefaults.
Offline-First Architecture
enum SyncStatus: Int16 {
case synced = 0
case pendingSync = 1
case syncFailed = 2
}
class OfflineFirstRepository: MealRepositoryProtocol {
private let localRepository: CoreDataMealRepository
private let remoteService: MealService
private let networkMonitor: NetworkMonitor
func save(_ meal: Meal) async throws -> Meal {
// Always save locally first
var savedMeal = try await localRepository.save(meal)
// Try to sync if online
if networkMonitor.isConnected {
do {
savedMeal = try await remoteService.createMeal(meal)
savedMeal.syncStatus = .synced
} catch {
savedMeal.syncStatus = .pendingSync
}
} else {
savedMeal.syncStatus = .pendingSync
}
return try await localRepository.save(savedMeal)
}
func syncPendingChanges() async {
let pending = try? await localRepository.fetchPending()
for meal in pending ?? [] {
do {
let synced = try await remoteService.createMeal(meal)
var updated = synced
updated.syncStatus = .synced
_ = try? await localRepository.save(updated)
} catch {
// Will retry next sync
}
}
}
}
Interview Questions
Q1: When would you use Core Data vs UserDefaults vs Keychain?
Answer:
| Storage | Use Case | Example |
|---|---|---|
| Core Data/SwiftData | Complex structured data with relationships and queries | Meals, recipes, health records |
| UserDefaults | Simple preferences, small non-sensitive data | Theme, onboarding flag, settings |
| Keychain | Sensitive data requiring encryption | Auth tokens, passwords, API keys |
// UserDefaults - preferences
UserDefaults.standard.set(true, forKey: "isDarkMode")
// Keychain - secrets
KeychainService.shared.save(tokenData, for: "apiToken")
// Core Data - structured data
let meal = MealEntity(context: context)
try context.save()
Q2: Explain NSManagedObjectContext and thread safety.
Answer: NSManagedObjectContext is Core Data's scratchpad for objects. Each context is bound to a specific thread:
// View context - main thread only
let viewContext = persistentContainer.viewContext
// Background context - background queue
persistentContainer.performBackgroundTask { context in
// Safe to use context here
}
Key rules:
- Never pass NSManagedObjects between contexts
- Never access context from wrong thread
- Pass object IDs, not objects, between contexts
- Use
performorperformAndWaitfor thread-safe access
Q3: How do you handle Core Data migrations?
Answer: Core Data supports lightweight (automatic) and custom migrations:
Lightweight Migration:
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
Works for: Adding/removing attributes, optional attributes with defaults, renaming with ID.
Custom Migration: Required for complex schema changes - create mapping models and migration policies.
Q4: What is faulting in Core Data?
Answer: Faulting is lazy loading. Objects are "faults" until accessed:
let meals = try context.fetch(request)
// meals[0] is a fault - no data loaded
let name = meals[0].name
// Now meal is "fired" - data loaded
Control with:
request.relationshipKeyPathsForPrefetching = ["ingredients"]
request.returnsObjectsAsFaults = false // Load immediately
Common Mistakes
1. Accessing Context from Wrong Thread
// ❌ Background access to view context
DispatchQueue.global().async {
let meals = try? viewContext.fetch(request) // Crash!
}
// ✅ Use background context
persistentContainer.performBackgroundTask { context in
let meals = try? context.fetch(request)
}
2. Storing Large Data in UserDefaults
// ❌ Images don't belong in UserDefaults
UserDefaults.standard.set(imageData, forKey: "profile")
// ✅ Use file system
let url = documentsDirectory.appendingPathComponent("profile.jpg")
try imageData.write(to: url)
3. Forgetting to Save
// ❌ Changes lost
let meal = MealEntity(context: context)
meal.name = "Lunch"
// Forgot to save!
// ✅ Always save
meal.name = "Lunch"
try context.save()
Summary
| Technology | When to Use | Key Consideration |
|---|---|---|
| SwiftData | iOS 17+, new projects | Simplest, Swift-native |
| Core Data | iOS 16 support, complex needs | More control, more complexity |
| UserDefaults | Preferences only | Not for sensitive or large data |
| Keychain | Secrets | Always encrypted |
| File System | Large files | Manual management |
The right persistence choice depends on your data's nature, security requirements, and target iOS version. SwiftData is the future for most apps, but Core Data knowledge remains valuable.
Part 5 of the iOS Interview Prep series.