SwiftData Persistence
Comprehensive guide to SwiftData framework, the @Model macro, reactive queries, relationships, and native iCloud synchronization for iOS 26 development.
Prerequisites
-
iOS 17+ for SwiftData (iOS 26 recommended)
-
Xcode 26+
@Model Macro Basics
Defining a Model
import SwiftData
@Model class Note { var title: String var content: String var createdAt: Date var isPinned: Bool
init(title: String, content: String = "") {
self.title = title
self.content = content
self.createdAt = Date()
self.isPinned = false
}
}
What @Model Provides
The @Model macro automatically:
-
Makes the class persistable
-
Tracks property changes
-
Enables SwiftUI observation
-
Generates schema metadata
Model Requirements
@Model class Item { // All stored properties must be: // - Codable types (String, Int, Date, Data, etc.) // - Other @Model types (relationships) // - Arrays/optionals of the above
var name: String // ✓ Codable
var count: Int // ✓ Codable
var timestamp: Date // ✓ Codable
var data: Data // ✓ Codable
var tags: [String] // ✓ Array of Codable
var metadata: [String: String] // ✓ Dictionary of Codable
var related: RelatedItem? // ✓ Optional @Model relationship
// Computed properties are NOT persisted
var displayName: String {
name.uppercased()
}
init(name: String) {
self.name = name
self.count = 0
self.timestamp = Date()
self.data = Data()
self.tags = []
}
}
Model Attributes
@Attribute Macro
@Model class User { // Unique constraint (NOT compatible with iCloud sync) @Attribute(.unique) var email: String
// Spotlight indexing
@Attribute(.spotlight)
var name: String
// External storage for large data
@Attribute(.externalStorage)
var profileImage: Data?
// Encryption (device-only, not synced to iCloud)
@Attribute(.encrypt)
var sensitiveData: String?
// Preserve value when nil assigned
@Attribute(.preserveValueOnDeletion)
var archiveReason: String?
// Ephemeral (not persisted)
@Attribute(.ephemeral)
var temporaryState: String?
// Custom original name for migration
@Attribute(originalName: "userName")
var displayName: String
init(email: String, name: String) {
self.email = email
self.name = name
self.displayName = name
}
}
@Transient Macro
@Model class Document { var title: String var content: String
// Not persisted, recalculated
@Transient
var wordCount: Int = 0
init(title: String, content: String) {
self.title = title
self.content = content
self.wordCount = content.split(separator: " ").count
}
}
Relationships
One-to-Many Relationship
@Model class Folder { var name: String
// One folder has many notes
@Relationship(deleteRule: .cascade)
var notes: [Note] = []
init(name: String) {
self.name = name
}
}
@Model class Note { var title: String var content: String
// Many notes belong to one folder (inverse)
var folder: Folder?
init(title: String, content: String = "", folder: Folder? = nil) {
self.title = title
self.content = content
self.folder = folder
}
}
Many-to-Many Relationship
@Model class Note { var title: String
// Note can have many tags
@Relationship(inverse: \Tag.notes)
var tags: [Tag] = []
init(title: String) {
self.title = title
}
}
@Model class Tag { var name: String
// Tag can be on many notes
var notes: [Note] = []
init(name: String) {
self.name = name
}
}
Delete Rules
@Relationship(deleteRule: .cascade) // Delete related objects @Relationship(deleteRule: .nullify) // Set relationship to nil (default) @Relationship(deleteRule: .deny) // Prevent deletion if related exist @Relationship(deleteRule: .noAction) // Do nothing
iCloud-Compatible Relationships
Important: For iCloud sync, all relationships MUST be optional:
@Model class Note { var title: String
// REQUIRED for iCloud: Optional relationships
var folder: Folder?
var tags: [Tag]? // Optional array
init(title: String) {
self.title = title
}
}
ModelContainer Configuration
Basic Setup
@main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [Note.self, Folder.self, Tag.self]) } }
Custom Configuration
@main struct MyApp: App { let container: ModelContainer
init() {
let schema = Schema([Note.self, Folder.self, Tag.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to configure SwiftData: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Multiple Configurations
let userConfig = ModelConfiguration( "UserData", schema: Schema([User.self]), url: userDataURL )
let cacheConfig = ModelConfiguration( "Cache", schema: Schema([CachedItem.self]), isStoredInMemoryOnly: true )
let container = try ModelContainer( for: Schema([User.self, CachedItem.self]), configurations: userConfig, cacheConfig )
Native iCloud Sync
Enabling iCloud Sync (One Line!)
SwiftData includes native iCloud sync - no CloudKit code required:
@main struct MyApp: App { let container: ModelContainer
init() {
let schema = Schema([Note.self, Tag.self])
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .automatic // That's it!
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to configure SwiftData: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
cloudKitDatabase Options
// Automatic iCloud sync (recommended) cloudKitDatabase: .automatic
// Specific CloudKit container cloudKitDatabase: .private("iCloud.com.yourcompany.yourapp")
// No iCloud sync (local only) cloudKitDatabase: .none
Xcode Setup for iCloud
-
Select your target in Xcode
-
Go to "Signing & Capabilities"
-
Click "+ Capability"
-
Add "iCloud"
-
Check "CloudKit"
-
Select or create a CloudKit container
-
Add "Background Modes" capability
-
Check "Remote notifications"
iCloud-Compatible Model Requirements
Critical rules for iCloud sync:
@Model class Note { // ✓ Default values for non-optional properties var title: String = "" var content: String = "" var createdAt: Date = Date()
// ✓ Optional relationships
var folder: Folder?
var tags: [Tag]?
// ✗ NO unique constraints (not supported by CloudKit)
// @Attribute(.unique) var id: String // DON'T DO THIS
// ✗ NO deny delete rules
// @Relationship(deleteRule: .deny) // DON'T DO THIS
init(title: String = "", content: String = "") {
self.title = title
self.content = content
self.createdAt = Date()
}
}
Schema Migration for iCloud
After shipping to production:
// DO: // - Add new optional properties with defaults // - Add new optional relationships
// DON'T: // - Delete properties (data loss) // - Rename properties (treated as delete + add) // - Change property types // - Add required properties without defaults
Initialize CloudKit Schema
Before first production release:
#if DEBUG // Run once to create CloudKit schema try container.mainContext.initializeCloudKitSchema() #endif
@Query Macro
Basic Query
struct NotesListView: View { @Query var notes: [Note]
var body: some View {
List(notes) { note in
Text(note.title)
}
}
}
Sorted Query
// Single sort @Query(sort: \Note.createdAt, order: .reverse) var notes: [Note]
// Multiple sorts @Query(sort: [ SortDescriptor(\Note.isPinned, order: .reverse), SortDescriptor(\Note.createdAt, order: .reverse) ]) var notes: [Note]
Filtered Query
// Static predicate @Query(filter: #Predicate<Note> { note in note.isPinned == true }) var pinnedNotes: [Note]
// Complex predicate @Query(filter: #Predicate<Note> { note in note.title.contains("Swift") && !note.content.isEmpty }) var swiftNotes: [Note]
Dynamic Filtering
struct SearchableNotesView: View { @State private var searchText = ""
var body: some View {
FilteredNotesView(searchText: searchText)
.searchable(text: $searchText)
}
}
struct FilteredNotesView: View { @Query var notes: [Note]
init(searchText: String) {
let predicate = #Predicate<Note> { note in
searchText.isEmpty || note.title.localizedStandardContains(searchText)
}
_notes = Query(filter: predicate, sort: \.createdAt, order: .reverse)
}
var body: some View {
List(notes) { note in
Text(note.title)
}
}
}
Query with Limit
@Query(sort: \Note.createdAt, order: .reverse) var recentNotes: [Note]
// In view, limit manually List(recentNotes.prefix(10)) { note in Text(note.title) }
Query Animations
@Query(sort: \Note.title, animation: .default) var notes: [Note]
ModelContext Operations
Accessing Context
struct ContentView: View { @Environment(.modelContext) private var modelContext
// ...
}
Creating Objects
func createNote() { let note = Note(title: "New Note") modelContext.insert(note) // Auto-saved on SwiftUI lifecycle events }
Explicit Save
func saveChanges() { do { try modelContext.save() } catch { print("Save failed: (error)") } }
Deleting Objects
func deleteNote(_ note: Note) { modelContext.delete(note) }
func deleteNotes(at offsets: IndexSet) { for index in offsets { modelContext.delete(notes[index]) } }
Fetching with Descriptor
func fetchRecentNotes() throws -> [Note] { let descriptor = FetchDescriptor<Note>( predicate: #Predicate { $0.isPinned }, sortBy: [SortDescriptor(.createdAt, order: .reverse)] ) return try modelContext.fetch(descriptor) }
// With limit func fetchTopNotes(limit: Int) throws -> [Note] { var descriptor = FetchDescriptor<Note>( sortBy: [SortDescriptor(.createdAt, order: .reverse)] ) descriptor.fetchLimit = limit return try modelContext.fetch(descriptor) }
Batch Operations
// Delete all matching predicate try modelContext.delete(model: Note.self, where: #Predicate { note in note.createdAt < cutoffDate })
// Enumerate for batch processing let descriptor = FetchDescriptor<Note>() try modelContext.enumerate(descriptor) { note in note.processedAt = Date() }
#Predicate Macro
Basic Predicates
// Equality #Predicate<Note> { $0.isPinned == true }
// Comparison #Predicate<Note> { $0.createdAt > someDate }
// String contains #Predicate<Note> { $0.title.contains("Swift") }
// Case-insensitive contains #Predicate<Note> { $0.title.localizedStandardContains(searchText) }
Compound Predicates
// AND #Predicate<Note> { note in note.isPinned && note.title.contains("Important") }
// OR #Predicate<Note> { note in note.isPinned || note.folder?.name == "Favorites" }
// NOT #Predicate<Note> { note in !note.content.isEmpty }
Optional Handling
#Predicate<Note> { note in note.folder?.name == "Work" }
// Check for nil #Predicate<Note> { note in note.folder != nil }
Array Predicates
// Array contains #Predicate<Note> { note in note.tags?.contains(where: { $0.name == "Important" }) ?? false }
// Array is empty #Predicate<Note> { note in note.tags?.isEmpty ?? true }
Model Inheritance (iOS 26)
Base and Derived Models
@Model class MediaItem { var title: String var createdAt: Date
init(title: String) {
self.title = title
self.createdAt = Date()
}
}
@Model final class Photo: MediaItem { var imageData: Data? var resolution: String?
init(title: String, imageData: Data?) {
super.init(title: title)
self.imageData = imageData
}
}
@Model final class Video: MediaItem { var duration: TimeInterval var thumbnailData: Data?
init(title: String, duration: TimeInterval) {
super.init(title: title)
self.duration = duration
}
}
Polymorphic Queries
// Query all media items (photos and videos) @Query var allMedia: [MediaItem]
// Query only photos @Query var photos: [Photo]
Background Operations
Background Context
func importData() async { let container = modelContainer
await Task.detached {
let context = ModelContext(container)
// Perform operations
for item in largeDataSet {
let note = Note(title: item.title)
context.insert(note)
}
try? context.save()
}.value
}
Actor Isolation
@ModelActor actor DataImporter { func importNotes(from data: [ImportData]) throws { for item in data { let note = Note(title: item.title, content: item.content) modelContext.insert(note) } try modelContext.save() } }
// Usage let importer = DataImporter(modelContainer: container) try await importer.importNotes(from: importData)
Migration
Lightweight Migration (Automatic)
SwiftData handles lightweight migrations automatically:
-
Adding new properties with defaults
-
Removing properties
-
Adding optional relationships
Custom Migration
enum MySchemaV1: VersionedSchema { static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
class Note {
var title: String
var content: String
init(title: String, content: String) {
self.title = title
self.content = content
}
}
}
enum MySchemaV2: VersionedSchema { static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
class Note {
var title: String
var content: String
var createdAt: Date // New property
init(title: String, content: String) {
self.title = title
self.content = content
self.createdAt = Date()
}
}
}
enum MyMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [MySchemaV1.self, MySchemaV2.self] }
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: MySchemaV1.self,
toVersion: MySchemaV2.self
)
}
// Use in container let container = try ModelContainer( for: Note.self, migrationPlan: MyMigrationPlan.self )
Testing
In-Memory Testing
import Testing import SwiftData
@Test func testNoteCreation() throws { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: Note.self, configurations: config) let context = ModelContext(container)
let note = Note(title: "Test", content: "Content")
context.insert(note)
let descriptor = FetchDescriptor<Note>()
let notes = try context.fetch(descriptor)
#expect(notes.count == 1)
#expect(notes.first?.title == "Test")
}
SwiftUI Preview with Sample Data
@MainActor let previewContainer: ModelContainer = { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try! ModelContainer(for: Note.self, configurations: config)
// Insert sample data
let context = container.mainContext
let sampleNotes = [
Note(title: "First Note", content: "Content 1"),
Note(title: "Second Note", content: "Content 2")
]
sampleNotes.forEach { context.insert($0) }
return container
}()
#Preview { NotesListView() .modelContainer(previewContainer) }
Best Practices
- Design for iCloud from the Start
// GOOD: iCloud-compatible model @Model class Note { var title: String = "" var content: String = "" var folder: Folder? // Optional relationship var tags: [Tag]? // Optional array
init(title: String = "") {
self.title = title
}
}
// AVOID: iCloud-incompatible @Model class Note { @Attribute(.unique) var id: String // Not supported var folder: Folder // Non-optional relationship }
- Use @Query for Reactive Data
// GOOD: Reactive updates @Query(sort: \Note.createdAt) var notes: [Note]
// AVOID: Manual fetching in views @State private var notes: [Note] = [] func loadNotes() { notes = try? context.fetch(FetchDescriptor<Note>()) }
- Explicit Save for Critical Data
func saveImportantChange() { modelContext.insert(criticalData) do { try modelContext.save() } catch { // Handle error appropriately } }
- Use Background Contexts for Heavy Work
func importLargeDataset() async { await Task.detached { let context = ModelContext(container) // Heavy operations try? context.save() }.value }
Official Resources
-
SwiftData Documentation
-
Syncing model data across devices
-
WWDC23: Meet SwiftData
-
WWDC23: Model your schema with SwiftData
-
WWDC24: What's new in SwiftData