Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result
  1. Define model schema
  2. Configure model container
  3. Query and mutate through context
  4. Test persistence and migration
02 / ArchitectureResponsibility boundaries
  1. Boundary 1Model container
  2. Boundary 2Model context
  3. Boundary 3Observable queries

Connected responsibilities, not a required class hierarchy or an execution trace.

@Model Macro and Schema Definition

import SwiftData

@Model
class Task {
    var id: UUID
    var title: String
    var notes: String
    var isCompleted: Bool
    var priority: Int
    var createdAt: Date
    var dueDate: Date?

    // Relationship
    var category: Category?
    var tags: [Tag]

    // Transient (not persisted)
    @Transient var isSelected = false

    // Unique constraint
    #Unique<Task>([\.id])

    init(title: String, priority: Int = 0) {
        self.id = UUID()
        self.title = title
        self.notes = ""
        self.isCompleted = false
        self.priority = priority
        self.createdAt = Date()
        self.tags = []
    }
}

@Model
class Category {
    var id: UUID
    var name: String
    var color: String

    // Inverse relationship with cascade delete
    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task]

    init(name: String, color: String = "blue") {
        self.id = UUID()
        self.name = name
        self.color = color
        self.tasks = []
    }
}

@Model
class Tag {
    var id: UUID
    var name: String

    @Relationship(inverse: \Task.tags)
    var tasks: [Task]

    init(name: String) {
        self.id = UUID()
        self.name = name
        self.tasks = []
    }
}

ModelContainer and ModelContext

// App setup
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Category.self, Tag.self])
    }
}

// Custom configuration
@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Task.self, Category.self, Tag.self])
        let config = ModelConfiguration(
            "MyApp",
            schema: schema,
            isStoredInMemoryOnly: false,
            allowsSave: true,
            groupContainer: .identifier("group.com.myapp.shared")
        )
        do {
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Failed to create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

// Using ModelContext directly
struct TaskListView: View {
    @Environment(\.modelContext) private var modelContext

    func addTask(title: String) {
        let task = Task(title: title)
        modelContext.insert(task)
        // SwiftData auto-saves; explicit save if needed:
        // try? modelContext.save()
    }

    func deleteTask(_ task: Task) {
        modelContext.delete(task)
    }
}

@Query for Fetching

struct TaskListView: View {
    // Basic query with sort
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]

    // Filtered and sorted query
    @Query(
        filter: #Predicate<Task> { !$0.isCompleted },
        sort: [
            SortDescriptor(\Task.priority, order: .reverse),
            SortDescriptor(\Task.createdAt, order: .reverse),
        ],
        animation: .default
    )
    private var pendingTasks: [Task]

    var body: some View {
        List(pendingTasks) { task in
            TaskRow(task: task)
        }
    }
}

// Dynamic query with init parameter
struct FilteredTaskList: View {
    @Query private var tasks: [Task]

    init(showCompleted: Bool, searchText: String) {
        let filter = #Predicate<Task> { task in
            (showCompleted || !task.isCompleted) &&
            (searchText.isEmpty || task.title.localizedStandardContains(searchText))
        }
        _tasks = Query(
            filter: filter,
            sort: \Task.createdAt,
            order: .reverse
        )
    }

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

#Predicate Macro for Type-Safe Queries

// Simple predicate
let highPriority = #Predicate<Task> { $0.priority >= 2 }

// Compound predicate
let urgentIncomplete = #Predicate<Task> { task in
    !task.isCompleted && task.priority >= 2
}

// String search
let searchPredicate = #Predicate<Task> { task in
    task.title.localizedStandardContains("meeting")
}

// Date-based predicate
let today = Calendar.current.startOfDay(for: Date())
let dueTodayPredicate = #Predicate<Task> { task in
    if let dueDate = task.dueDate {
        return dueDate >= today
    }
    return false
}

// Using predicates with FetchDescriptor
func fetchOverdueTasks(context: ModelContext) throws -> [Task] {
    let now = Date()
    let descriptor = FetchDescriptor<Task>(
        predicate: #Predicate { task in
            !task.isCompleted && task.dueDate != nil && task.dueDate! < now
        },
        sortBy: [SortDescriptor(\.dueDate)]
    )
    return try context.fetch(descriptor)
}

// Fetch with limit
func fetchRecentTasks(context: ModelContext, limit: Int = 10) throws -> [Task] {
    var descriptor = FetchDescriptor<Task>(
        sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
    )
    descriptor.fetchLimit = limit
    return try context.fetch(descriptor)
}

// Count
func countIncompleteTasks(context: ModelContext) throws -> Int {
    let descriptor = FetchDescriptor<Task>(
        predicate: #Predicate { !$0.isCompleted }
    )
    return try context.fetchCount(descriptor)
}

SortDescriptor

Bool is not Comparable, so you cannot sort by one

This is the first thing to know, because isPinned / isCompleted / isFavourite is the most common sort key there is, and the compiler's diagnostic sends you somewhere else entirely:

// WRONG — does not compile.
@Query(sort: [SortDescriptor(\Note.isPinned, order: .reverse)])
private var notes: [Note]

// error: initializer 'init(_:order:)' requires that 'Note'
//        inherit from 'NSObject'

SortDescriptor.init(_:order:) needs Value: Comparable. Swift's Bool is not Comparable, so overload resolution falls through to the NSObject key-path overload and reports that failure — pointing at a Core Data problem you do not have. Nothing in the message mentions Bool.

Two fixes, and the second is usually the right one:

// 1. Sort in Swift after fetching. Fine for a screen's worth of rows.
@Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]

var ordered: [Note] {
    notes.sorted { ($0.isPinned ? 0 : 1) < ($1.isPinned ? 0 : 1) }
}

// 2. Store an Int rank alongside the Bool. This sorts in the store, so it
//    still works with pagination and with a fetch limit — which sorting in
//    Swift does not, because the limit is applied before your sort runs.
@Model
final class Note {
    var isPinned: Bool = false
    /// Kept in sync with `isPinned`; 0 sorts before 1.
    private(set) var pinRank: Int = 1

    func setPinned(_ pinned: Bool) {
        isPinned = pinned
        pinRank = pinned ? 0 : 1
    }
}

@Query(sort: [SortDescriptor(\Note.pinRank), SortDescriptor(\Note.updatedAt, order: .reverse)])
private var notes: [Note]

The same applies to any non-Comparable property: an enum without a Comparable conformance, or a custom struct.

The rest

// Single sort
@Query(sort: \Task.title) private var tasks: [Task]

// Multiple sorts — note the Int rank, not the Bool, for the first key.
@Query(sort: [
    SortDescriptor(\Task.completionRank),
    SortDescriptor(\Task.priority, order: .reverse),
    SortDescriptor(\Task.createdAt, order: .reverse),
])
private var tasks: [Task]

// Dynamic sorting
struct SortableTaskList: View {
    @State private var sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]

    var body: some View {
        TaskListContent(sort: sortOrder)
            .toolbar {
                Menu("Sort") {
                    Button("By Date") {
                        sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]
                    }
                    Button("By Priority") {
                        sortOrder = [SortDescriptor(\Task.priority, order: .reverse)]
                    }
                    Button("By Title") {
                        sortOrder = [SortDescriptor(\Task.title)]
                    }
                }
            }
    }
}

struct TaskListContent: View {
    @Query private var tasks: [Task]

    init(sort: [SortDescriptor<Task>]) {
        _tasks = Query(sort: sort)
    }

    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
    }
}

Relationships and Cascade Rules

@Model
class Project {
    var name: String

    // Delete rule options: .cascade, .nullify, .deny, .noAction
    @Relationship(deleteRule: .cascade, inverse: \Milestone.project)
    var milestones: [Milestone]

    @Relationship(deleteRule: .nullify, inverse: \TeamMember.projects)
    var members: [TeamMember]

    init(name: String) {
        self.name = name
        self.milestones = []
        self.members = []
    }
}

@Model
class Milestone {
    var title: String
    var project: Project?

    init(title: String) {
        self.title = title
    }
}

@Model
class TeamMember {
    var name: String
    var projects: [Project] // Many-to-many

    init(name: String) {
        self.name = name
        self.projects = []
    }
}

// Working with relationships
func addMilestone(to project: Project, title: String, context: ModelContext) {
    let milestone = Milestone(title: title)
    milestone.project = project // Automatically updates project.milestones
}

Migration with VersionedSchema

// Version 1
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [TaskV1.self] }

    @Model
    class TaskV1 {
        var id: UUID
        var title: String
        var isCompleted: Bool
        init(title: String) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
        }
    }
}

// Version 2 — adds priority field
enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [TaskV2.self] }

    @Model
    class TaskV2 {
        var id: UUID
        var title: String
        var isCompleted: Bool
        var priority: Int // New field
        init(title: String, priority: Int = 0) {
            self.id = UUID()
            self.title = title
            self.isCompleted = false
            self.priority = priority
        }
    }
}

// Migration plan
enum TaskMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

// Apply migration
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: SchemaV2.TaskV2.self, migrationPlan: TaskMigrationPlan.self)
    }
}

SwiftData with CloudKit

// CloudKit-enabled container
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Task.self, Category.self]) {
            // Container is configured — CloudKit syncs automatically
            // when the app's CloudKit container is set in entitlements
        }
    }
}

// Requirements for CloudKit compatibility:
// 1. All properties must have default values or be optional
// 2. No unique constraints (CloudKit doesn't support them)
// 3. All relationships must be optional
// 4. Enable "CloudKit" capability in Xcode
// 5. Set the CloudKit container identifier in entitlements

@Model
class CloudTask {
    var id: UUID = UUID()
    var title: String = ""
    var isCompleted: Bool = false
    var priority: Int = 0
    var createdAt: Date = Date()

    // Optional relationship for CloudKit
    var category: CloudCategory?

    init(title: String) {
        self.title = title
    }
}

iOS 18+ Additions

History API

The SwiftData History API enables tracking time-based model changes (inserts, updates, deletes) for server sync, auditing, or undo support. It uses HistoryDescriptor and the ModelContext history-fetching methods to retrieve changes since a given point in time.

import SwiftData

// Fetching model history for sync
func fetchChangesSinceLastSync(context: ModelContext, lastToken: DefaultHistoryToken?) async throws -> DefaultHistoryToken? {
    // Build a descriptor starting from the last known sync point
    var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
    if let lastToken {
        descriptor.predicate = #Predicate { transaction in
            transaction.token > lastToken
        }
    }

    // Fetch transactions from history
    let transactions = try context.fetchHistory(descriptor)

    for transaction in transactions {
        // Each transaction contains changes grouped atomically
        for change in transaction.changes {
            switch change {
            case let change as DefaultHistoryInsert<Task>:
                let modelID = change.persistentIdentifier
                print("Inserted Task: \(modelID)")
                // Sync insert to server

            case let change as DefaultHistoryUpdate<Task>:
                let modelID = change.persistentIdentifier
                let updatedProperties = change.updatedProperties
                print("Updated Task: \(modelID), fields: \(updatedProperties)")
                // Sync update to server

            case let change as DefaultHistoryDelete<Task>:
                let modelID = change.persistentIdentifier
                print("Deleted Task: \(modelID)")
                // Sync delete to server

            default:
                break
            }
        }
    }

    // Return the latest token to persist for next sync
    return transactions.last?.token
}

// Persisting the sync token
class SyncManager {
    private let tokenKey = "lastHistorySyncToken"

    func saveToken(_ token: DefaultHistoryToken) {
        let data = try? JSONEncoder().encode(token)
        UserDefaults.standard.set(data, forKey: tokenKey)
    }

    func loadToken() -> DefaultHistoryToken? {
        guard let data = UserDefaults.standard.data(forKey: tokenKey) else { return nil }
        return try? JSONDecoder().decode(DefaultHistoryToken.self, from: data)
    }

    func performSync(context: ModelContext) async throws {
        let lastToken = loadToken()
        if let newToken = try await fetchChangesSinceLastSync(context: context, lastToken: lastToken) {
            saveToken(newToken)
        }
    }
}

@Index Macro

The #Index macro defines database indexes on model properties, improving query performance for frequently searched or sorted fields.

import SwiftData

@Model
class Task {
    var id: UUID
    var title: String
    var isCompleted: Bool
    var priority: Int
    var createdAt: Date
    var dueDate: Date?
    var category: String

    // Single-property index for fast lookups by title
    #Index<Task>([\.title])

    // Compound index for queries that filter by completion status and sort by priority
    #Index<Task>([\.isCompleted, \.priority])

    // Index on createdAt for time-based sorting queries
    #Index<Task>([\.createdAt])

    // Compound index for category-based filtered and sorted queries
    #Index<Task>([\.category, \.dueDate])

    init(title: String, priority: Int = 0, category: String = "general") {
        self.id = UUID()
        self.title = title
        self.isCompleted = false
        self.priority = priority
        self.createdAt = Date()
        self.category = category
    }
}

// The indexes above optimize queries like:
// @Query(filter: #Predicate<Task> { !$0.isCompleted }, sort: \.priority)
// @Query(filter: #Predicate<Task> { $0.category == "work" }, sort: \.dueDate)

Custom Data Stores

The DataStore protocol allows SwiftData to use custom storage backends beyond the default SQLite/CoreData store. You can back SwiftData models with JSON files, remote APIs, or any custom persistence layer.

import SwiftData

// A custom data store backed by JSON files
actor JSONDataStore: DataStore {
    typealias Snapshot = DefaultSnapshot

    let configuration: DataStoreConfiguration
    let fileURL: URL

    init(_ configuration: DataStoreConfiguration, fileURL: URL) {
        self.configuration = configuration
        self.fileURL = fileURL
    }

    // Fetch models from the custom store
    func fetch<T: PersistentModel>(_ descriptor: FetchDescriptor<T>) throws -> [T] {
        // Read from your custom backend (JSON file, API, etc.)
        let data = try Data(contentsOf: fileURL)
        let snapshots = try JSONDecoder().decode([DefaultSnapshot].self, from: data)
        // Convert snapshots back to models
        // Implementation depends on your storage format
        return []
    }

    // Save changes to the custom store
    func save(_ insert: [DefaultSnapshot], _ update: [DefaultSnapshot], _ delete: [PersistentIdentifier]) throws {
        // Persist inserts, updates, and deletes to your custom backend
        var existing = loadExistingSnapshots()

        // Apply inserts
        existing.append(contentsOf: insert)

        // Apply updates
        for updated in update {
            if let index = existing.firstIndex(where: { $0.persistentIdentifier == updated.persistentIdentifier }) {
                existing[index] = updated
            }
        }

        // Apply deletes
        existing.removeAll { snapshot in
            delete.contains(snapshot.persistentIdentifier)
        }

        // Write back to storage
        let data = try JSONEncoder().encode(existing)
        try data.write(to: fileURL)
    }

    private func loadExistingSnapshots() -> [DefaultSnapshot] {
        guard let data = try? Data(contentsOf: fileURL) else { return [] }
        return (try? JSONDecoder().decode([DefaultSnapshot].self, from: data)) ?? []
    }
}

// Custom configuration for the data store
struct JSONStoreConfiguration: DataStoreConfiguration {
    var name: String
    var schema: Schema?
    var fileURL: URL

    init(name: String, schema: Schema? = nil, fileURL: URL) {
        self.name = name
        self.schema = schema
        self.fileURL = fileURL
    }
}

// Using the custom data store with ModelContainer
@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        let schema = Schema([Task.self, Category.self])
        let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let storeURL = documentsURL.appendingPathComponent("data.json")

        let config = JSONStoreConfiguration(
            name: "JSONStore",
            schema: schema,
            fileURL: storeURL
        )

        do {
            container = try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Failed to create ModelContainer with custom store: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}