SwiftData tutorial: Swift & iOS best practices explained
SwiftData is Apple's modern, Swift-native persistence framework that replaces Core Data for most new projects. If you've spent weeks wrestling with NSFetchRequest boilerplate and merge conflicts in your data model, SwiftData offers a cleaner, type-safe alternative that integrates seamlessly with SwiftUI. In this SwiftData tutorial, we'll walk through the core patterns you need to build production apps that persist data reliably without the complexity.
Why SwiftData matters for modern iOS development
Core Data has served us well, but it carries decades of legacy APIs, threading rules, and footguns that make it a heavy lift for teams moving fast. SwiftData was built for the SwiftUI era—your models are just Swift classes with a @Model macro, queries automatically update your views, and the framework handles serialization to disk without you managing NSManagedObject hierarchies.
We've used SwiftData in production e-commerce and SaaS apps where we needed to sync local caches with APIs, handle offline workflows, and keep the UI responsive. The framework shines when you care about clean code and developer velocity.
Define a model with @Model
Every SwiftData model starts as a regular Swift class decorated with the @Model macro. This single annotation turns your class into a persisted entity with automatic change tracking and serialization.
import SwiftData
@Model
final class Note {
var title: String
var createdAt: Date
var content: String = ""
var tags: [String] = []
var isPinned: Bool = false
init(title: String) {
self.title = title
self.createdAt = .now
}
}
The @Model macro inspects your class and generates the boilerplate for persistence. SwiftData automatically tracks all properties that conform to Codable and standard types like String, Date, Int, Bool, and collections of these types.
Modeling relationships
When your domain requires relationships between models, use @Relationship to define one-to-one, one-to-many, and many-to-many connections.
@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 folder: Folder?
init(title: String, folder: Folder? = nil) {
self.title = title
self.createdAt = .now
self.folder = folder
}
}
The deleteRule: .cascade ensures that when a folder is deleted, all notes in it are removed as well. The inverse parameter lets SwiftData maintain bidirectional consistency automatically, so you don't manually sync both sides of the relationship.
Exclude properties from persistence
Not every property needs to be saved. Use @Transient for computed values, UI state, or data you derive at runtime.
@Model
final class Note {
var title: String
var content: String
@Transient
var wordCount: Int {
content.split(separator: " ").count
}
@Transient
var isEditing: Bool = false
init(title: String, content: String) {
self.title = title
self.content = content
}
}
This pattern keeps your model clean—persistence logic stays in SwiftData, UI state stays in your views, and computed properties remain read-only and derived.
Container: Set up your data store
Every SwiftData app needs a ModelContainer. This object manages the connection to your on-disk database and creates the context where you perform queries and mutations. Initialize it once in your App struct.
import SwiftUI
import SwiftData
@main
struct NotesApp: App {
let modelContainer: ModelContainer
init() {
do {
let container = try ModelContainer(
for: Note.self, Folder.self,
configurations: ModelConfiguration(
isStoredInMemoryOnly: false
)
)
self.modelContainer = container
} catch {
fatalError("Could not initialize ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(modelContainer)
}
}
Pass all your model types to ModelContainer(for:). The .modelContainer(modelContainer) modifier injects the container into the SwiftUI environment, making it available to every view that needs to read or write data.
Testing with in-memory stores
During unit tests and previews, use an in-memory store to avoid file I/O and keep tests fast and isolated.
let container = try ModelContainer(
for: Note.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
#Preview {
ContentView()
.modelContainer(container)
}
Each preview or test run gets a fresh, empty database that disappears when the process ends. This approach ensures your tests don't interfere with each other or leave artifacts on disk.
Query: Fetch and subscribe to data
The @Query property wrapper is the heart of reactive data fetching. It automatically runs your fetch request when the view appears, updates your view whenever the data changes, and handles pagination for large result sets.
import SwiftUI
import SwiftData
struct NotesListView: View {
@Query var notes: [Note]
var body: some View {
List(notes) { note in
VStack(alignment: .leading) {
Text(note.title).font(.headline)
Text(note.createdAt, style: .date).font(.caption).foregroundColor(.gray)
}
}
}
}
That's it. SwiftData fetches all notes, subscribes to changes, and re-renders the view whenever a note is added, updated, or deleted. You don't manage fetchers, cancellables, or state machines.
Sorting and filtering
Use @Query with predicates and sort descriptors to control which data you see and in what order.
struct NotesListView: View {
@Query(
sort: \.createdAt,
order: .reverse,
where: \Note.isPinned
) var pinnedNotes: [Note]
var body: some View {
List(pinnedNotes) { note in
Text(note.title)
}
}
}
Here, @Query sorts by creation date in reverse order and only includes pinned notes. The view stays synchronized automatically—if you unpin a note elsewhere in your app, it vanishes from this list immediately.
Querying with custom predicates
For complex filters, build predicates using #Predicate syntax.
struct NotesListView: View {
@Query(
#Predicate<Note> { note in
note.isPinned || note.tags.contains("urgent")
},
sort: \.createdAt,
order: .reverse
) var importantNotes: [Note]
var body: some View {
List(importantNotes) { note in
Text(note.title)
}
}
}
The type-safe #Predicate macro prevents runtime errors by checking your condition at compile time. SwiftData translates this into an efficient database query that runs on the persistent store, not in memory.
Insert and delete: Mutate data with modelContext
To create, update, or remove objects, you inject the ModelContext from the environment and call its methods. SwiftData automatically saves changes to disk.
struct AddNoteView: View {
@Environment(\.modelContext) var modelContext
@State private var title: String = ""
@State private var content: String = ""
@Environment(\.dismiss) var dismiss
var body: some View {
Form {
TextField("Title", text: $title)
TextField("Content", text: $content)
Button("Save") {
let note = Note(title: title)
note.content = content
modelContext.insert(note)
dismiss()
}
.disabled(title.isEmpty)
}
}
}
Call modelContext.insert(note) to add the new note to the database. SwiftData batches this change and flushes it to disk automatically. Because @Query views are subscribed to the context, your list updates immediately.
Updating existing objects
Modifications to persisted objects are tracked automatically. Just change a property and SwiftData detects the mutation.
struct NoteDetailView: View {
@Bindable var note: Note
@Environment(\.modelContext) var modelContext
var body: some View {
VStack {
TextField("Title", text: $note.title)
TextEditor(text: $note.content)
Toggle("Pinned", isOn: $note.isPinned)
}
.onChange(of: note.isPinned) {
try? modelContext.save()
}
}
}
The @Bindable property wrapper lets you bind directly to object properties. When the user toggles the pin, the property changes, SwiftData detects it, and you can call save() if you want explicit control over when writes happen (though SwiftData auto-saves periodically).
Deletion
Remove objects by calling modelContext.delete().
struct NoteDetailView: View {
@Bindable var note: Note
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
var body: some View {
VStack {
// ... note display ...
Button(role: .destructive) {
modelContext.delete(note)
dismiss()
} label: {
Text("Delete Note")
}
}
}
}
When you delete an object, SwiftData marks it for removal and persists that change. If your model has cascade relationships (like a Folder with Notes), deleting the folder automatically removes all its notes.
Common mistakes and how to avoid them
Forgetting to pass all models to ModelContainer
If you define a Note and a Folder but only pass ModelContainer(for: Note.self), SwiftData won't know about Folder and won't persist it. Always list every model type you use.
Mutating @Query results directly without @Bindable
Don't try to mutate objects fetched by @Query without wrapping them in @Bindable. SwiftUI's binding system depends on that wrapper to detect changes and propagate them back to the model context.
Calling save() unnecessarily
SwiftData auto-saves periodically. Calling modelContext.save() after every single change is redundant and can slow your app. Reserve explicit saves for critical moments like syncing with a remote API or ensuring data persists before a background task.
Using @Query in views that don't need reactive updates
If you're displaying read-only data that doesn't change, fetching with @Query is appropriate. But if you need a snapshot at initialization and don't want the view to rebuild when data changes, use a traditional fetch from @Environment(\.modelContext) and store the result in a local @State variable.
Forgetting @Transient on computed properties
If you define a property that's derived from other properties, mark it @Transient. Otherwise, SwiftData tries to persist it and you may see serialization errors or stale cached values.
Key takeaways
- Use
@Modelto turn a Swift class into a persisted entity. All standard types and Codable structs are automatically supported. - Initialize
ModelContaineronce in yourAppand inject it via.modelContainer()so all views can access it. - Fetch data with
@Query, which automatically subscribes your view to changes and re-renders when the database updates. - Mutate data by injecting
ModelContextfrom the environment and callinginsert(),delete(), or by modifying properties directly with@Bindable. - Use
@Relationshipto model connections between entities and define cascade rules to maintain referential integrity. - Mark non-persisted properties with
@Transientto keep computed state and UI flags out of your database. - Test with in-memory containers to keep your test suite fast and avoid file I/O side effects.
- SwiftData auto-saves changes, so you rarely need to call
save()explicitly unless coordinating with network requests or background work.
SwiftData is production-ready for most iOS and macOS apps. If you're starting a new project, prefer it over Core Data. The cleaner API, tight SwiftUI integration, and reduced boilerplate will let your team ship faster and maintain less code.






Comments
Loading comments…