One big benefit of SwiftUI and SwiftData is that it does a lot of stuff for you, and abstracts pretty much everything, so you can just kick back and make cool apps. The flip side of this is that it is a pain in the back to debug — be it the dreaded “Swift compiler is unable to type-check this expression in a reasonable time” or something else. In my case, my app started randomly crashing out of nowhere with an EXC_BREAKPOINT
error code somewhere in SwiftData code.
Since you are here, I will assume that the same (or something similar) is happening right now to you. My sincere condolences, we will get through this together.
This post is going to be less of a comprehensive debugging manual and more of a case study, which hopefully might help with the general direction on where to dig, because there is barely any information on debugging this, apart from some open Apple Developer Forums threads.
Paul Hudson has a few useful articles on debugging SwiftData, and his SwiftData by Example tutorials were immensely helpful for me, however here we will try to dig a bit deeper. So, without further ado, let’s look at the anamnesis.
Our patient
Naam is a domain management app, that has a somewhat convoluted data schema structured in a way to reduce unnecessary repetition of code. Here is an extremely simplified version of the data schema:
struct Service: Codable {
let name: String
let logo: String?
let url: URL
}
struct DNSData: Codable {
let nameServers: [String]
let dnsProvider: Service
let mailProvider: Service?
}
@Model
final class Domain {
var name: String
var expiry: Date
var dnsData: DNSData
init(name: String, expiry: Date, dnsData: DNSData) {
self.name = name
self.expiry = expiry
self.dnsData = dnsData
}
}
The domain has a few parameters, like its name (e.g. example.com
), expiry date, and also data that can be fetched from the DNS, like the nameservers, DNS provider and mail provider. Since we want to display providers nicely in the UI, they have some properties like a name, logo, and link.
However, while all domains functioning on the internet will have at least one nameserver, and as a consequence a DNS provider, not all domains have a mail provider (for example, example.com
does not).
gbgk@aluminium ~ % host -t MX example.com
example.com mail is handled by 0 .
Due to this, we will mark mailProvider
optional.
Symptoms
After launching the app and adding some domains, the app randomly crashes. There appears to be nothing specific causing the issue, and there is no helpful error message.
Let’s start the examination
Since clicking through the stack frames does not yield anything remotely useful, the next step is to detach the debugger and generate a proper crash report.
How do I get a crash report?
- In the Xcode console, next to
(lldb)
typedetach
and hit enter. - Copy the crash report from your development device using this manual from Apple, or if you are using a simulator, open
Console.app
and selectCrash reports
in the sidebar.
Thread 0 Crashed:: Dispatch queue: NSManagedObjectContext 0x60000350c9a0
0 SwiftData 0x1d2b75c80 0x1d2b40000 + 220288
1 SwiftData 0x1d2b772c4 0x1d2b40000 + 225988
2 libswiftCore.dylib 0x194c8cbd8 _KeyedDecodingContainerBox.decode<A>(_:forKey:) + 236
3 libswiftCore.dylib 0x194c82904 KeyedDecodingContainer.decode(_:forKey:) + 36
4 swiftdatacrashpoc.debug.dylib 0x100e98c3c Service.init(from:) + 332
5 swiftdatacrashpoc.debug.dylib 0x100e99178 protocol witness for Decodable.init(from:) in conformance Service + 20
6 libswiftCore.dylib 0x194efdfe0 dispatch thunk of Decodable.init(from:) + 16
7 SwiftData 0x1d2b767d4 0x1d2b40000 + 223188
Okay, this is something! We can see in frames 2 through 6 references to the decoding of our struct Service
which would suggest that SwiftData is having trouble decoding it from whatever format it stores it in.
However, if you look closely, you might see that frame 4 references Service.init(from:)
— a method that we have not defined for our struct. That’s because when a struct conforms to Codable
, the Swift compiler auto-synthesizes this method for us. Let’s write out all decoders manually to see where the problem lies — thankfully with Xcode it’s extremely easy, just type init
in the struct code and accept two suggestions — first the one that initializes the struct from properties (init(name: ...
) and then the one that creates the struct from a Decoder
(init(from decoder: ...
)
Let’s do that for both Service
and DNSData
and re-run the app. And now, look at that, now the debugger is showing us the precise line where the error is occurring:
We can now do some print
-debugging by placing print
statements before every decoding operation in the DNSData
initializer:
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.nameServers = try container.decode([String].self, forKey: .nameServers)
print("decode dnsProvider")
self.dnsProvider = try container.decode(Service.self, forKey: .dnsProvider)
print("decode mailProvider")
self.mailProvider = try container.decodeIfPresent(Service.self, forKey: .mailProvider)
print("done")
}
After re-running the app we can see that all print
statements are executed except done
, meaning only decoding of mailProvider
fails.
Since mailProvider
is optional, the next logical step would be to see if mailProvider
not being nil
fixes the crash — that’s pretty trivial to check, and we do indeed see that the app does not crash if mailProvider
is set to something.
What is quite weird is that mailProvider
is being decoded with the decodeIfPresent
method, which should return nil
if there is no container for key .mailProvider
, but it does not happen. If we check decoder.allKeys
in the Service
initializer, we can see that it returns an empty array. This means that the container is created, but is empty.
That is quite unusual, but actually makes perfect sense if you look at how SwiftData stores our Domain
in the SQLite database.
Let’s take an example domain:
Domain(
name: "example.com",
expiry: Date(),
dnsData: DNSData(
nameServers: ["ns.example.com"],
dnsProvider: Service(
name: "Example",
logo: "example",
url: URL(string: "https://example.com")!
),
mailProvider: nil
)
)
SwiftData converts it into this SQL record (written out here as JSON for readability):
{
"name": "example.com",
"expiry": "2025-01-01T...",
"nameServers": "[encoded as binary data]",
"name1": "Example",
"logo1": "example",
"url1": "https://example.com",
"name2": null,
"logo2": null,
"url2": null
}
Which makes sense on the surface, but if you spend a few minutes looking at this JSON, you might realize that there is some ambiguity.
If name2
is NULL
, what does it mean? Does it mean that mailProvider.name
is nil
? It would make sense, if not for the fact that mailProvider.name
just cannot be nil
, it’s not optional!
Sounds a bit confusing? Well it is! And SwiftData also gets confused, so it tries to decode the abovementioned record into this:
Domain(
name: "example.com",
expiry: Date(),
dnsData: DNSData(
nameServers: ["ns.example.com"],
dnsProvider: Service(
name: "Example",
logo: "example",
url: URL(string: "https://example.com")!
),
mailProvider: Service(
name: nil, // Because name2 is null (uh oh!!!)
logo: nil, // Because logo2 is null (this is fine, logo is optional)
url: nil // Because url2 is null (uh oh again!!!)
)
)
)
…which breaks our code because name
and url
are not optional!
So, to summarize, let’s retrace what our app is doing:
- When launched, it creates a
ModelContainer
- Since SwiftData can find the SQLite database with the data, it tries to read it
- SwiftData finds the record with a
mailProvider
equal tonil
- SwiftData starts decoding the record, and since structs are flattened in the database, assumes that
mailProvider
exists (even though it is optional, and that is not guaranteed!) - Decoding initializer of
Service
receives a container, but since all values in the database are null, the container is empty - The
decode
tries to read the value from the container, but there is none - Kaboom
Treatment
There are a few options for how we can fix this. The most obvious and blunt one is just not using optional structs, but that is not optimal. Another option is for us to manually check if the container is empty or not before decoding it.
To do that, we first need to explicitly define the CodingKeys
of Service
. That is quite easy, just type CodingKeys
inside the Service
struct definition and let Xcode autofill it for you.
After that, replace the decoding of mailProvider
in the DNSData
initializer with this:
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.nameServers = try container.decode([String].self, forKey: .nameServers)
self.dnsProvider = try container.decode(Service.self, forKey: .dnsProvider)
// Get the containter for mailProvider
let nestedMailContainer = try container.nestedContainer(keyedBy: Service.CodingKeys.self, forKey: .mailProvider)
// Check if all values in the container are null / the container is empty
if nestedMailContainer.allKeys.isEmpty {
self.mailProvider = nil
} else {
self.mailProvider = try container.decodeIfPresent(Service.self, forKey: .mailProvider)
}
}
If you rebuild and launch the app now, it should start without problems!
I have reported the problem to Apple Developer Technical Support but haven’t received any reply yet, however if anything does happen in the future, I will try to update this article with details.
Throughout debugging this problem I have learned quite a bit about debugging SwiftUI apps, and how the internals of SwiftData work, and I hope this article has been useful for you, even if not resolving your issue outright, but at least pointing you in the right direction.
Have you seen other interesting SwiftData behaviors? Feel free to reply with them to this post on Bluesky.
Comments
Reply to this post on Bluesky to post a comment