5 min read

Swift 6 Strict Concurrency Broke My SwiftData Imports. @ModelActor Fixed It

Sunday, 9 PM. My app's SwiftData contact import screen had been working fine for a month. Then I flipped the whole project to Swift 6 strict concurrency, and suddenly importing a 2,000-row JSON file crashed the app. Not every time. Only when the import was big enough, or fast enough, or when the moon was in the wrong phase.

This is Part 2 of my Swift 6 migration notes. Part 1 covered three data races hiding in plain sight; this one is about SwiftData, which turned out to be its own special circle of hell. If you're touching ModelContext from a background task, read this before your next release.

Developer migrating a SwiftData app to Swift 6 strict concurrency, laptop with code glowing at night

Why strict concurrency and SwiftData don't get along

The whole problem fits in one sentence: ModelContext is not Sendable, and every @Model instance is bound to the context that created it. The main context is main-actor isolated, so any code that touches a context from another executor is a data race by construction.

In Swift 5 language mode this compiled and mostly behaved. Timing saved you, the way it always does right up until it doesn't. Swift 6 makes the compiler tell you about the race instead of letting you discover it in a crash log at 10 PM.

That's a gift, honestly. It just doesn't feel like one while you're in the middle of it.

Crash one: sharing the context with a detached task

Here's what my import code looked like before the migration. I'm not proud of it:

struct FriendImporter {
    let context: ModelContext

    func importFriends(from data: [FriendDTO]) async {
        await Task.detached {
            for dto in data {
                context.insert(Friend(name: dto.name, birthday: dto.birthday))
            }
            try? context.save()
        }.value
    }
}

I knew context wasn't Sendable. I assumed the compiler would catch me. It did, but only after the app had spent a week crashing in ways I couldn't reproduce: once on save(), once mid-scroll, once in the simulator only. Intermittent crashes feel random. They weren't. Two threads were fighting over the same context, and the loser changed from run to run.

The fix is @ModelActor, which gives you an actor with its own private modelContext:

@ModelActor
actor FriendImporter {
    func importFriends(from data: [FriendDTO]) throws {
        for dto in data {
            let friend = Friend(name: dto.name, birthday: dto.birthday)
            modelContext.insert(friend)
        }
        try modelContext.save()
    }
}

// from a view:
let importer = FriendImporter(modelContainer: container)
try await importer.importFriends(from: dtos)

ModelContainer is Sendable, so passing it into the actor's initializer is legal. Everything inside runs one operation at a time on its own executor, and the hand-written locking disappears. That's the whole pattern.

Crash two: handing models across the boundary

Once imports ran on the actor, I hit the second trap. My sync code fetched friends on a background context and handed the model objects straight to the main thread. Models are not Sendable. They carry a reference to the context that made them, and using one on the wrong executor is undefined behavior, not a warning.

The rule that fixed it: send the ID, not the object.

let id: PersistentIdentifier = friend.persistentModelID

// somewhere else, on a different context:
if let friend = otherContext.model(for: id) as? Friend {
    friend.isFavorite = true
    try otherContext.save()
}

PersistentIdentifier is Sendable. The model is not. That one distinction, IDs travel and objects stay home, prevents most SwiftData concurrency bugs I've seen, including a few of my own.

Crash three: the list that lied to me

This one isn't a crash, it's worse. After the import moved to the actor, my @Query-backed list stopped updating. The data was in the store, the import reported success, and the UI showed an empty list until I killed the app. Background-context inserts don't reliably trigger @Query refreshes, and I don't think that's documented loudly enough.

The workaround that worked for me: observe didSave on the container and poke the main context:

NotificationCenter.default.addObserver(
    forName: ModelContext.didSaveNotification,
    object: nil, queue: .main
) { _ in
    Task { @MainActor in
        try? container.mainContext.save()
    }
}

Forcing the main context to save after a background save picks up the store's new transactions, and the list refreshes. It feels like a hack, because it is one. If you find a cleaner approach, tell me, I'd love to delete this block.

CloudKit adds its own rules

If you add CloudKit sync on top, every model property needs a default value or needs to be optional, and relationships must be optional too. Records can arrive half-formed over the wire. I hit this on day one, and it produces the least helpful error message I've seen all year.

The mental model that stuck

After a week, here's how I think about it now: the context owns its objects, the actor owns its context, and only IDs ever travel between the two. Everything else is a race with a costume on.

SwiftData is still worth it for me, and strict concurrency is doing you a favor by surfacing this at compile time instead of 2 AM. The rough edges are real, but you map them out once and stop stepping on them.

I keep the @ModelActor template, the ID-not-object rule, and the didSave workaround in Snippet Ark so the next app starts from the working version instead of a crash log. And if you're about to flip the language mode yourself, Part 1 covers the three data races I found first.