@attribute(.primaryKey) in SwiftData: How Primary Keys Work and When They Break
SwiftData primary keys define application-level identity, but they break quickly when identifiers are regenerated, migrated poorly, or used in sync-heavy architectures.
SwiftData makes persistence feel straightforward — until it doesn't. One of the first places teams hit a wall is identity: how SwiftData decides two records are the same thing. @attribute(.primaryKey) is the answer, but it comes with constraints that aren't obvious from the documentation alone.
This article covers how primary keys work in SwiftData, what happens when you use them incorrectly, and the failure modes that surface in production — particularly in offline-first and sync-heavy architectures.
What @attribute(.primaryKey) Actually Does
Every @Model class has an implicit persistent identifier managed by the framework. You don't see it directly — it's the PersistentIdentifier returned by modelContext.insert(_:) and accessible via persistentModelID.
@attribute(.primaryKey) is different. It marks a property as the application-level unique identifier — the field SwiftData uses to determine whether an incoming record already exists in the store.
@Model
final class Transaction {
@attribute(.primaryKey) var id: UUID
var amount: Decimal
var timestamp: Date
}
When you insert a Transaction whose id already exists in the store, SwiftData upserts rather than duplicates. Without .primaryKey, the framework treats every insertion as a new record — even if the id values match.
That distinction matters the moment you introduce any external data source: a CloudKit sync, an API response, a local JSON import.
The Upsert Behavior — and Its Limits
The upsert is the primary reason to use .primaryKey. It gives you deterministic merge behavior: insert an object with a known identifier, and SwiftData either creates it or updates the existing record.
What it does not give you is conflict resolution. If the same record exists on two devices with divergent field values, .primaryKey ensures they refer to the same object — but it does not decide which field values win. That logic is yours to write.
This is a common misconception. Teams assume .primaryKey plus CloudKit sync equals automatic conflict handling. It doesn't. The primary key handles identity. Conflict resolution is a separate concern, and SwiftData's CloudKit integration has documented limits around CRDT-style merging — covered in depth in the offline-first iOS Core Data and CloudKit sync footguns article.
Type Constraints
Not every type is valid as a primary key. SwiftData requires the property to be a value type conforming to Hashable and Codable, with a value that's stable across app launches — no computed values, no random generation at init time without persistence.
UUID, String, and Int all work. Date works but is a poor choice — two records created at the same millisecond collide. Custom structs work if they satisfy Hashable and Codable.
What breaks silently: using a UUID that gets regenerated on every init because you wrote var id: UUID = UUID() without storing it. The value differs every time the object is recreated from a fetch, so the upsert logic never fires. The store accumulates duplicates instead.
The fix is explicit:
@Model
final class Transaction {
@attribute(.primaryKey) var id: UUID
init(id: UUID = UUID(), amount: Decimal, timestamp: Date) {
self.id = id
self.amount = amount
self.timestamp = timestamp
}
}
Pass the id explicitly from your data source. Never let the model generate it independently on the insert path.
Migration and Primary Key Changes
Changing a primary key after your schema is in production is one of the more disruptive migrations you can make. SwiftData treats the primary key as part of the schema identity for that entity. Adding .primaryKey to a property that previously had no constraint — or changing which property carries the annotation — requires a versioned schema migration.
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [Transaction.self]
@Model
final class Transaction {
@attribute(.primaryKey) var id: UUID
var amount: Decimal
var timestamp: Date
}
}
Without a migration plan, existing records lose their identity mapping. On the next launch, SwiftData may treat all existing records as orphans or fail to open the store entirely.
If you're building a SwiftData schema from scratch, decide on your primary key strategy before the first TestFlight build. Changing it after external testers have data is painful.
Composite Keys — What SwiftData Does Not Support
SwiftData does not support composite primary keys as of 2026. You cannot annotate two properties with .primaryKey and have the framework treat them as a combined unique constraint.
If your domain model requires composite uniqueness — a (userId, reportDate) pair, for example — there are two options.
Option 1: Synthesize a single key from the composite fields.
@Model
final class DailyReport {
@attribute(.primaryKey) var compositeKey: String // "\(userId)_\(reportDate)"
var userId: String
var reportDate: String
var data: Data
}
This works, but requires discipline. Every insertion path must compute the composite key identically. A format mismatch creates duplicates.
Option 2: Use @Attribute(.unique) on the synthesized field and manage merge logic explicitly in your model context operations.
Neither option is clean. This is a genuine gap in the framework — worth factoring into schema design early, especially in health or fintech contexts where record uniqueness often depends on multiple dimensions.
Relationship Integrity with Primary Keys
When a @Model with a .primaryKey is referenced by a relationship on another model, deletion behavior becomes important.
SwiftData relationships use @Relationship with a deleteRule. Delete a parent record that carries the primary key, and child records with a .cascade rule are deleted too. With .nullify, the relationship reference becomes nil. With .deny, the deletion fails if children exist.
@Model
final class Account {
@attribute(.primaryKey) var id: UUID
@Relationship(deleteRule: .cascade) var transactions: [Transaction]
}
The primary key has no direct effect on delete rules — but it does affect how upserts interact with relationships. Upserting an Account with a matching id updates the scalar properties. It does not automatically reconcile the transactions array. Relationship updates require explicit handling in your merge logic.
Fetch Descriptors and Primary Key Lookups
You can fetch by primary key directly using a predicate:
let targetID = UUID(uuidString: "...")!
let descriptor = FetchDescriptor<Transaction>(
predicate: #Predicate { $0.id == targetID }
)
let results = try modelContext.fetch(descriptor)
This is efficient — SwiftData maps the .primaryKey property to an indexed column in the underlying SQLite store, making this a point lookup rather than a scan.
Don't fetch all records and filter in memory when you have the identifier. At scale, the difference is significant.
When Primary Keys Break in Sync Architectures
The failure mode that causes the most damage in production: inserting records from a remote source without preserving the original identifier.
A common pattern in teams new to SwiftData is to fetch JSON from an API, construct model objects using init() with a fresh UUID(), and insert them. The primary key is new on every sync cycle. The store grows unboundedly. Deduplication logic added later can't recover the original identifiers because they were never stored.
The rule is simple: the identifier lives in the data source, not in the model's initializer. If the server assigns a UUID, that UUID is the primary key. If the server assigns an integer ID, that integer is the primary key. The model does not generate its own identity.
This applies to CloudKit sync as well. NSPersistentCloudKitContainer manages its own record identifiers separately from your application-level primary key. The two systems coexist — but you need to be explicit about which identifier serves which purpose. Conflating them produces subtle duplication bugs that are difficult to diagnose after the fact.
For teams building SwiftUI architecture with SwiftData as the persistence layer, getting identity right at the schema level prevents an entire class of state management bugs upstream.
Production Checklist for @attribute(.primaryKey)
Before shipping a SwiftData model with a primary key into production:
- The identifier comes from the data source, not from
UUID()in the model initializer - The type is
Hashable,Codable, and stable across launches - A versioned schema migration exists if the primary key was added to an existing model
- Composite uniqueness requirements are handled via a synthesized key, not multiple annotations
- Relationship delete rules are explicit and tested against upsert scenarios
- Fetch paths that look up by identifier use
FetchDescriptorwith a predicate, not in-memory filtering
In sync-heavy apps — health records, financial transactions, field-ops data — these aren't optional. They're the difference between a store that stays coherent and one that requires a wipe-and-resync on every edge case.
If you're evaluating a SwiftData-based codebase for these kinds of issues, the AI-Native App Architecture Audit at 3nsofts.com covers schema design, sync architecture, and App Store compliance in 5 business days — 12 to 20 prioritized findings, starting from 1,440 euros.
FAQs
What does @attribute(.primaryKey) do in SwiftData?
It marks a property as the application-level unique identifier for a @Model class. SwiftData uses this property to determine whether an incoming record already exists in the store. If a record with the same primary key value exists, SwiftData performs an upsert rather than creating a duplicate.
Can you use a composite primary key in SwiftData?
No. SwiftData does not support composite primary keys as of 2026. If your domain requires composite uniqueness, synthesize a single key from the relevant fields — concatenating two identifiers into a single String property, for example — and annotate that property with .primaryKey.
What types are valid for a SwiftData primary key?
The property must be a value type conforming to Hashable and Codable, with a value that's stable across app launches. UUID, String, and Int are common choices. Avoid types whose values change on reconstruction — such as a UUID generated fresh in a default property initializer.
Does @attribute(.primaryKey) handle conflict resolution during CloudKit sync?
No. The primary key handles identity — it ensures two records with the same identifier are treated as the same object. It does not decide which field values win when the same record has diverged on two devices. Conflict resolution requires separate logic in your model context operations.
What happens if you change a primary key property after shipping to production?
You need a versioned schema migration using VersionedSchema and SchemaMigrationPlan. Without a migration, existing records lose their identity mapping and the store may fail to open. Decide on your primary key strategy before external testers have data.
Why are duplicate records appearing even though I'm using .primaryKey?
The most common cause is generating a new UUID in the model initializer instead of passing the identifier from the data source. Each insertion creates a fresh identifier, so the upsert logic never matches an existing record. The fix is to pass the identifier explicitly from your data source on every insertion path.
How do I fetch a record by its primary key efficiently in SwiftData?
Use a FetchDescriptor with a predicate that matches the primary key property directly. SwiftData indexes the primary key column in the underlying SQLite store, making this a point lookup. Fetching all records and filtering in memory is significantly slower at any meaningful data volume.