Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 029 / 100

SwiftData tutorial: Swift & iOS best practices explained

Persist data with Swift-native models.

Expona Admin9 min read

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.

swift
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.

swift
@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.

swift
@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.

swift
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.

swift
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.

swift
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.

swift
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.

swift
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.

swift
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.

swift
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().

swift
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 @Model to turn a Swift class into a persisted entity. All standard types and Codable structs are automatically supported.
  • Initialize ModelContainer once in your App and 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 ModelContext from the environment and calling insert(), delete(), or by modifying properties directly with @Bindable.
  • Use @Relationship to model connections between entities and define cascade rules to maintain referential integrity.
  • Mark non-persisted properties with @Transient to 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.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles