I flipped on iCloud sync so a player's scores would follow them across their devices, and WordErase started crashing the instant it launched. No tap, no gesture, just a cold start straight into a fatal error. The message sounded almost philosophical: CloudKit integration requires that all attributes be optional, or have a default value set. Here is what that rule actually means, why CloudKit enforces it on SwiftData, the two-part fix that got sync working without letting a provisioning hiccup take the app down, and the one scary-looking error I eventually learned to ignore.
The crash
The setup was ordinary. WordErase keeps a player's history in two tiny SwiftData models. Score records the result of a single day's puzzle, keyed by date. Event records the smaller moments the stats screen counts: words found, perfect days, streak milestones. Both are deliberately small, just three stored properties each.
Locally this had worked for weeks. The moment I added CloudKit to the ModelConfiguration so progress could sync, the app refused to start. The crash was not in my game logic at all. It happened during ModelContainer creation, before the first tile ever drew, which is why it felt so abrupt: the app died building its own database.
The error was clear once I read it slowly. SwiftData was telling me the models were not eligible for CloudKit because they contained non-optional properties with no default values. The originals looked exactly the way you write Swift when sync was never part of the plan:
@Model class Score {
var dateKey: String // non-optional, no default
var points: Int // non-optional, no default
var maxPoints: Int // non-optional, no default
}
Why CloudKit insists on this
This is not SwiftData being fussy for its own sake. It is inheriting a constraint from CloudKit's record model, and once I sat with it, the constraint exists for a genuine reason.
When SwiftData syncs through CloudKit it does not store your Swift types directly. It projects each @Model into a CloudKit record type, prefixed with CD_, so Score becomes CD_Score and Event becomes CD_Event in the CloudKit Dashboard. Those records have to reconcile across devices and, crucially, across time.
Imagine a player on an old build whose records predate a field you add in a later release. When that older record syncs up to a newer schema, CloudKit needs to materialize the missing field somehow. If the property is required and has no default, there is no honest value to put there, and the record cannot be reconstructed. So CloudKit refuses to provision a schema that could ever paint itself into that corner. Either the property tolerates absence by being optional, or it carries a default CloudKit can fall back on when a value is genuinely missing.
A required field with no default is a promise that every record, forever, in every version of the app, will have that value. Across a syncing system with many devices and a history of schema changes, that is a promise you cannot keep, and CloudKit gently stops you from making it.
The first half: defaults everywhere
The direct fix was to give every stored property on both models a default value. I chose defaults over making everything optional on purpose. Optionality leaks into the rest of the code: every read site would have to unwrap, and these values are not conceptually optional. A score always has points. A puzzle always has a date key. They are set the moment the object is created.
@Model class Score {
@Attribute(.preserveValueOnDeletion)
var dateKey: String = ""
@Attribute(.preserveValueOnDeletion)
var points: Int = 0
@Attribute(.preserveValueOnDeletion)
var maxPoints: Int = 0
init(dateKey: String, points: Int, maxPoints: Int) {
self.dateKey = dateKey
self.points = points
self.maxPoints = maxPoints
}
}
The subtle part is that those defaults are essentially never used. The initializer always assigns real values, so an empty string or a zero never survives long enough to mean anything in gameplay. The defaults exist purely so CloudKit can provision the schema, and so it has something to lean on if it ever reconstructs a sparse record. They are a contract with the sync layer, not a value the game code ever sees.
The @Attribute(.preserveValueOnDeletion) annotation is a separate, smaller decision. It tells SwiftData to keep these field values even after a record is deleted, which leaves the value visible in CloudKit's deletion change history. For a stats app that is quietly useful: a deleted score can still be accounted for when reconciling totals across devices, rather than vanishing from the record stream.
The second half: never let sync take the app down
Defaults stopped the schema complaint, but they did not address the deeper fragility I had stumbled into. The crash was a fatal error during container creation, which meant any failure in CloudKit setup, not just a schema mismatch, could end the app before it drew a single tile. A transient provisioning error, a momentary iCloud outage, a device in some odd account state: any of those should soften the experience, not end it.
So I made the container builder defensive. It tries to build with the CloudKit configuration first, pointed at the app's private database. If that throws for any reason, it catches the error and rebuilds the same schema as a local-only store. It lives in an actor singleton so initialization happens once and the container is shared safely.
actor DataContainer {
static let shared = DataContainer()
nonisolated let modelContainer: ModelContainer
nonisolated let isUsingCloudKit: Bool
private init() {
let schema = Schema([Score.self, Event.self])
do {
let cloud = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.WordErase")
)
modelContainer = try ModelContainer(for: schema,
configurations: [cloud])
isUsingCloudKit = true
} catch {
// CloudKit unavailable: keep playing locally.
let local = ModelConfiguration(schema: schema,
cloudKitDatabase: .none)
modelContainer = try! ModelContainer(for: schema,
configurations: [local])
isUsingCloudKit = false
}
}
}
A couple of details mattered here. The container is named explicitly with .private(...) rather than left to .automatic, so the app always targets its own private CloudKit database and never assumes a container that does not exist. And I kept an isUsingCloudKit flag around, because the rest of the app sometimes wants to know whether it is in synced mode, for example to decide whether a CloudKit account-status check is even worth running.
The payoff is that a player whose iCloud is having a bad day still gets a fully working game, with their progress stored on-device. Sync resumes on a later launch when conditions improve. I traded a hard guarantee of sync for a softer one, and in exchange a sync problem alone can no longer keep the app from launching.
The warning I learned to ignore
After both fixes the app launched cleanly, but the console still scared me. On the first few runs with the new schema I kept seeing this:
CKError: "Server Rejected Request" (15/2000)
Failed to set up CloudKit integration
My instinct was that something was still broken. It was not. When you change a SwiftData schema, CloudKit has to provision the matching record types on Apple's servers, and in the development environment that provisioning is not instant. For a window after the first launch with a new schema, CloudKit can reject sync setup while it catches up, sometimes for a day or two. The app does not care, because the fallback covers it: a rejected setup just means local storage for that session, and sync starts once the schema settles. Production containers are pre-provisioned at submission, so end users never hit this.
This was the genuinely useful lesson buried in the whole episode. Not every red line in the console is your bug. Some of them are a distributed system telling you, accurately, that it needs a minute. The trick was building the app so that a temporary no from CloudKit was survivable, and then trusting the design instead of chasing the warning.
Takeaways
- CloudKit requires every SwiftData attribute to be optional or carry a default, so it can reconcile records that predate a schema change. SwiftData projects each
@Modelinto aCD_-prefixed CloudKit record type, and that record has to be reconstructable across versions. - Prefer defaults over optionals when the value is conceptually always present. The default keeps every read site clean and exists only to satisfy provisioning; the initializer still sets the real value.
- Do not let
ModelContainercreation be a hardfatalError. Wrap it so a CloudKit failure falls back to a local-only store, and the app launches no matter what iCloud is doing. - Name your CloudKit container explicitly with
.private(...)rather than leaning on.automatic, and keep a flag for whether sync is actually active. - A fresh schema triggers a temporary "Server Rejected Request" while CloudKit provisions in development. It is expected, it clears on its own, and a good fallback makes it harmless.
Sync looks like a single toggle and turns out to be a small contract with an entire distributed system. Reading the error carefully, instead of fighting it, told me exactly what that contract was asking for: give every field a fallback, never trust setup to succeed, and let the noisy provisioning warnings settle on their own.