SwiftData in Depth: Relationships, Migrations, Background Work and iCloud

Advanced iOS Development: Concurrency, Architecture, Security and Release · Data and Apple Frameworks · 8 min read · Updated

Changing the model safely

  1. Real users already have data stored in version 1 of your model.
  2. A migration plan says how to move from one version to the next.
  3. Version 2 is a new, separate schema. You never edit a shipped version.
  4. The updated app opens with the old data intact. Always test this with real data first.

In the beginner course SwiftData saved one kind of object. A real app has related data: an account has many entries, a category has many expenses, a recipe has ingredients. And real apps live for years, so the shape of the data changes after people already have data on their phones. This lesson covers the three problems that come with that: relationships, migrations and work off the main thread, plus the rules for syncing with iCloud. Because Apple changes these APIs between releases, read the current documentation for your Xcode version, and treat the code here as the shape of the solution.

Relationships

A relationship is a property that points to another model, or to a list of them. Mark the owning side with @Relationship, say what happens to the children when the parent is deleted, and name the property on the other side with inverse, so both sides stay in step.

import SwiftData
import Foundation

@Model
final class Account {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Entry.account)
    var entries: [Entry] = []

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

@Model
final class Entry {
    var title: String
    var amount: Double
    var date: Date
    var account: Account?

    init(title: String, amount: Double, date: Date = .now, account: Account? = nil) {
        self.title = title
        self.amount = amount
        self.date = date
        self.account = account
    }
}
  • deleteRule: .cascade deletes the entries when the account is deleted. .nullify (the default) keeps the entries and clears their link. .deny refuses to delete an account that still has entries. Pick the one that matches what the user expects.
  • Set the link on one side and SwiftData updates the other: entry.account = cash makes cash.entries include the entry.
  • The Account? is optional because an entry may exist before it has an account, and because iCloud sync needs optional relationships.
  • @Attribute(.unique) makes a property unique across the store, such as an email address. Note that unique constraints cannot be used with iCloud sync.

Fetching with FetchDescriptor

@Query is for views. In other code, such as a view model, an import job or a test, you fetch with a FetchDescriptor and the model context. It lets you set the filter, the order and a limit.

@MainActor
func largeEntries(in context: ModelContext) throws -> [Entry] {
    var descriptor = FetchDescriptor<Entry>(
        predicate: #Predicate { $0.amount >= 100 },
        sortBy: [SortDescriptor(\Entry.date, order: .reverse)]
    )
    descriptor.fetchLimit = 20
    return try context.fetch(descriptor)
}

let count = try context.fetchCount(FetchDescriptor<Entry>())

A fetchLimit matters for large tables. Loading ten thousand rows to show twenty wastes time and memory. Add an index or a limit before you reach for caching.

Migrations: changing the model after release

When you add a property with a default value, SwiftData can usually migrate the store by itself. Bigger changes, such as renaming a property, splitting one model into two or changing a type, need a versioned schema and a migration plan. You freeze each shipped version of the model in its own type, and describe how to move from one to the next.

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

    @Model final class Entry {
        var title: String
        var amount: Double
        init(title: String, amount: Double) { self.title = title; self.amount = amount }
    }
}

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

    @Model final class Entry {
        var title: String
        var amount: Double
        var note: String = ""        // new in version 2
        init(title: String, amount: Double) { self.title = title; self.amount = amount }
    }
}

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

// when you create the container
let container = try ModelContainer(for: SchemaV2.Entry.self, migrationPlan: EntryMigrationPlan.self)
  • A lightweight stage handles simple changes, such as adding a property with a default. A custom stage lets you run your own code before and after, for harder changes.
  • Test migrations with real data. Install the old version of your app, add data, then install the new version over it. A migration that fails can stop the app from opening, which is among the worst bugs a release can have.
  • Never edit a shipped schema version. Add a new one.

Work off the main thread: ModelActor

A big import, such as ten thousand rows from a server, must not freeze the screen. SwiftData provides @ModelActor, a macro that makes an actor with its own model context. Work done inside it stays off the main actor.

import SwiftData

@ModelActor
actor EntryImporter {
    func importEntries(_ rows: [(title: String, amount: Double)]) throws {
        for row in rows {
            modelContext.insert(Entry(title: row.title, amount: row.amount))
        }
        try modelContext.save()
    }
}

// from a task:
let importer = EntryImporter(modelContainer: container)
try await importer.importEntries(rows)

Objects belong to the context that made them. Do not pass a model object between actors. Pass its persistentModelID or the plain values, and fetch it again where you need it. SwiftUI's @Query will pick up the saved changes by itself.

Syncing with iCloud

SwiftData can sync a private database through CloudKit, Apple's iCloud service, and the people using the app pay nothing for the storage. You add the iCloud capability with CloudKit and the Background Modes capability with *Remote notifications*, and create the container with a CloudKit configuration. Apple's documentation lists the rules your models must follow. The main ones are that every property needs a default value or must be optional, relationships must be optional, and unique constraints are not allowed. It is easier to design for these from the start than to change a shipped model later.

Where Core Data and other options fit

  • Core Data is the older framework under SwiftData. Apps that started before iOS 17 use it, and it supports iOS versions that SwiftData does not. Its ideas are the same: a container, contexts, fetch requests, migrations.
  • SQLite libraries such as GRDB suit teams that want full control of SQL. They are a good choice when you need very complex queries.
  • A backend such as Firebase or your own server holds data that must be shared between *people*, not only between one person's devices. The app often keeps a local copy as well, so it works offline.

Common mistakes

  • Loading all rows when the screen shows twenty.
  • Changing a shipped model with no migration.
  • Passing model objects between actors.
  • Forgetting what the delete rule does, and removing more data than the user expected.

Your task

  1. Add Account and Entry with a relationship. Delete an account and check what happens to its entries with .cascade and then .nullify.
  2. Write a fetchLimit query for the 20 newest entries over a given amount.
  3. Create SchemaV1 and SchemaV2 as above and write down the exact steps you would use to test the migration with real data.
  4. Write the @ModelActor importer and import 5,000 rows while a list is on screen. The screen should stay responsive.

How to check it worked

  • The delete rule behaves as you expect in both modes.
  • The list stays responsive during the import.
  • You can say three rules a model must follow to sync with iCloud.

Frequently asked questions

When do I need a SwiftData migration plan?

When you change a shipped model in a way SwiftData cannot handle automatically, such as renaming a property or splitting a model. Adding a property with a default value is often handled for you. Test with real data before every release.

What does a relationship delete rule do?

It says what happens to related objects when the parent is deleted. Cascade deletes them, nullify keeps them and clears the link, and deny refuses to delete the parent while children exist.

How do I import a lot of data without freezing the app?

Use a ModelActor, which has its own model context and runs off the main actor. Pass plain values in and do not move model objects between actors.