---
title: "SwiftData rollback after a failed save"
slug: swiftdata-rollback-after-a-failed-save
section: tech
date: 2026-10-31T11:30:00.000Z
canonical: https://roland.leth.ro/blog/tech/swiftdata-rollback-after-a-failed-save
description: "SwiftData rollback after a failed save, in one place: why a rolled-back delete crashed until processPendingChanges() ran first, and how the tests fail a save."
---

When we call `save()` on a `SwiftData` context and it throws, Apple's docs don't say what happens to the changes it tried to write. As far as I can tell, they don't go anywhere, they stay pending in the context, and on a context that autosaves, like Digest's main one, the next successful save anywhere in the app writes them after all.

That's what a code review found in [Digest][digest], a food journal I'm working on. A failed delete showed an alert saying "Nothing already logged is affected", and the next save, from any screen, would've deleted the meal anyway. A failed meal log, tapped again, would've written the meal twice.

So now Digest rolls the context back after a failed save, in one place. That rollback had a trap of its own: a rolled-back delete that crashed on the next read, which the tests found by making a save fail on purpose. I'm going into very edge case territory here, but it was still a fun thing to tackle.

I've never actually seen a save fail on a device; it's all from the tests.

## One save for the whole app

Every write in Digest already went through one function, an extension on `ModelContext` called `saveStamped()`. It sets a `modifiedAt` date on every inserted and changed model, which is how the export notices an edit, then saves.

Undoing a failed save, on the other hand, was pretty much up to each call site: a handful had a hand-written undo, like deleting the insert that failed, and most had none.

I wanted one place instead of a fix at each of about 35 call sites. That would've been the same code 35 times, and the next call site would probably forget it.

`saveStamped()` was already that place, of course:

```swift
func saveStamped() throws {
   // [...] stamps every inserted and changed model

   do {
      try save()
   } catch {
      revertToStore() // ❶

      throw error // ❷
   }
}
```

We put the context back (❶) before the error leaves (❷), so whoever called it can trust "nothing was saved". A retry writes the meal once, and the next save elsewhere has nothing of this one left to commit.

What does the user see? The editor stays open with whatever they typed still there, under a "Couldn't save" alert: "Nothing already logged is affected. Try again in a moment." That line is actually true now.

`revertToStore()` ends in a `rollback()`, though (see below), which throws away _everything_ pending in the context, not just this write. It's fine for Digest, because by convention every write saves right away, so when a save fails, nothing else should be pending. And if something was, it was part of the same failed save anyway. Where it would hurt is an app that leaves edits unsaved in a shared context for a while: one screen's failed save would throw away another screen's work.

## The delete that crashed

Apple's documentation says [`rollback()`][rollback] "discards pending inserts and deletes, restores changed models to their most recent committed state, and empties the undo stack". Calling just that, though, crashed the test host on a delete: the meal was deleted, its save failed, the rollback ran, and the next read of the meal brought the whole host down.

If we roll back a delete the context hasn't processed yet, the object is still there, the editor still holds it, a list row might still show it, but reading it crashes. Processed first, it comes back whole:

```swift
private func revertToStore() {
   processPendingChanges() // ❶
   rollback() // ❷
   Log.logging.notice("Pending changes put back after a failed save.")
}
```

Apple documents [`processPendingChanges()`][ppc] (❶) as telling the undo manager to record the context's changes, and says you can call it yourself "to coalesce any pending changes". The docs don't say why that keeps a rolled-back delete readable, and I can't either. But with it, the read after `rollback()` (❷) works.

This one probably never reached the app, though. A real `save()` processes the pending changes on its way to the store (Apple's [`willSave`][willsave] notification posts when the context is "about to process" them), so by the time it throws, the delete is most likely processed already.

So where did the crash come from? The tests: there, the failure is thrown _before_ `save()` runs, so nothing has processed the delete yet when the rollback comes. I kept the call anyway, so the rollback doesn't depend on where the failure came from, whether that's a real store, the test flag, or anything else that might throw before `save()` later on.

There's a thread on Apple's forums, ["Fatal error on rollback after delete"][forum], about a crash _inside_ `rollback()` after deleting a model that has children. An Apple engineer reproduced it and called it a framework issue, and the only workarounds in the thread are deleting the children first, or doing the work in a separate context. Mine crashed on the read after the rollback, not inside it, so it might just be a different bug, and I don't know if processing first helps there.

## Making a save fail in a test

We can't ask a store to fail, so in debug builds `saveStamped()` checks a flag right before `save()`, after the stamps:

```swift
do {
   #if DEBUG
   if SaveFailureInjection.isFailing {
      throw SaveFailureInjection.Failure()
   }
   #endif

   try save()
} catch {
   // [...]
}
```

The flag is a task-local:

```swift
#if DEBUG
nonisolated enum SaveFailureInjection {
   struct Failure: Error {}

   @TaskLocal
   static var isFailing = false
}
#endif
```

A plain `static var` would be simpler, but Swift Testing runs tests in parallel, and a static flag would fail the saves of every test running beside the one that set it. `@TaskLocal` (a value bound to a task, and inherited by the tasks it starts) is only set for the length of a `withValue` call, so the failure stays inside one test:

```swift
private func failingSave(in context: ModelContext) {
   #expect(throws: SaveFailureInjection.Failure.self) {
      try SaveFailureInjection.$isFailing.withValue(true) {
         try context.saveStamped()
      }
   }
}
```

The delete test, with `Entry` being a meal and `EntryItem` the row joining it to a food:

```swift
@Test("Brings a deleted meal back whole, readable through the reference the screen holds")
func deleteIsPutBack() throws {
   let container = try ModelContainer.inMemory() // ❶
   let context = container.mainContext
   let entry = Entry(timestamp: .now, items: [Item(name: "rye")])
   context.insert(entry)
   try context.saveStamped()
   let timestamp = entry.timestamp

   context.delete(entry)
   failingSave(in: context) // ❷
   try context.saveStamped() // ❸

   #expect(entry.timestamp == timestamp) // ❹
   #expect(entry.entryItems?.count == 1)
   #expect(try context.fetchCount(FetchDescriptor<Entry>()) == 1)
   #expect(try context.fetchCount(FetchDescriptor<EntryItem>()) == 1)
}
```

We start from an in-memory container (❶; the `CloudKit` trap with those is in [iOS testing with Claude Code][testing]). We fail the delete's save (❷), then save again, the way a new screen would (❸).

Reading `entry` after that (❹) is the read that used to crash, and the 2 counts check the store still holds the meal and its food row, so the second save didn't commit the delete either.

2 more tests do the same for an insert (a retry writes it once) and for a changed field (the screen's object gets its old value back). Before the flag, none of the old hand-written undos had a test.

One more way to slip out of the rollback, found along the way: inserting an object the context already holds isn't really the no-op it looks like.

In Digest, it cut the object off from the rollback. After a failed save, it kept the values the save tried to write while the store had the old ones, and a retried meal log counted a food's use twice. The screens that build a meal now insert only the foods that aren't in a context yet:

```swift
func insertUnstored<Model: PersistentModel>(_ models: [Model]) {
   for model in models where model.modelContext == nil {
      insert(model)
   }
}
```

If you know why an unprocessed delete can't be read after a rollback, or you've found what fixes the forum one, I'd like to hear it ([@roland.leth.ro][] / [@rolandleth][])!

[digest]: /projects/digest "Digest"
[testing]: /blog/tech/ios-testing-with-claude-code "iOS testing with Claude Code"
[rollback]: https://developer.apple.com/documentation/swiftdata/modelcontext/rollback() "rollback()"
[ppc]: https://developer.apple.com/documentation/swiftdata/modelcontext/processpendingchanges() "processPendingChanges()"
[willsave]: https://developer.apple.com/documentation/swiftdata/modelcontext/willsave "willSave"
[forum]: https://developer.apple.com/forums/thread/820173 "Fatal error on rollback after delete"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
[@rolandleth]: https://x.com/rolandleth "@rolandleth on X"
