Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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

StorageUse CaseExample
SwiftDataStructured data, iOS 17+Meals, recipes, user content
Core DataComplex queries, iOS 16 supportEnterprise apps, complex relationships
UserDefaultsSimple preferencesTheme, settings flags
KeychainSensitive dataTokens, passwords
File SystemLarge files, mediaImages, 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:

StorageUse CaseExample
Core Data/SwiftDataComplex structured data with relationships and queriesMeals, recipes, health records
UserDefaultsSimple preferences, small non-sensitive dataTheme, onboarding flag, settings
KeychainSensitive data requiring encryptionAuth 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 perform or performAndWait for 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

TechnologyWhen to UseKey Consideration
SwiftDataiOS 17+, new projectsSimplest, Swift-native
Core DataiOS 16 support, complex needsMore control, more complexity
UserDefaultsPreferences onlyNot for sensitive or large data
KeychainSecretsAlways encrypted
File SystemLarge filesManual 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.

← Previous

Mastering Combine and Reactive Patterns

Next →

iOS Networking with Async/Await and Actors