CloudKit Rejects @Attribute(.unique) — Model Around It
Published 31 August 2026 · Updated 3 September 2026
Quick answer:
@Attribute(.unique)cannot be used in a CloudKit-backed SwiftData store. CloudKit has no unique-constraint concept, soModelContainer.initrejects the schema outright. Enforce uniqueness in your code instead — fetch-then-insert, or an upsert helper — and design for the fact that two offline devices can still create the same record.
Add @Attribute(.unique) to a model, turn on CloudKit sync, and the container refuses to build. There is no flag, no partial mode, and no version of this that works.
@Model
final class Card {
@Attribute(.unique) var code: String = "" // ← breaks CloudKit
}
Why there's no workaround
CloudKit's record model doesn't have unique constraints. It can't — it's a distributed store designed for devices that spend a lot of time offline. Two devices in aeroplane mode can both create a record with the same value, and neither can know until they sync. A constraint that can't be checked at write time isn't a constraint.
So SwiftData validates the schema up front rather than failing mysteriously later. That's the right call, but it means the fix is architectural rather than a keyword.
This sits alongside the other CloudKit schema rules, which have the same root cause — a record must be able to exist with a field missing:
- Every attribute optional or defaulted on the declaration, not just in
init - All relationships optional
- No
@Attribute(.unique) - If an enum needs filtering, persist a queryable raw
String/Intcolumn and expose the enum as a computed property
Enforcing uniqueness yourself
The practical replacement is a fetch-then-insert:
func upsertCard(code: String, in context: ModelContext) throws -> Card {
var descriptor = FetchDescriptor<Card>(
predicate: #Predicate { $0.code == code }
)
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first {
return existing
}
let card = Card(code: code)
context.insert(card)
return card
}
Route every insert through it. The moment one code path inserts directly, the invariant is gone — which is exactly what the attribute used to prevent, and why this is worth wrapping rather than remembering.
Design for duplicates arriving anyway
This is the part that matters more than the helper. Even with a perfect upsert, two offline devices can create the same record independently, and both will sync. Your uniqueness is best-effort within one device, not across the account.
So the model needs to tolerate duplicates rather than assume they can't happen:
- Fetch defensively. Use
.firston a sorted fetch rather than assuming exactly one row. Code that doesresults[0]after asserting a count of 1 will crash on a user's device eventually. - Make merging cheap. If a duplicate appears, can you fold them together without losing data? Designing the model so merge is possible is worth more than trying to prevent the collision.
- Give records a deterministic identity. If uniqueness comes from a natural key — a card code, an ISBN, a date — two devices generating the "same" record will at least agree on which one it is, which makes dedupe straightforward. Random UUIDs generated per device do the opposite.
A dedupe pass at launch, keeping the oldest and folding the rest, is usually simpler than any scheme to prevent duplicates in the first place.
Verify the schema in a test
The useful thing about this validation being up front is that you can catch it in CI. With cloudKitDatabase: .private(...), ModelContainer.init validates the entire schema — so a test that just constructs the container is real evidence, not a no-op:
@Test func schemaIsCloudKitCompatible() throws {
let config = ModelConfiguration(cloudKitDatabase: .private("iCloud.com.example.app"))
_ = try ModelContainer(for: Card.self, Deck.self, configurations: config)
}
That one test catches the CloudKit schema rules above — the missing default, the non-optional relationship, and the unique attribute — at the moment someone adds them, rather than when a user turns on sync and the store silently fails to load. Queryability of an enum is a separate predicate concern; test that with a fetch using the raw column you intend to query.
For the distinction between persistable enums, Codable attributes, and queryable values, see Apple's SwiftData enumeration documentation and SwiftData Codable guidance.
Related posts
- A .DS_Store File Will Break Your Screenshot Upload Upload tools that read image dimensions try to decode every file in the directory — so a Finder artefact you can't see aborts the whole batch with 'image: unknown format'.
- AttributedString(markdown:) Throws Away Your Paragraphs With default options, AttributedString's markdown parser joins paragraphs with no separator and drops list bullets — turning model output into run-on text. The option that fixes it.
- xcodebuild Might Be Building Release Without Telling You Omit -configuration and xcodebuild uses whatever the scheme says, which is often Release. The tell is an empty Debug-iphonesimulator directory and a product you can't find.