swiftdata-persistence

SKILL.md

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

  1. Select your target in Xcode
  2. Go to "Signing & Capabilities"
  3. Click "+ Capability"
  4. Add "iCloud"
  5. Check "CloudKit"
  6. Select or create a CloudKit container
  7. Add "Background Modes" capability
  8. 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

1. 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
}

2. 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>())
}

3. Explicit Save for Critical Data

func saveImportantChange() {
    modelContext.insert(criticalData)
    do {
        try modelContext.save()
    } catch {
        // Handle error appropriately
    }
}

4. Use Background Contexts for Heavy Work

func importLargeDataset() async {
    await Task.detached {
        let context = ModelContext(container)
        // Heavy operations
        try? context.save()
    }.value
}

Official Resources

Weekly Installs
8
GitHub Stars
1
First Seen
Jan 26, 2026
Installed on
opencode8
claude-code7
codex7
gemini-cli7
continue6
roo6