Home / Blog / SwiftData in production: relationships and migrations that actually hold up

SwiftData in production: relationships and migrations that actually hold up

I’ve put SwiftData into production on two projects. The API that looks clean in demo tutorials behaves differently at real data volumes. Here’s what I learned, mostly around relationship management and schema migrations. Relationships: cascade and inverse On the first project I declared the parent-child relationship only on one side. After a delete, I was […]

I’ve put SwiftData into production on two projects. The API that looks clean in demo tutorials behaves differently at real data volumes. Here’s what I learned, mostly around relationship management and schema migrations.

Relationships: cascade and inverse

On the first project I declared the parent-child relationship only on one side. After a delete, I was left with orphan records. The fix is to always declare the inverse:

@Model
final class Workout {
    @Relationship(deleteRule: .cascade, inverse: Exercise.workout)
    var exercises: [Exercise] = []
}

@Model
final class Exercise {
    var workout: Workout?
}

Without the inverse, SwiftData wires the relationship one-way and cascade delete stops working. If you come from Core Data this feels familiar, but SwiftData is quieter about it, so you only notice later.

To-many relationships and fetch performance

A to-many @Relationship is lazily loaded by default. On a screen that showed 500 workouts, every row queried its own exercise count, and the UI started stalling.

struct WorkoutRow: View {
    let workout: Workout
    var body: some View {
        HStack {
            Text(workout.name)
            Spacer()
            Text("(workout.exercises.count)")
        }
    }
}

That snippet triggers 500 separate fetches across 500 rows. Either add a cached exerciseCount property to the model, or use relationshipKeyPathsForPrefetching on the FetchDescriptor. I prefer the second:

var descriptor = FetchDescriptor<Workout>()
descriptor.relationshipKeyPathsForPrefetching = [.exercises]

Schema migrations: lightweight and custom

Two migration types apply at version bumps. Lightweight handles simple changes like adding or removing properties automatically. Custom migrations step in when you need to transform data.

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

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

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // pre-migration cleanup
        },
        didMigrate: { context in
            // data transformation
        }
    )
}

Turning a field into an enum

In the old schema I stored status: String. In the new one I wanted it to be an enum. Lightweight migration won’t do that conversion because the storage type changes. In a custom stage, inside didMigrate, I iterate every record and map the string into the enum.

Important detail: inside a custom migration you need the old schema’s model type to read the old property. SwiftData hands you that through the fromVersion and toVersion parameters.

ModelContainer setup: production settings

let schema = Schema([Workout.self, Exercise.self])
let config = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .automatic
)
let container = try ModelContainer(
    for: schema,
    migrationPlan: AppMigrationPlan.self,
    configurations: [config]
)

If you want CloudKit sync, every property has to be optional or have a default value. A required field gets the sync rejected, and you’ll crash when opening the container.

How I test this

For every migration I spin up an in-memory ModelContainer in XCTest, seed data with the old schema, then migrate to the new schema and assert the transformation was correct. I also push to TestFlight, install the old app, then the update on top, so I can exercise a real device. That step is critical because production devices surface edge cases you won’t see in a unit test.

When I stay on Core Data

No user base yet isn’t a reason. But if an existing app has heavy Core Data code, NSFetchedResultsController, intricate predicates, or hand-tuned performance hot spots, I stay. SwiftData doesn’t give you the same granular controls Core Data does, not yet.

Closing notes

SwiftData is a good pick for a new project. For an existing Core Data codebase, look for a concrete reason before you migrate. A migration is never less than two weeks of work, and without full test coverage it can stretch to two months. Be ready to write custom migrations; Apple’s lightweight promise doesn’t cover most real scenarios.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch