[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"post-content-swift-6-strict-concurrency-swiftdata":3},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>ModelContext\u003C\u002Fcode> from a background task, read this before your next release.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1531297484001-80022131f5a1?w=800&amp;q=80\" alt=\"Developer migrating a SwiftData app to Swift 6 strict concurrency, laptop with code glowing at night\" width=\"800\" loading=\"lazy\" style=\"width:100%;border-radius:12px;margin:1.5em 0;\" \u002F>\n\n\u003Ch2>Why strict concurrency and SwiftData don't get along\u003C\u002Fh2>\n\n\u003Cp>The whole problem fits in one sentence: \u003Ccode>ModelContext\u003C\u002Fcode> is not Sendable, and every \u003Ccode>@Model\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>That's a gift, honestly. It just doesn't feel like one while you're in the middle of it.\u003C\u002Fp>\n\n\u003Ch2>Crash one: sharing the context with a detached task\u003C\u002Fh2>\n\n\u003Cp>Here's what my import code looked like before the migration. I'm not proud of it:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-swift\">struct FriendImporter {\n    let context: ModelContext\n\n    func importFriends(from data: [FriendDTO]) async {\n        await Task.detached {\n            for dto in data {\n                context.insert(Friend(name: dto.name, birthday: dto.birthday))\n            }\n            try? context.save()\n        }.value\n    }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>I knew \u003Ccode>context\u003C\u002Fcode> 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 \u003Ccode>save()\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>The fix is \u003Ccode>@ModelActor\u003C\u002Fcode>, which gives you an actor with its own private \u003Ccode>modelContext\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-swift\">@ModelActor\nactor FriendImporter {\n    func importFriends(from data: [FriendDTO]) throws {\n        for dto in data {\n            let friend = Friend(name: dto.name, birthday: dto.birthday)\n            modelContext.insert(friend)\n        }\n        try modelContext.save()\n    }\n}\n\n\u002F\u002F from a view:\nlet importer = FriendImporter(modelContainer: container)\ntry await importer.importFriends(from: dtos)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>ModelContainer\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>Crash two: handing models across the boundary\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The rule that fixed it: send the ID, not the object.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-swift\">let id: PersistentIdentifier = friend.persistentModelID\n\n\u002F\u002F somewhere else, on a different context:\nif let friend = otherContext.model(for: id) as? Friend {\n    friend.isFavorite = true\n    try otherContext.save()\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>PersistentIdentifier\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>Crash three: the list that lied to me\u003C\u002Fh2>\n\n\u003Cp>This one isn't a crash, it's worse. After the import moved to the actor, my \u003Ccode>@Query\u003C\u002Fcode>-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 \u003Ccode>@Query\u003C\u002Fcode> refreshes, and I don't think that's documented loudly enough.\u003C\u002Fp>\n\n\u003Cp>The workaround that worked for me: observe \u003Ccode>didSave\u003C\u002Fcode> on the container and poke the main context:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-swift\">NotificationCenter.default.addObserver(\n    forName: ModelContext.didSaveNotification,\n    object: nil, queue: .main\n) { _ in\n    Task { @MainActor in\n        try? container.mainContext.save()\n    }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>CloudKit adds its own rules\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>The mental model that stuck\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>I keep the \u003Ccode>@ModelActor\u003C\u002Fcode> template, the ID-not-object rule, and the didSave workaround in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> 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, \u003Ca href=\"\u002Fposts\u002Fswift-6-strict-concurrency-data-races\u002F\">Part 1\u003C\u002Fa> covers the three data races I found first.\u003C\u002Fp>\n",1788264748788]