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:
@Modelworks 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.@Queryreads 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).
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")) }
}
}
}Hint: The user taps Add. The note is inserted into the store. Which variable does the List show, and when was it last filled?
The view fetches the notes once, in onAppear, and copies them into @State. Inserting a note changes the store, not that array, so the new note doesn’t appear until the screen is opened again. Deletes from another screen leave stale rows behind, too.
@Query is a live view of the store: it re-fetches when matching data changes.
@Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]Manual fetch is for one-off reads (counting, exporting, background work), not for driving a list.
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.
@Modelclasses (Observation-tracked,PersistentModel),ModelContainer(schema + store, SQLite),ModelContext(unit of work, main context on the main actor, autosave on).@Queryis live: re-fetches on relevant saves; don’t copy fetch results into@Statefor lists. UseFetchDescriptor+context.fetchfor one-off reads.@Bindablefor editing models; in-memoryModelConfigurationfor previews and tests.@Transientfor non-persisted properties;Codablevalue types become composite attributes.
Check this part
What does @Query do in a SwiftUI view?
After context.insert(note), do you have to save manually?
Which identifier is safe to pass between actors?