SwiftData pitfalls

I’ve been exploring SwiftData lately, and I’ve been unpleasantly surprised by how many sharp edges it has. I’m going to try to describe some of them here, so that hopefully others can avoid them (or perhaps be dissuaded from using SwiftData to begin with).

I’m using Xcode 15.0.1 (Swift 5.9) on macOS 14.1 (Sonoma).

Background

I’ve dabbled in Core Data over the years – mostly pre-Swift – and have a kind of begrudging, distant respect. I understand what it’s trying to do and I can appreciate some of the ways in which it makes that easier, but I’m also all too familiar with its limitations and caveats. So mostly I’ve ignored it, preferring to use other approaches – whether that be JSON or plists for simple cases, or just directly using a SQL database when that feature-set genuinely is warranted.

When SwiftData was introduced, I was intrigued. On the surface it seemed like it greatly reduced the boilerplate and some of the complexity of using Core Data. It at least seemed like an appealing option for light use, with relatively simple models and especially with SwiftUI. It seems pretty obviously targeted at the typical, pretty trivial patterns that most iOS apps have – i.e. not much more than lists of objects.

But it’s only recently that I actually tried using SwiftData for more than just some trivial toy cases. I had a textbook case of a hierarchical type tree, with fairly simple 1-to-1 or many-to-1 relationships, the need for cascade deletes in some cases but not others, and a dataset a little larger than what I could get away with just stuffing into User Defaults.

Alas, what I’ve found is that SwiftData has so many glaring bugs and limitations, that even for this textbook example case, it just doesn’t make any sense to use it.

Example use case

My model is fairly simple – a root object called House which can contain multiple Floors each of which has multiple Rooms. Each of those has a few other attributes – simple strings and numbers – and the Rooms can reference a few other model types, like Weapons and Monsters, as 1-to-1 relationships.

The order of floors and rooms matters – they correspond to top to bottom (attic to basement) and left to right as will be rendered in the GUI.

Design flaws

Auto-save doesn’t work

By default, model containers are supposed to auto-save. That’s what the documentation says, both in the API reference and the tutorials:

In both instances, the returned context periodically checks whether it contains unsaved changes, and if so, implicitly saves those changes on your behalf. For contexts you create manually, set the autosaveEnabled property to true to get the same behavior [sic].

Apple’s Save models for later use (subsection of Preserving your app’s model data across launches)

I suppose it hinges on the word “periodically”. In my testing, that period must be tens of seconds, or worse. It’s trivial to see that it doesn’t work – just make some changes and close the window, or quit your app. Virtually all the time – even if you wait several seconds – those changes are silently lost!

It’s clear that SwiftData has no hooks into deinitialisation, view / window closure, app exit, etc. You would think it has to, in order to save things reliably even in the happy-path cases (as opposed to e.g. app crashes). But it simply does not, and thus it does not.

Workarounds

If you want full resilience to unexpected app termination (e.g. crashes) then any time you make a change to any SwiftData-managed object, you have to immediately call context.save() manually. Which is especially annoying since that method throws, so you have to figure out some sane way to handle that failure possibility in every situation in which you make any changes to your models.

For non-trivial applications, this leads to your code being riddled with save-management code. Think about every SwiftUI field which reflects an element of your model and enables mutation of it. Every button that adds or removes or reorders anything. Every editable list view. Etc.

It’s easy to miss a spot where you make a modification, leading to silent data loss.

If you’re willing to lose data when your app crashes (which you really shouldn’t be willing to do so easily do), you can limit your saves to certain key paths – e.g. window closure, app exit, etc. But you have to find and hook into all those things manually.

This is nominally no different to some SwiftData alternatives (e.g. NSCoding), although below par compared to User Defaults and most other SQL database frameworks.

Arrays of @Model objects are randomly shuffled

Array in Swift is very explicitly an ordered collection, in the sense that the elements within have a specific albeit arbitrary order, based on how they’re inserted. This is the same as arrays / vectors / lists in practically every programming language.

SwiftData violates this by randomly reordering elements at various times, such as when [re]loading model objects from persist storage.

The reason is pretty simple – it fails to record the order of elements in its underlying SQLite database, instead using a randomly-assigned, arbitrary integer as a uniquing key, and nothing else.

This is a pretty startling design flaw. I’m not sure how SwiftData got out the door with this.

Workarounds

I haven’t found any good workarounds. The advice you’ll generally see online (e.g. on StackOverflow) is to manually load the objects in the array, specifying a sort order as part of the query. But this basically has you manually reimplementing the relational aspects of SwiftData:

  • You have to add a member variable to the target class in question just to store its index in the array. You need one of these variables for every many-to relationship it can be a part of.
  • You have to then somehow populate those variables and keep them in sync with any changes to the arrays.
  • You can’t actually use the array member variables – since the objects are in random order – and instead have to manually query the database separately. Or, you can sort the array member variables at some point, but it’s difficult to do that efficiently (you can’t really be sure when SwiftData is going to mangle the sort order, so you basically have to do the sorting on the fly every single time you access the array).

Rather than going through all that hassle, it’s easier to just not use SwiftData – e.g. you can simply use a normal object graph that’s persisted to disk as JSON, or in property list form, or using NSCoding, etc.

let cannot be used for bidirectional relationships

All member variables (of @Model classes) that refer bidirectionally to other model classes must be variables (var), not constants (let). Irrespective of their actual intended semantics.

This is seemingly just an inherent part of SwiftData (and Core Data underneath) – the presumption that all relationships are optional and can change arbitrarily. It worked okay in Objective-C because that was closer to the reality of Objective-C objects, but it flies in the face of Swift’s much stronger controls and expectations on mutability and optionality.

It would be a relatively minor annoyance – losing compile-time protection of immutability is frustrating but not technically a show-stopper – except that there are no warnings or indications otherwise, of any kind, that you’re “holding it wrong”. If you declare the member with let, everything will compile without so much as a warning, but you’ll run into obtuse runtime errors and crashes, e.g.:

💣 Could not cast value of type 'Swift.KeyPath<Game.House, Swift.Array<Game.Floor>>' (0x12e00cc98) to 'Swift.ReferenceWritableKeyPath<Game.House, Swift.Array<Floor.Region>>' (0x13e0b4198).

What that error message is trying but failing to convey is that you cannot (outside of initialisation) write to a let, of course, so its attempt to modify the property failed. So it crashed your app.

Workarounds

None, really. You just have to remember, every time, to avoid let. Possibly you could obtain & configure some linter to check this for you.

Relationships are secretly implicitly unwrapped by default

@Model
final class Room {
    let monster: Monster


}

That’s fine, right – every room has a Monster. Simple.

Except it’s not, if Monster is a @Model. In that case, despite the fact you’ve declared it as non-optional, it actually is optional. @Model essentially makes it implicitly unwrapped without telling you (and without the important ! character after the type to warn you).

The technical reason it’s implicitly and unavoidably optional is because Room and Monster are stored in separate SQLite tables, with a nullable foreign key relationship between them. SwiftData does not support non-nullable foreign keys. So it’s totally valid, as far as the actual database layer and persistent storage are concerned, for the relevant Monster to disappear entirely. But that means your model instance is basically corrupt when you load it, which can have a variety of ill-defined effects such as crashing your app.

You might think this is largely hypothetical, or academic – SwiftData’s not going to erroneously delete your Monster out from under the Room (you assume), and you’re not going to do it yourself, so it’ll never happen, right? Well, maybe. Consider what happens as your app evolves, undergoes refactors, etc. It’s quite possible you unwittingly create a situation in which what was previously valid data is no longer valid to your app.

Or, you might run into a SwiftData design flaw or bug which causes the data to be corrupted on save, such as is detailed in the next section. Even if the root cause is that your data was corrupted at save time, it’s frustrating to have that manifest only through obtuse crashes at some ill-defined time in the future after your app is relaunched.

Workarounds

None, really.

At the very least, you can explicitly make all relationships optional. That way you can code defensively around them, by putting in appropriate unwrapping code and guards. If nothing else you can at the very least crash with a clear failure message (there is no log message at all when SwiftData crashes your app via implicit unwrapping).

You cannot directly initialise bidirectional relationship properties

@Model
final class House {
    @Relationship(deleteRule: .cascade,
                  inverse: \Floor.house)
    let floors: [Floor]

    init(floors: [Floor]) {
        self.floors = floors
    }
}

If you do something like the above – making use of Xcode to generate your initialiser for you, like you normally would for classes – you’ll get the wonderful behaviour that everything works fine until you quit your app and reload it. Then you’ll discover that floors is empty.

There are no error messages, neither at compile time nor runtime. No warnings. Nothing.

Looking at the underlying SQLite database, its apparent that something is seriously broken because while it does save the Floor instances, their foreign key back to House is NULL (even if logically that should be impossible – refer back to the prior point on how SwiftData forces all relationships to be optional).

What’s happening is that the initialisation of floors in init is in some sense bypassing SwiftData’s custom hooks; it’s “directly” setting the value in memory without SwiftData updating its corresponding Core Data model underneath (this is another place SwiftData’s abstraction is leaky – your @Model classes aren’t the real model classes, they’re merely a layer atop the actual Core Data models).

Workarounds

Thankfully this does have a simple workaround (iff you know it and can remember to always use it): never assign the relationship in init. You can append to it, and you can assign it outside of init. So e.g.:

@Model
final class House {
    @Relationship(deleteRule: .cascade,
                  inverse: \Floor.house)
    let floors: [Floor] = []

    init(floors: [Floor]) {
        self.floors.append(contentsOf: floors)
    }
}

Relationship constraints are only enforced at save time

@Relationship lets you specify the arity of a relationship – the minimum and maximum number of related objects that are permitted. However, this is not actually enforced at any time except save time (when you explicitly call context.save()).

Arguably this is better than nothing – vanilla Array member variables provide no way to constrain the array’s contents – but it can lead to mistakes if you unwittingly rely on more rigorous enforcement.

Workarounds

None, really, that I’ve been able to come up with.

It sort of helps to think of these ‘constraints’ as actually just ‘guidelines’; to not actually rely on them being enforced. That may mean you have to do manual validation, that’s in principle redundant, in order to ensure the rules are enforced.

That said, since you need to manually save aggressively anyway (since auto-save doesn’t work), you might be able to conjoin the two workarounds.

Singletons are unsupported

In some use-cases your root object is a singleton – you don’t ever actually want to have multiple instances of it, it’s merely the top-level of your object graph, the object which organises all other objects.

Unfortunately SwiftData doesn’t support this. Every @Model class can have multiple instances.

This can make things pretty awkward when dealing with your singleton, as you cannot do intuitive and simple things like:

struct Map: View {
    @Environment(\.modelContext) private var context
    @Query var house: House

    var body: some View {
        VStack {
            ForEach(house.floors) {

            }
        }
    }
}

Workarounds

Basically you have three choices:

  • Remove all your singletons, redesigning your model and app to accomodate. This might make some contrived sense if you rationalise it as a way to have multiple game sessions concurrently, or to facilitate concurrent unit testing, or somesuch. But you’ll know in your heart that it’s not real.
  • Use force-unwrapping (or equivalent), making your app crash or otherwise fail if your singleton expectations are violated by SwiftData.
  • Silently ignore all but an arbitrary instance of the model class in question.

These are all pretty flawed. And no matter which approach you choose, they introduce some significant boilerplate into your code. e.g.:

struct Map: View {
    @Environment(\.modelContext) private var context
    @Query var houses: [House]

    var body: some View {
        VStack {
            ForEach(house[0].floors) { // Silently ignore other houses.

            }
        }
    }
}

In the above example, which house is chosen is arbitrary and may change between view updates. You can do extra work to pick a house deterministically – e.g. sort by some unique attribute and pick the first – and you probably should if you’re going to use this hack, but you’ll still be operating in a messed-up state.

You cannot create model objects outside of SwiftData contexts

This limitation is explicable and reasonable on the face of it, but does limit your app architecture in ways that can be annoying. e.g. you cannot initialise your views with test models like you normally would:

#Preview {
    MapView(house: House.default())
}

That will compile without any complaints, but when you try to actually view the preview in Xcode you’ll get the dreaded “Cannot preview in this file” error, with absolutely no indication why.

If you happen to try a similar thing in your app initialisation, e.g.:

@main
struct Game: App {
    var body: some Scene {
        WindowGroup {
            Overview(house: House.default())
                .modelContainer(for: [House.self])
        }
    }
}

…then you can at least run your app and will see an error message before it crashes, like:

💣 SwiftData/ModelContainer.swift:144: Fatal error: failed to find a currently active container for Room
Failed to find any currently loaded container for Room) [sic]

So far as Apple error messages go, this is practically perfect – it at least does mention almost the pertinent object (the [SwiftData] container is similar to the SwiftData context) and provide some explanation of the genuine problem.

Workarounds

I haven’t explored this in great detail, so there might be other options, but the best I could promptly come up with is to move initialisation inside your SwiftUI views. Whether that be through explicit user interaction (e.g. a “New Game” GUI in my use case, when there’s no existing House in the database), or in a suitable onAppear handler, etc.

That’s reasonable for actual app execution, but doesn’t immediately help you for SwiftUI previews. However, if you attach a suitable model container to your preview instance:

#Preview {
    MapView()
        .modelContainer(for: [House.self])
}

…then your preview will actually use the real app data. This can actually be handy sometimes, as you can manipulate the state – either in the preview or your actual app execution – which can be handy for debugging view problems (e.g. you’re running your app, playing around, and you notice a rendering error – you can quit your app and its saved state will be used in your Xcode previews, automatically showing you the view in its broken state).

Alternatively, you can go through a more laborious process of [more-]manually creating the SwiftData context, in order to allow you to use a distinct database and to create test objects, e.g.:

#Preview {
    let container = try! ModelContainer(for: House.self,
                                         configurations: ModelConfiguration(isStoredInMemoryOnly: true))

    MapView(house: House.forPreviewing())
        .modelContainer(container)
}

I believe using an in-memory-only store will prevent it touching your actual app’s saved state, but I haven’t poked at it much. I also believe you can use the more complicated configuration initialiser to explicitly specify a URL, if you want a persistent test dataset that’s nonetheless separate from your app’s real dataset, but I haven’t played with it myself.

There are various tutorials online that go into more detail, e.g. Paul Hudson‘s How to use SwiftData in SwiftUI previews.

Apple’s documentation is wrong

This one barely rates a mention since Apple’s documentation is pretty infamously unreliable (what little of it exists at all). Still, it’s important to point out a couple of particularly glaring errors that will hit most SwiftData newbies:

@Model
class Trip {
    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    var accommodation: Accommodation?
}
Apple’s Preserving your app’s model data across launches

That is Apple’s very first code example in introducing SwiftData, and it’s wrong. It has no initialiser, therefore you cannot actually use this Trip class.

Now, you might say that’s pedantic, but it’s actually quite pertinent – Apple never show a complete example of a @Model class, so they never show you how you’re supposed to initialise one, and thus they never even try to prevent people falling into pits such as the aforementioned issues with arrays.

Another poignant example:

@Relationship(.cascade) var accommodation: Accommodation?
Apple’s Preserving your app’s model data across launches

That’s not valid; it doesn’t even compile. The syntax actually is:

@Relationship(deleteRule: .cascade) var accommodation: Accommodation?

Workarounds

There are quite a few tutorials available online which provide an alternative onboarding guide, to Apple’s official documentation. I did find a broad perusal of them useful in gleaning bits and pieces that Apple themselves don’t cover (or are wrong about). Unfortunately many are essentially just copy-pastes or paraphrases of Apple’s documentation, and even those that aren’t tend to make the same mistakes.

But, since you’ve now read this post, you’re better equipped than most to try diving into SwiftData.

Non-pitfalls

Just as a bit of a bonus section, I want to call out one aspect of SwiftData which actually works as you might expect.

You only need to register the root(s) of your model graph

When setting up your model container, you don’t have to manually enumerate every model object you use, merely some root(s). SwiftData automatically walks your class(es) member variables to find relationships to other @Model classes, and implicitly registers those too. So e.g.:

@main
struct Game: App {
    var body: some Scene {
        WindowGroup {
            Map()
                .modelContainer(for: [House.self])
        }
    }
}

You don’t need to list Floor.self, Room.self, etc. You can – it doesn’t hurt anything, and might make your app a little more robust against future refactors – but it’s nice that you don’t have to.

Bonus: TablePlus recommendation

It’s unfortunately critical, when working with SwiftData (or Core Data), to be able to view & edit the underlying SQLite database. You can do that out-of-the-box on your Mac using just the built-in sqlite3 CLI, but that’s pretty awkward.

There are many GUI apps for working with SQLite databases. Quite a few are free. Most are functional but clumsy.

Years ago – when I was mostly working with MySQL – I came across TablePlus. It’s a bit pricey at $90 up front – plus $50 per subsequent year if you want to keep getting software updates – but it’s by far the best database client I’ve found, on any platform. It’s easily the most native-feeling on a Mac, even though it’s cross-platform (macOS, iOS / iPadOS, Linux, and Windows!), and one of the fastest. It doesn’t quite have all the features – I do sometimes fire up MySQL Workbench to access its query performance visualisation – but it covers 99% of what I need.

My only notable complaint with it is that it has historically been a tad buggy. Not dramatically, but consistently. That said, I only just got back into it as a result of this SwiftData work – and it’s worked flawlessly so far – so it may well have upped its game since I last properly used it a couple of years ago. 🤞

1 thought on “SwiftData pitfalls”

  1. Thank you for the chapter “let cannot be used for bidirectional relationships” and the explanations!

    It helped me as the only source in the whole™ InterWeb! Thank you!

    Reply

Leave a Comment