Skip to content
Commitwise
Menu
iOS Development

Persistence with SwiftData

Save data on the device with SwiftData, from @Model classes and @Query in SwiftUI to filters, sorting, relationships and delete rules, plus the production parts: background imports with @ModelActor, unique attributes and schema migrations.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A notes app keeps everything in an array. It works beautifully until the user swipes the app away, and every note is gone. Data that must survive restarts needs to be stored on disk, queried efficiently, and updated without the UI falling out of sync. SwiftData (iOS 17+) does all three with a few annotations, as long as you know what it does behind them.

New to some of these? Open what you need, or skip ahead.

Basics:How do SwiftUI views work?

In SwiftUI, a screen is built from views: small structs that conform to View and describe what to show in a body property:

struct Greeting: View {
    let name: String
    var body: some View {
        Text("Hello, \(name)!")
    }
}

You don’t change views after they’re shown. When the data they depend on changes, SwiftUI calls body again and updates the screen to match. Views are combined by nesting (VStack, List) and adjusted with modifiers like .padding() and .font(.title).

Basics:What are @State and the other @ attributes?

Words like @State, @Binding and @Observable are Swift attributes; most of the ones in SwiftUI are property wrappers. A property wrapper adds behavior to a property: @State var count = 0 still looks like a normal variable, but SwiftUI stores its value outside the view and redraws the view when it changes.

The $ prefix ($count) gives a binding: a two-way connection that lets another view, like a Toggle or TextField, read and change the value.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Part Not started

Models, the container and @Query

Think of a filing cabinet. You decide what a form looks like (a note has a title, a body and a date), the cabinet stores the filled-in forms, and a clerk fetches the ones you ask for. You never deal with drawers and folders yourself.

In SwiftData, the form is a class marked @Model, the cabinet is the ModelContainer (the database file on disk), and the clerk is the ModelContext, which inserts, deletes and saves. In SwiftUI, @Query asks for the forms you want and keeps the list on screen up to date.

SwiftData is a Swift-native layer over the Core Data stack (SQLite by default). The @Model macro turns stored properties into persisted attributes and relationships with change tracking via Observation; a ModelContainer owns the schema and store; ModelContexts are units of work bound to an actor (the main context to the main actor). @Query is a SwiftUI property wrapper that runs a fetch on the environment’s context and re-runs it when relevant changes are saved. Autosave on the main context is on by default.

import SwiftData
import SwiftUI

@Model
final class Note {
    var title: String
    var body: String
    var createdAt: Date

    init(title: String, body: String = "", createdAt: Date = .now) {
        self.title = title
        self.body = body
        self.createdAt = createdAt
    }
}

@main
struct NotesApp: App {
    var body: some Scene {
        WindowGroup { NotesView() }
            .modelContainer(for: Note.self)          // creates (or opens) the store on disk
    }
}

struct NotesView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    Text(note.title)
                }
                .onDelete { offsets in
                    for index in offsets { context.delete(notes[index]) }
                }
            }
            .toolbar {
                Button("Add") { context.insert(Note(title: "New note")) }
            }
            .navigationTitle("Notes")
        }
    }
}

That’s everything: no SQL, no save button. A few things to notice:

  • @Model works on classes. Every stored property is saved; the class gets an identity and change tracking automatically.
  • .modelContainer(for: Note.self) creates the database the first time and opens it after that, and puts a context into the environment.
  • @Query reads the notes, sorted newest first, and updates the list by itself whenever notes are inserted, changed or deleted, anywhere in the app.
  • Changes are saved automatically a moment later. You can call try context.save() when you need it on disk right now (before sharing a file, for example).
Spot the bugNew notes don't appear in the list

Tap the line with the bug.

struct NotesView: View {
    @Environment(\.modelContext) private var context
    @State private var notes: [Note] = []

    var body: some View {
        List(notes) { Text($0.title) }
            .onAppear {
                notes = (try? context.fetch(FetchDescriptor<Note>())) ?? []
            }
            .toolbar {
                Button("Add") { context.insert(Note(title: "New note")) }
            }
    }
}

Editing a model

Model objects are observable, so a detail screen can bind directly to their properties with @Bindable; edits are saved automatically:

struct NoteEditor: View {
    @Bindable var note: Note

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
            TextEditor(text: $note.body)
                .frame(minHeight: 200)
        }
        .navigationTitle(note.title)
    }
}

// In the list:
NavigationLink(note.title) { NoteEditor(note: note) }

There’s no “save” step and no copying back: the editor changes the same Note the list shows, and both update together.

Previews with sample data

#Preview {
    let container = try! ModelContainer(for: Note.self,
                                        configurations: ModelConfiguration(isStoredInMemoryOnly: true))
    container.mainContext.insert(Note(title: "Groceries", body: "Milk, eggs"))
    container.mainContext.insert(Note(title: "Ideas"))
    return NotesView().modelContainer(container)
}

An in-memory container never touches the disk, so previews (and unit tests) start empty every time.

What the macro does

@Model adds PersistentModel conformance, a backing data store per instance, and Observation tracking for every stored property; properties become persisted attributes unless marked @Transient. Value types stored in properties must be Codable (stored as composite attributes, queryable only in limited ways), enums need raw values or Codable, and relationships are inferred from properties whose type is another model (or an array of them). Identity is persistentModelID (a PersistentIdentifier), which is Sendable and the thing to pass across actors.

  • @Model classes (Observation-tracked, PersistentModel), ModelContainer (schema + store, SQLite), ModelContext (unit of work, main context on the main actor, autosave on).
  • @Query is live: re-fetches on relevant saves; don’t copy fetch results into @State for lists. Use FetchDescriptor + context.fetch for one-off reads.
  • @Bindable for editing models; in-memory ModelConfiguration for previews and tests.
  • @Transient for non-persisted properties; Codable value types become composite attributes.

Check this part

  1. What does @Query do in a SwiftUI view?

  2. After context.insert(note), do you have to save manually?

  3. Which identifier is safe to pass between actors?

Mark a class with @Model to store it, attach a ModelContainer to the app, insert and delete through the ModelContext, and read with @Query, which keeps the view in sync with the store automatically. Changes are saved for you.

Part Not started

Filtering, sorting and relationships

@Model
final class Folder {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Note.folder)
    var notes: [Note] = []

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

@Model
final class Note {
    var title: String
    var createdAt: Date
    var isPinned: Bool = false
    var folder: Folder?

    init(title: String, folder: Folder? = nil) {
        self.title = title
        self.createdAt = .now
        self.folder = folder
    }
}

struct NoteList: View {
    @Query private var notes: [Note]

    init(search: String) {
        let filter: Predicate<Note>? = search.isEmpty
            ? nil
            : #Predicate { $0.title.localizedStandardContains(search) }
        _notes = Query(filter: filter,
                       sort: [SortDescriptor(\Note.isPinned, order: .reverse),
                              SortDescriptor(\Note.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { Text($0.title) }
    }
}

// Parent view: NoteList(search: searchText) with .searchable(text: $searchText)
  • #Predicate is a filter written in Swift; SwiftData turns it into a database query, so filtering happens in the database, not in memory. Only some operations are supported inside it (comparisons, contains, localizedStandardContains, &&, ||…); the compiler or the store tells you when something isn’t.
  • Dynamic queries: a view can’t change its @Query later, so it builds it in init from its parameters. When the search text changes, SwiftUI creates a new NoteList with a new query.
  • Relationships: a note may belong to a folder, a folder has many notes. inverse: tells SwiftData the two properties are the same link seen from both sides, so setting note.folder = work also puts the note in work.notes.

What happens on delete

The delete rule on a relationship decides what deleting a model does to the models it points to:

RuleDeleting the folder…
.nullify (the default)Keeps its notes; their folder becomes nil
.cascadeDeletes its notes too
.denyRefuses while it still has notes
.noActionLeaves the notes pointing at a deleted folder (avoid)

Predict the output

A folder has 3 notes, and the relationship has no deleteRule. The user deletes the folder. How many notes are left, and what is their folder?

Exerciseeasy

Pinned notes first, with a filter

Show only the notes of one folder, pinned ones first, then newest first, with an optional “only pinned” toggle. Write the view’s initializer that builds the @Query.

›

The predicate compares the note’s folder with the given folder. #Predicate can compare persistent identifiers: capture folder.persistentModelID outside the predicate.

SolutionTry it yourself first
struct FolderNotes: View {
    @Query private var notes: [Note]

    init(folder: Folder, onlyPinned: Bool) {
        let folderID = folder.persistentModelID
        let predicate = #Predicate<Note> { note in
            note.folder?.persistentModelID == folderID && (!onlyPinned || note.isPinned)
        }
        _notes = Query(filter: predicate,
                       sort: [SortDescriptor(\Note.isPinned, order: .reverse),
                              SortDescriptor(\Note.createdAt, order: .reverse)])
    }

    var body: some View {
        List(notes) { Text($0.title) }
    }
}

Values from outside (the folder id, the flag) are captured as constants before the predicate. Filtering and sorting run in the database, so the view only receives the notes it shows. A simpler alternative for small folders is folder.notes.sorted(…), which loads the relationship into memory.

Query performance

  • @Query fetches all matching rows on each relevant change. For large tables, narrow with predicates and fetchLimit (via FetchDescriptor in Query(descriptor)), and avoid sorting by computed or composite attributes, which may not be pushed down to SQL.
  • To-many relationships load lazily as faults; iterating folder.notes.count for every row in a list triggers a fetch per row (an N+1 in disguise). Store counts you need to display, or fetch them with a count query (context.fetchCount).
  • Predicates are translated to SQL; unsupported expressions fail at runtime with an “unsupported predicate” error, so cover every dynamic predicate with a test against an in-memory container.
  • #Predicate compiles to the store’s query language: supported operators only; capture outside values as constants; compare relationships via persistentModelID.
  • Dynamic @Query: build Query(filter:sort:) in the view’s init; parent changes recreate it.
  • Relationships: inverse: for bidirectional links; delete rules .nullify (default), .cascade, .deny, .noAction.
  • Performance: predicates + fetchLimit in FetchDescriptor, fetchCount for counts, beware per-row relationship faults.

Check this part

  1. Where does a #Predicate filter run?

  2. What is the default delete rule for a SwiftData relationship?

  3. A list row shows folder.notes.count for every folder. What's the risk with many folders?

@Query takes a #Predicate filter and sort descriptors; a view's initializer can build the query from parameters like a search text. Relationships connect models; the delete rule decides what happens to related models: nullify (the default) leaves them, cascade deletes them.

Part Not started

SwiftData in production

Three things matter once real users have real data: importing lots of data without freezing the screen, avoiding duplicates when the same data arrives twice, and changing your models in an update without losing what users already saved.

The production concerns are concurrency (contexts and models are actor-bound and not Sendable), identity (uniqueness and upserts during sync), schema evolution (versioned schemas and migration stages, including what lightweight migration can’t do), and the CloudKit sync constraints if you enable it (no unique constraints, all properties optional or defaulted, relationships optional).

@ModelActor
actor NoteImporter {
    func importNotes(_ items: [NoteDTO]) throws -> Int {
        for item in items {
            modelContext.insert(Note(title: item.title))     // this actor's own context
        }
        try modelContext.save()
        return items.count
    }
}

// From a view or a view model:
let importer = NoteImporter(modelContainer: container)
let count = try await importer.importNotes(downloaded)        // the main thread stays free

Inserting thousands of notes on the main context would freeze scrolling. A @ModelActor gets its own context that runs off the main thread; it saves, and @Query lists in the UI update by themselves. The important rule: model objects belong to the context that loaded them. Don’t hand a Note from the importer to the UI (or the other way round); pass its persistentModelID and fetch it again on the other side.

Production puzzles

Predict the output

Tag has @Attribute(.unique) var name: String. The app inserts Tag(name: "swift") twice (two syncs of the same data) and saves. How many tags named swift are in the store?

Spot the bugCrashes during background sync

Tap the line with the bug.

@MainActor
final class SyncController {
    let container: ModelContainer
    init(container: ModelContainer) { self.container = container }

    func syncNotes() async throws {
        let notes = try container.mainContext.fetch(FetchDescriptor<Note>())
        try await Task.detached {
            for note in notes {
                note.isSynced = try await api.upload(note.title)
            }
        }.value
    }
}

Exercisemedium

A schema change for version 2

Version 1 shipped with Note { title, body, createdAt }. Version 2 adds isPinned: Bool and tags: [Tag], and renames body to content. Users must keep their notes. Describe the versioned schemas and the migration plan, and say which steps are lightweight and which need custom code.

›

Adding a property with a default and adding a relationship are lightweight. Renaming a stored property needs SwiftData to know the old name.

SolutionTry it yourself first
enum SchemaV1: VersionedSchema {
    static let versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Note.self] }

    @Model final class Note {
        var title: String
        var body: String
        var createdAt: Date
        init(title: String, body: String, createdAt: Date) {
            self.title = title; self.body = body; self.createdAt = createdAt
        }
    }
}

enum SchemaV2: VersionedSchema {
    static let versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Note.self, Tag.self] }

    @Model final class Note {
        var title: String
        @Attribute(originalName: "body") var content: String     // renamed, data kept
        var createdAt: Date
        var isPinned: Bool = false                                // new, with a default
        var tags: [Tag] = []                                      // new relationship
        init(title: String, content: String, createdAt: Date) {
            self.title = title; self.content = content; self.createdAt = createdAt
        }
    }

    @Model final class Tag {
        @Attribute(.unique) var name: String
        init(name: String) { self.name = name }
    }
}

enum NotesMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
    static var stages: [MigrationStage] {
        [.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
    }
}

// App: .modelContainer(try! ModelContainer(for: SchemaV2.Note.self, SchemaV2.Tag.self,
//                                          migrationPlan: NotesMigrationPlan.self))
  • Lightweight: new property with a default, new model, new optional or to-many relationship, a rename via @Attribute(originalName:).
  • Custom (.custom(fromVersion:toVersion:willMigrate:didMigrate:)): transforming data, like splitting title into title and subtitle, deduplicating before adding a unique constraint, or computing new values from old ones. Do it in didMigrate, on the new schema’s context.
  • Process: keep every shipped schema version in the code forever; test migrations by opening a store created with the previous app version (keep fixture stores in the test bundle); ship V1 with a VersionedSchema from day one, so the first migration isn’t an emergency.
  • Contexts and models are actor-bound and non-Sendable: pass PersistentIdentifiers; do imports and syncs in a @ModelActor (self[id, as:], fetchIdentifiers).
  • @Attribute(.unique) makes duplicate inserts upserts (not with CloudKit); @Attribute(originalName:) for renames.
  • Ship VersionedSchema from day one; SchemaMigrationPlan with lightweight stages for additive changes and renames, custom stages for transformations; test against fixture stores from old versions.
  • CloudKit sync: no unique constraints, optional/defaulted properties and relationships.

Check this part

  1. Why import thousands of notes in a @ModelActor instead of the main context?

  2. How do you hand a note from a background actor to the UI?

  3. Version 2 renames the stored property body to content. How do you keep users' data?

Models belong to the context (and actor) that loaded them: pass PersistentIdentifiers across threads and do heavy work in a @ModelActor. @Attribute(.unique) turns duplicate inserts into updates. Changing the schema needs a VersionedSchema and a migration plan before you ship.

Key takeaways

  • @Model classes are saved automatically in the ModelContainer; insert and delete through the ModelContext.
  • Use @Query for lists: it stays in sync with the store; build it in init when it depends on parameters.
  • Relationships need a delete rule decision: the default .nullify keeps related models, .cascade deletes them.
  • Do heavy work in a @ModelActor and pass identifiers, not model objects, between threads.
  • SwiftData sits on Core Data/SQLite: predicates compile to queries, relationships fault lazily, autosave on the main context.
  • Actor-bound, non-Sendable models: PersistentIdentifier across boundaries, @ModelActor for background work.
  • Unique attributes upsert; versioned schemas and migration plans from the first release; test migrations with old stores.
  • Know CloudKit’s modeling constraints before enabling sync.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.