Start a project

Article · iOS · SwiftData · Persistence · 8 min read

Persisting data with SwiftData: models, queries, background work and migrations

Almost every app needs to remember something between launches: a list, a draft, a history, a cache of what came from the server. SwiftData, introduced with iOS 17, is Apple’s Swift-first way to do that. It sits on the same storage engine as Core Data, but you describe your data in ordinary Swift classes and query it straight from SwiftUI. This guide covers what you need to ship it with confidence, including the parts that bite later: migrations and background work.

On this page 9 sections
  1. When SwiftData is the right choice
  2. Defining models
  3. Setting up the container
  4. Reading data with @Query
  5. Editing models
  6. Background work with @ModelActor
  7. Schema migrations
  8. Syncing with iCloud
  9. Key takeaways

When SwiftData is the right choice

SwiftData suits apps whose data is owned by the user and lives mainly on the device: notes, logs, collections, plans, offline copies of server data. It works well with SwiftUI and can sync through iCloud with little extra code.

Consider something else when:

For anything else, UserDefaults is for small settings only, not for records.

Defining models

A SwiftData model is a class with the @Model macro:

import Foundation
import SwiftData

@Model
final class Trip {
    var name: String
    var startDate: Date
    var endDate: Date
    var notes: String = ""

    @Relationship(deleteRule: .cascade, inverse: \Stop.trip)
    var stops: [Stop] = []

    init(name: String, startDate: Date, endDate: Date) {
        self.name = name
        self.startDate = startDate
        self.endDate = endDate
    }

    var durationInDays: Int {
        Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
    }
}

@Model
final class Stop {
    var title: String
    var arrival: Date
    var trip: Trip?

    init(title: String, arrival: Date) {
        self.title = title
        self.arrival = arrival
    }
}

Stored properties become persisted attributes. Computed properties such as durationInDays aren’t stored. The cascade delete rule means deleting a trip deletes its stops; the default rule, .nullify, would leave orphaned stops with no trip.

Useful attributes to know:

From iOS 18 you can also declare indexes with the #Index macro for properties you filter or sort on often, which helps once tables grow into the thousands of rows.

Setting up the container

The modelContainer modifier creates the store and puts a context in the environment:

import SwiftUI
import SwiftData

@main
struct TripsApp: App {
    var body: some Scene {
        WindowGroup {
            TripListView()
        }
        .modelContainer(for: Trip.self)
    }
}

Listing Trip is enough; SwiftData follows the relationship to include Stop. For previews and tests, use an in-memory store so nothing touches disk:

@MainActor
let previewContainer: ModelContainer = {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Trip.self, configurations: config)
    let lisbon = Trip(name: "Lisbon", startDate: .now, endDate: .now.addingTimeInterval(86_400 * 4))
    container.mainContext.insert(lisbon)
    return container
}()

#Preview {
    TripListView()
        .modelContainer(previewContainer)
}

Reading data with @Query

@Query fetches models and keeps the view up to date as they change:

struct TripListView: View {
    @Query(sort: \Trip.startDate, order: .reverse) private var trips: [Trip]
    @Environment(\.modelContext) private var context

    var body: some View {
        NavigationStack {
            List {
                ForEach(trips) { trip in
                    NavigationLink(trip.name, value: trip)
                }
                .onDelete { offsets in
                    for index in offsets { context.delete(trips[index]) }
                }
            }
            .navigationTitle("Trips")
            .navigationDestination(for: Trip.self) { TripDetailView(trip: $0) }
            .toolbar {
                Button("Add", systemImage: "plus") {
                    context.insert(Trip(name: "New trip", startDate: .now, endDate: .now))
                }
            }
        }
    }
}

Inserts and deletes through the main context are saved automatically shortly afterwards, so you rarely need to call save() yourself in UI code. Call it explicitly before something that must see the data on disk, such as handing off to a widget.

For a query that depends on input, such as a search term, build the Query in the view’s initialiser with a #Predicate:

struct FilteredTripsView: View {
    @Query private var trips: [Trip]

    init(searchText: String, upcomingOnly: Bool) {
        let now = Date.now
        _trips = Query(
            filter: #Predicate<Trip> { trip in
                (searchText.isEmpty || trip.name.localizedStandardContains(searchText))
                && (!upcomingOnly || trip.startDate > now)
            },
            sort: [SortDescriptor(\Trip.startDate)]
        )
    }

    var body: some View {
        List(trips) { Text($0.name) }
    }
}

Note let now = Date.now outside the predicate: predicates are translated into a database query, and they can only capture plain values, not call arbitrary functions. If a predicate won’t compile, that’s usually the reason.

Outside SwiftUI, use FetchDescriptor with the context directly, and set fetchLimit when you only need the first few results.

Editing models

Model objects are observable, so a detail screen can bind straight to them with @Bindable:

struct TripDetailView: View {
    @Bindable var trip: Trip

    var body: some View {
        Form {
            TextField("Name", text: $trip.name)
            DatePicker("Starts", selection: $trip.startDate, displayedComponents: .date)
            DatePicker("Ends", selection: $trip.endDate, in: trip.startDate..., displayedComponents: .date)
            Section("Notes") {
                TextEditor(text: $trip.notes)
            }
        }
        .navigationTitle(trip.name)
    }
}

Changes save automatically. If you need a “Cancel” button that throws edits away, edit a copy of the values in local @State and write them back on “Save”, or use a separate ModelContext with autosave turned off and only save it when the user confirms.

Background work with @ModelActor

Model objects belong to the context that loaded them, and the main context belongs to the main actor. Importing a large file or syncing hundreds of records from a server on the main context will make the UI stutter. Do it on a model actor instead:

@ModelActor
actor TripImporter {
    func importTrips(_ records: [TripRecord]) throws -> Int {
        for record in records {
            let trip = Trip(name: record.name, startDate: record.start, endDate: record.end)
            modelContext.insert(trip)
        }
        try modelContext.save()
        return records.count
    }
}

// From a main-actor model:
let importer = TripImporter(modelContainer: container)
let count = try await importer.importTrips(records)

The macro gives the actor its own modelContext on its own executor. Two rules keep this safe:

The main context picks up changes saved by the background context, and @Query views refresh.

Schema migrations

The day you ship version 1, your schema becomes a contract with every installed copy of the app. Adding a property with a default value, or a new optional property, is handled automatically as a lightweight migration. Renaming, splitting or changing the type of a property needs a plan.

Declare each shipped schema as a VersionedSchema and describe how to get from one to the next:

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

    @Model final class Trip {
        var name: String
        var startDate: Date
        var endDate: Date
        init(name: String, startDate: Date, endDate: Date) {
            self.name = name; self.startDate = startDate; self.endDate = endDate
        }
    }
}

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

    @Model final class Trip {
        @Attribute(originalName: "name") var title: String
        var startDate: Date
        var endDate: Date
        var notes: String = ""
        init(title: String, startDate: Date, endDate: Date) {
            self.title = title; self.startDate = startDate; self.endDate = endDate
        }
    }
}

enum TripsMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [TripsSchemaV1.self, TripsSchemaV2.self] }
    static var stages: [MigrationStage] { [v1ToV2] }

    static let v1ToV2 = MigrationStage.lightweight(fromVersion: TripsSchemaV1.self,
                                                   toVersion: TripsSchemaV2.self)
}

@Attribute(originalName:) tells SwiftData that title is the old name, so a rename stays lightweight. For changes that need code, such as splitting one field into two or removing duplicates before adding a unique constraint, use MigrationStage.custom with willMigrate and didMigrate closures. Then create the container with the plan:

let container = try ModelContainer(for: TripsSchemaV2.Trip.self,
                                   migrationPlan: TripsMigrationPlan.self)

In the app, add typealias Trip = TripsSchemaV2.Trip so the rest of the code keeps using the short name.

The habit that saves the most pain: before every release that touches models, install the previous App Store version on a device, add realistic data, then install the new build over it. A failed migration on launch is one of the worst bugs to ship, because the user can’t get past it.

Syncing with iCloud

SwiftData can sync a private database through CloudKit when you add the iCloud capability with CloudKit and a container, plus the Background Modes capability for remote notifications. CloudKit sync puts constraints on the schema: properties need default values or must be optional, relationships must be optional, and unique constraints aren’t supported. Apple lists the details in Syncing model data across a person’s devices.

Decide early. Adding sync to a schema that wasn’t designed for it means a migration to loosen it, so if iCloud sync is likely in a later version, shape the models for it from the start.

Key takeaways

If your app needs to work offline, sync between devices or move off an older Core Data setup, tell me what you’re working with and I’ll suggest an approach that fits.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project