17 KiB
Data Persistence
Patterns for persisting data in macOS apps using SwiftData, Core Data, and file-based storage.
<choosing_persistence> SwiftData (macOS 14+): Best for new apps
- Declarative schema in code
- Tight SwiftUI integration
- Automatic iCloud sync
- Less boilerplate
Core Data: Best for complex needs or backward compatibility
- Visual schema editor
- Fine-grained migration control
- More mature ecosystem
- Works on older macOS
File-based (Codable): Best for documents or simple data
- JSON/plist storage
- No database overhead
- Portable data
- Good for document-based apps
UserDefaults: Preferences and small state only
- Not for app data
Keychain: Sensitive data only
- Passwords, tokens, keys </choosing_persistence>
@Model class Project { var name: String var createdAt: Date var isArchived: Bool
@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task]
@Attribute(.externalStorage)
var thumbnail: Data?
// Computed properties are fine
var activeTasks: [Task] {
tasks.filter { !$0.isComplete }
}
init(name: String) {
self.name = name
self.createdAt = Date()
self.isArchived = false
self.tasks = []
}
}
@Model class Task { var title: String var isComplete: Bool var dueDate: Date? var priority: Priority
var project: Project?
enum Priority: Int, Codable {
case low = 0
case medium = 1
case high = 2
}
init(title: String, priority: Priority = .medium) {
self.title = title
self.isComplete = false
self.priority = priority
}
}
</model_definition>
<container_setup>
```swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Project.self)
}
}
// Custom configuration
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Project.self, Task.self])
let config = ModelConfiguration(
"MyApp",
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
</container_setup>
```swift struct ProjectListView: View { // Basic query @Query private var projects: [Project]// Filtered and sorted
@Query(
filter: #Predicate<Project> { !$0.isArchived },
sort: \Project.createdAt,
order: .reverse
) private var activeProjects: [Project]
// Dynamic filter
@Query private var allProjects: [Project]
var filteredProjects: [Project] {
if searchText.isEmpty {
return allProjects
}
return allProjects.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
}
@State private var searchText = ""
var body: some View {
List(filteredProjects) { project in
Text(project.name)
}
.searchable(text: $searchText)
}
}
</querying>
<relationship_patterns>
<critical_rule>
**When adding items to relationships, set the inverse relationship property, then insert into context.** Don't manually append to arrays.
</critical_rule>
<adding_to_relationships>
```swift
// CORRECT: Set inverse, then insert
func addCard(to column: Column, title: String) {
let card = Card(title: title, position: 1.0)
card.column = column // Set the inverse relationship
modelContext.insert(card) // Insert into context
// SwiftData automatically updates column.cards
}
// WRONG: Don't manually append to arrays
func addCardWrong(to column: Column, title: String) {
let card = Card(title: title, position: 1.0)
column.cards.append(card) // This can cause issues
modelContext.insert(card)
}
</adding_to_relationships>
<when_to_insert>
Always call modelContext.insert() for new objects. SwiftData needs this to track the object.
// Creating a new item - MUST insert
let card = Card(title: "New")
card.column = column
modelContext.insert(card) // Required!
// Modifying existing item - no insert needed
existingCard.title = "Updated" // SwiftData tracks this automatically
// Moving item between parents
card.column = newColumn // Just update the relationship
// No insert needed for existing objects
</when_to_insert>
<relationship_definition>
@Model
class Column {
var name: String
var position: Double
// Define relationship with inverse
@Relationship(deleteRule: .cascade, inverse: \Card.column)
var cards: [Card] = []
init(name: String, position: Double) {
self.name = name
self.position = position
}
}
@Model
class Card {
var title: String
var position: Double
// The inverse side - this is what you SET when adding
var column: Column?
init(title: String, position: Double) {
self.title = title
self.position = position
}
}
</relationship_definition>
<common_pitfalls> Pitfall 1: Not setting inverse relationship
// WRONG - card won't appear in column.cards
let card = Card(title: "New", position: 1.0)
modelContext.insert(card) // Missing: card.column = column
Pitfall 2: Manually managing both sides
// WRONG - redundant and can cause issues
card.column = column
column.cards.append(card) // Don't do this
modelContext.insert(card)
Pitfall 3: Forgetting to insert
// WRONG - object won't persist
let card = Card(title: "New", position: 1.0)
card.column = column
// Missing: modelContext.insert(card)
</common_pitfalls>
<reordering_items>
// For drag-and-drop reordering within same parent
func moveCard(_ card: Card, to newPosition: Double) {
card.position = newPosition
// SwiftData tracks the change automatically
}
// Moving between parents (e.g., column to column)
func moveCard(_ card: Card, to newColumn: Column, position: Double) {
card.column = newColumn
card.position = position
// No insert needed - card already exists
}
</reordering_items> </relationship_patterns>
<crud_operations>
struct ProjectListView: View {
@Environment(\.modelContext) private var context
@Query private var projects: [Project]
var body: some View {
List {
ForEach(projects) { project in
Text(project.name)
}
.onDelete(perform: deleteProjects)
}
.toolbar {
Button("Add") {
addProject()
}
}
}
private func addProject() {
let project = Project(name: "New Project")
context.insert(project)
// Auto-saves
}
private func deleteProjects(at offsets: IndexSet) {
for index in offsets {
context.delete(projects[index])
}
}
}
// In a service
actor DataService {
private let context: ModelContext
init(container: ModelContainer) {
self.context = ModelContext(container)
}
func fetchProjects() throws -> [Project] {
let descriptor = FetchDescriptor<Project>(
predicate: #Predicate { !$0.isArchived },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try context.fetch(descriptor)
}
func save(_ project: Project) throws {
context.insert(project)
try context.save()
}
}
</crud_operations>
<icloud_sync>
// Enable in ModelConfiguration
let config = ModelConfiguration(
cloudKitDatabase: .automatic // or .private("containerID")
)
// Handle sync status
struct SyncStatusView: View {
@Environment(\.modelContext) private var context
var body: some View {
// SwiftData handles sync automatically
// Monitor with NotificationCenter for CKAccountChanged
Text("Syncing...")
}
}
</icloud_sync>
<core_data> <stack_setup>
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "MyApp")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
}
</stack_setup>
<fetch_request>
struct ProjectListView: View {
@Environment(\.managedObjectContext) private var context
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \CDProject.createdAt, ascending: false)],
predicate: NSPredicate(format: "isArchived == NO")
)
private var projects: FetchedResults<CDProject>
var body: some View {
List(projects) { project in
Text(project.name ?? "Untitled")
}
}
}
</fetch_request>
<crud_operations_coredata>
// Create
func createProject(name: String) {
let project = CDProject(context: context)
project.id = UUID()
project.name = name
project.createdAt = Date()
do {
try context.save()
} catch {
context.rollback()
}
}
// Update
func updateProject(_ project: CDProject, name: String) {
project.name = name
try? context.save()
}
// Delete
func deleteProject(_ project: CDProject) {
context.delete(project)
try? context.save()
}
// Background operations
func importProjects(_ data: [ProjectData]) async throws {
let context = PersistenceController.shared.newBackgroundContext()
try await context.perform {
for item in data {
let project = CDProject(context: context)
project.id = UUID()
project.name = item.name
}
try context.save()
}
}
</crud_operations_coredata> </core_data>
<file_based> <codable_storage>
struct AppData: Codable {
var items: [Item]
var lastModified: Date
}
class FileStorage {
private let fileURL: URL
init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let appFolder = appSupport.appendingPathComponent("MyApp", isDirectory: true)
// Create directory if needed
try? FileManager.default.createDirectory(at: appFolder, withIntermediateDirectories: true)
fileURL = appFolder.appendingPathComponent("data.json")
}
func load() throws -> AppData {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(AppData.self, from: data)
}
func save(_ appData: AppData) throws {
let data = try JSONEncoder().encode(appData)
try data.write(to: fileURL, options: .atomic)
}
}
</codable_storage>
<document_storage> For document-based apps, see document-apps.md.
struct ProjectDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
var project: Project
init(project: Project = Project()) {
self.project = project
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
project = try JSONDecoder().decode(Project.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(project)
return FileWrapper(regularFileWithContents: data)
}
}
</document_storage> </file_based>
```swift import Securityclass KeychainService { static let shared = KeychainService()
func save(key: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
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.saveFailed(status)
}
}
func load(key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.loadFailed(status)
}
return data
}
func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
enum KeychainError: Error { case saveFailed(OSStatus) case loadFailed(OSStatus) case deleteFailed(OSStatus) }
// Usage let token = "secret-token".data(using: .utf8)! try KeychainService.shared.save(key: "api-token", data: token)
</keychain>
<user_defaults>
```swift
// Using @AppStorage
struct SettingsView: View {
@AppStorage("theme") private var theme = "system"
@AppStorage("fontSize") private var fontSize = 14.0
var body: some View {
Form {
Picker("Theme", selection: $theme) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
Slider(value: $fontSize, in: 10...24) {
Text("Font Size: \(Int(fontSize))")
}
}
}
}
// Type-safe wrapper
extension UserDefaults {
enum Keys {
static let theme = "theme"
static let recentFiles = "recentFiles"
}
var theme: String {
get { string(forKey: Keys.theme) ?? "system" }
set { set(newValue, forKey: Keys.theme) }
}
var recentFiles: [URL] {
get {
guard let data = data(forKey: Keys.recentFiles),
let urls = try? JSONDecoder().decode([URL].self, from: data)
else { return [] }
return urls
}
set {
let data = try? JSONEncoder().encode(newValue)
set(data, forKey: Keys.recentFiles)
}
}
}
</user_defaults>
```swift // SwiftData handles lightweight migrations automatically // For complex migrations, use VersionedSchemaenum MyAppSchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0) static var models: [any PersistentModel.Type] { [Project.self] }
@Model
class Project {
var name: String
init(name: String) { self.name = name }
}
}
enum MyAppSchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0) static var models: [any PersistentModel.Type] { [Project.self] }
@Model
class Project {
var name: String
var createdAt: Date // New property
init(name: String) {
self.name = name
self.createdAt = Date()
}
}
}
enum MyAppMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [MyAppSchemaV1.self, MyAppSchemaV2.self] }
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: MyAppSchemaV1.self,
toVersion: MyAppSchemaV2.self
)
}
</swiftdata_migration>
</migration>
<best_practices>
- Use SwiftData for new apps targeting macOS 14+
- Use background contexts for heavy operations
- Handle migration explicitly for production apps
- Don't store large blobs in database (use @Attribute(.externalStorage))
- Use transactions for multiple related changes
- Test persistence with in-memory stores
</best_practices>