Persistence
SwiftData, Core Data concepts, UserDefaults, and local storage.
PART 10 — PERSISTENCE
Data that survives termination.
Learning Objectives
By the end of this chapter, you will understand how to securely and efficiently persist data across app launches. You will learn the distinctions between UserDefaults, Keychain, File System, SQLite, Core Data, and SwiftData, and be able to implement production-grade offline-first architectures.
Prerequisites
You must understand memory management (ARC), file paths, Codable, and structured concurrency.
Why Does This Exist?
When an application process is terminated by the operating system (due to memory pressure or the user swiping it away), everything in RAM is destroyed. Without persistence, a user would have to log in and re-download their entire profile every time they open the app. Persistence exists to bridge the gap between volatile memory (RAM) and non-volatile storage (disk).
The Problem Before the Solution
Before robust local databases and secure enclaves, developers often wrote raw bytes or simple property lists (PLISTs) to disk. If you had 100,000 records, you had to load all 100,000 into memory just to filter for one user, make the change, and then overwrite the entire file.
Why the Old Approach Breaks
This naive approach causes massive spikes in memory and CPU usage, risking immediate termination by the OS. It also risks data corruption if the app crashes halfway through writing the file. Furthermore, writing sensitive tokens to plaintext files compromises user security entirely.
History
Apple introduced UserDefaults for tiny preferences. For structured data, Core Data was born from NeXTSTEP's Enterprise Objects Framework (EOF) in the 90s. SQLite became the de facto standard for mobile local databases due to its reliability. Recently, SwiftData was introduced to provide a native, Swift-first declarative layer over Core Data.
Internal Working
Under the hood, iOS limits what your app can see via sandboxing. Your app only has access to its specific container directory.
- Documents/: User-generated content. Backed up to iCloud.
- Library/Caches/: Downloaded data that can be re-fetched. Not backed up. OS can delete it when space is low.
- tmp/: Temporary files. Deleted frequently.
When you query SwiftData, it translates your Swift predicates into SQL statements, executes them on an optimized SQLite connection, retrieves raw bytes, instantiates Swift model objects, and places them into a memory managed ModelContext.
Visual Explanation
Memory (RAM) Disk (Storage) [ App Process ] <--> [ App Sandbox ] - SwiftData Context <--> - SQLite Database (Library/Application Support) - Cached Images <--> - Caches Directory (Library/Caches) - Auth Token <--> - Secure Enclave (Keychain) - App Settings <--> - Plist (UserDefaults)
Syntax
SwiftData revolves around the @Model macro.
import SwiftData
@Model
class Recipe {
var id: UUID
var title: String
var isFavorite: Bool
init(title: String) {
self.id = UUID()
self.title = title
self.isFavorite = false
}
}
The @Model macro automatically synthesizes conformance to PersistentModel and generates the necessary Core Data underlying schema.
Tiny Example
Using SwiftData in a SwiftUI View:
import SwiftUI
import SwiftData
struct RecipeListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Recipe.title) private var recipes: [Recipe]
var body: some View {
List(recipes) { recipe in
Text(recipe.title)
}
Button("Add") {
context.insert(Recipe(title: "New Recipe"))
}
}
}
Walkthrough
When the view loads, the @Query property wrapper requests the modelContext to fetch all Recipe entities. SwiftData translates this to a SQLite SELECT, orders it by title, and observes changes. When the button is pressed, a new Recipe instance is created in memory and inserted into the context. The context tracks this insertion, and upon the next save cycle (or runloop end), issues an INSERT to SQLite. The query observes the change and invalidates the view, causing SwiftUI to re-render.
Break It
Storing massive images in UserDefaults.
// DON'T DO THIS
let imageData = UIImage(named: "massive_image")?.pngData()
UserDefaults.standard.set(imageData, forKey: "profilePic")
UserDefaults loads its entire contents into a dictionary in RAM upon app launch. Storing a 50MB image will cause your app's base memory footprint to spike instantly, leading to slow launches or OOM crashes.
Debug It
If your app crashes during SwiftData migrations, use the Xcode console and Core Data debug arguments:
- Add
-com.apple.CoreData.SQLDebug 1to your run scheme arguments to see every SQL statement being executed. - Use the Memory Graph Debugger to ensure context references aren't leaking.
Real Application Feature
Offline-First Caching Architecture.
Production Implementation
In production, you never query the network directly from the UI. You build an offline-first architecture using a Repository Pattern.
actor ArticleRepository {
let network: NetworkService
let database: DatabaseContext
func getArticles() async throws -> [Article] {
// 1. Immediately return cached data if available
let cached = try await database.fetchArticles()
// 2. Fire off a background task to refresh data
Task {
do {
let fresh = try await network.fetchArticles()
try await database.save(fresh)
} catch {
// Log failure, but the user already has cached data
}
}
return cached
}
}
Production Usage
Applications like Instagram or Twitter show you cached content immediately on launch while fetching new content in the background. They use SQLite/Core Data for the feed, file system for cached images, and Keychain for the session token.
Performance
Database fetches can block the main thread. Always perform heavy data processing or large batch inserts on a background context or Actor. Be careful with caching too much; the OS will evict your app if it hoards disk space. Always utilize the Caches directory for temporary assets.
Best Practices
- Never put PII (Personally Identifiable Information) in UserDefaults.
- Use Keychain for passwords and tokens.
- Use SwiftData/SQLite for structured, queryable data.
- Write images/files to the Caches or Documents directory, storing only the file path string in the database.
- Always anticipate database migrations. Version your schemas from day one.
Engineering Challenge
You have a chat application. A user sends a message while in an elevator with no service. The message must eventually be sent when they regain service. How do you design this?
Solution
1. User taps send. 2. The message is immediately saved to SwiftData with a status of .pending. 3. The UI updates instantly (optimistic UI). 4. A background task is spawned to send to the API. 5. If it fails, a BackgroundTask is scheduled with the OS to retry when network conditions improve. 6. Upon success, update the status to .delivered.
Revision Sheet
Persistence Quick Reference:
- UserDefaults: Small preferences. Plist backed.
- Keychain: Secure, encrypted, tokens, passwords.
- File System: Images, videos, JSON files. Use correct directories (Documents vs Caches).
- SwiftData/Core Data: Structured relationships, offline-first architectures, heavy querying.
- Offline-first: Read from DB, fetch from network, update DB, UI reacts to DB changes.
Connections
This connects to Concurrency (Actors protecting the database), Networking (fetching data to persist), and SwiftUI (observing context changes to drive UI). Next, we will cover Authentication & Security to properly lock down the APIs supplying this data.
Mini Project (20-30 min)
Use `UserDefaults` to save and load a simple user preference, like a theme setting (Light/Dark mode) or a highest score. Create a wrapper property wrapper or manager to access it safely.
View Solution
import Foundation
import SwiftUI
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
class AppPreferences: ObservableObject {
@UserDefault(key: "isDarkMode", defaultValue: false)
var isDarkMode: Bool {
willSet { objectWillChange.send() }
}
}
// SwiftUI View Usage
struct SettingsView: View {
@StateObject private var prefs = AppPreferences()
var body: some View {
Toggle("Dark Mode", isOn: $prefs.isDarkMode)
.padding()
// In iOS 13/14 you had to manually apply preferredColorScheme based on this
}
}
Bigger Project (1-2 hours)
Build a simple note-taking app using `SwiftData`. Create a `Note` model and implement a View that lets the user add, delete, and list notes.
View Solution
import SwiftUI
import SwiftData
@Model
class Note {
var text: String
var timestamp: Date
init(text: String, timestamp: Date = .now) {
self.text = text
self.timestamp = timestamp
}
}
struct NoteListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Note.timestamp, order: .reverse) private var notes: [Note]
@State private var newNoteText = ""
var body: some View {
NavigationView {
VStack {
HStack {
TextField("Enter a note", text: $newNoteText)
.textFieldStyle(.roundedBorder)
Button("Add") {
addNote()
}
}.padding()
List {
ForEach(notes) { note in
Text(note.text)
}
.onDelete(perform: deleteNotes)
}
}
.navigationTitle("My Notes")
}
}
private func addNote() {
guard !newNoteText.isEmpty else { return }
let note = Note(text: newNoteText)
context.insert(note)
newNoteText = ""
}
private func deleteNotes(offsets: IndexSet) {
for index in offsets {
context.delete(notes[index])
}
}
}
// In the App entry point:
// WindowGroup { NoteListView() }.modelContainer(for: Note.self)
Interview Questions
Easy: What is the primary difference between UserDefaults and CoreData/SwiftData?
UserDefaults is designed for small, simple key-value pairs (like user settings). CoreData and SwiftData are full object graph and persistence frameworks meant for complex, relational data and large datasets.
Medium: What does the `@Query` macro do in SwiftData?
The `@Query` macro automatically fetches data from the ModelContext and keeps the SwiftUI view updated whenever the underlying data changes in the database, acting similarly to `@FetchRequest` in CoreData.
Hard: How does Core Data handle multithreading, and what are `perform` and `performAndWait` used for?
Core Data managed object contexts (`NSManagedObjectContext`) are strictly bound to the thread (queue) they were created on. Accessing managed objects from the wrong thread will cause a crash. `perform` and `performAndWait` ensure that the block of code interacting with the context is executed on the correct queue associated with that specific context (asynchronously and synchronously, respectively).