Contents
- Background
- Example use case
- Design flaws
- Auto-save doesn’t work
- Arrays of @Model objects are randomly shuffled
- let cannot be used for bidirectional relationships
- Relationships are secretly implicitly unwrapped by default
- You cannot directly initialise bidirectional relationship properties
- Relationship constraints are only enforced at save time
- Singletons are unsupported
- You cannot create model objects outside of SwiftData contexts
- Apple’s documentation is wrong
- Non-pitfalls
- Bonus: TablePlus recommendation
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 Floor
s each of which has multiple Room
s. Each of those has a few other attributes – simple strings and numbers – and the Room
s can reference a few other model types, like Weapon
s and Monster
s, 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
Apple’s Save models for later use (subsection of Preserving your app’s model data across launches)autosaveEnabled
property totrue
to get the same behavior [sic].
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:
Apple’s Preserving your app’s model data across launches@Model class Trip { var name: String var destination: String var startDate: Date var endDate: Date var accommodation: Accommodation? }
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:
Apple’s Preserving your app’s model data across launches@Relationship(.cascade) var accommodation: Accommodation?
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. 🤞
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!
What a great, tremendous, article!! Now that almost a year has passed, I wonder if any updates? TY
Though I didn’t look deeply into the SwiftData changes from this year’s WWDC, I didn’t see or hear of any notable progress.
Thank you for this, it saved the day!