SwiftData @Model and isDeleted in 2026: Soft Deletion Patterns That Don't Break CloudKit Sync
A production guide to SwiftData soft deletion with CloudKit, covering tombstones, predicates, relationship cascades, conflicts, schema migration, and safe purging.
Soft deletion sounds simple. Add a var isDeleted: Bool flag, filter it out in your queries, done. In a purely local app, that works. The moment you add CloudKit sync through ModelContainer with a CloudKit configuration, that simplicity becomes a source of subtle, hard-to-reproduce bugs.
This article covers the specific failure modes that appear when you implement soft deletion in a SwiftData @Model class and sync it through CloudKit — and the patterns that avoid them.
The Core Problem with var isDeleted and CloudKit
SwiftData's CloudKit integration mirrors records using NSPersistentCloudKitContainer under the hood. When you mark a record with isDeleted = true locally and save the context, CloudKit syncs that change to other devices as a field update — not as a record deletion. That distinction matters.
A hard delete removes the CKRecord entirely. CloudKit propagates that tombstone to all synced devices. A soft delete updates a Boolean field. CloudKit propagates the field change, but only to devices that are online and have pulled the latest schema. Devices that are offline when the soft delete happens will eventually sync the updated field — unless your predicate logic or fetch descriptor setup prevents the updated record from being fetched at all.
That last scenario is where apps break silently.
Predicate Filtering and the Invisible Record Problem
The standard approach is to filter soft-deleted records at the fetch layer:
@Model
final class Transaction {
var id: UUID
var amount: Decimal
var isDeleted: Bool
var deletedAt: Date?
init(id: UUID = UUID(), amount: Decimal) {
self.id = id
self.amount = amount
self.isDeleted = false
self.deletedAt = nil
}
}
Then in your @Query or FetchDescriptor:
let descriptor = FetchDescriptor<Transaction>(
predicate: #Predicate { !$0.isDeleted }
)
This works locally. The problem appears when a second device receives the sync update for a record it has never fetched — because your fetch descriptor excluded it. The record exists in the local persistent store, but your app never loaded it into memory. When the CloudKit sync engine writes the updated isDeleted = true value, it writes it correctly. But if your app then performs a batch operation or a relationship traversal that touches the parent object, you may encounter stale relationship counts or orphaned child records that were never soft-deleted themselves.
This is one part of a broader sync design. The SwiftData and CloudKit production sync patterns cover offline durability, shared stores, conflicts, and schema changes together.
Separate your display predicate from your data integrity logic. Fetch soft-deleted records in background maintenance passes. Do not assume that "not shown" means "not present."
Relationship Cascades Don't Happen Automatically
This is the most common architectural mistake. Say you have an Invoice with a @Relationship to multiple LineItem objects. You soft-delete the Invoice. The LineItem records are untouched.
CloudKit syncs invoice.isDeleted = true to all devices. The LineItem records remain fully visible to any query that doesn't check the parent's deletion state. If another device creates a new LineItem referencing that invoice between the soft delete and the sync completing, you now have an orphaned child record pointing at a logically deleted parent.
Handle cascade soft deletion explicitly:
func softDelete(_ invoice: Invoice, in context: ModelContext) {
let now = Date()
invoice.isDeleted = true
invoice.deletedAt = now
for item in invoice.lineItems {
item.isDeleted = true
item.deletedAt = now
}
try? context.save()
}
Do not rely on SwiftData's deleteRule for soft deletion. deleteRule: .cascade performs hard deletes — it removes the CKRecord from CloudKit entirely, which carries different sync semantics. If you want soft deletion, you own the cascade logic.
Schema Versioning and the isDeleted Field
Adding isDeleted to an existing @Model class that already has CloudKit records in production requires a migration plan. SwiftData's lightweight migration handles adding a new optional or defaulted property, but the CloudKit schema update is a separate concern.
CloudKit schema changes in development are straightforward. In production, adding a new field to a CKRecord type is non-destructive — existing records simply won't have that field until they're updated. A device running the old app version will sync records that now include isDeleted and ignore the field. When that device updates, it reads the field correctly.
The risk window is the period between your CloudKit schema deployment and your app update reaching all devices. During that window, a record soft-deleted on a new-version device may appear as live on an old-version device. For most apps, this is acceptable. For health or fintech apps where a "deleted" transaction reappearing is a data integrity issue, you need a coordinated rollout strategy — or a server-side function that enforces deletion state independently of the client.
This is one of the CloudKit sync edge cases that surface most often in production.
Using deletedAt as a Tombstone Timestamp
A Boolean isDeleted flag carries no temporal information. Add var deletedAt: Date? alongside it. This gives you three things:
- Conflict resolution. If two devices soft-delete the same record at different times, the later timestamp wins. Without a timestamp, you have no tiebreaker beyond CloudKit's last-write-wins default.
- Purge scheduling. A background task can hard-delete records where
deletedAtis older than a defined retention window — 30 days, 90 days, whatever your data policy requires. - Audit trails. In regulated verticals — health, fintech, legal — knowing when a record was logically deleted is often a compliance requirement, not a nice-to-have.
@Model
final class Transaction {
var id: UUID
var amount: Decimal
var isDeleted: Bool
var deletedAt: Date?
var createdAt: Date
var isActive: Bool { !isDeleted }
}
The computed isActive property keeps query predicates readable without duplicating logic.
Conflict Resolution When Two Devices Delete Different Records
CloudKit's sync model is eventually consistent. Two devices can independently soft-delete different records while offline, then both sync when connectivity returns. This is the normal case — each record's isDeleted and deletedAt fields sync independently, and SwiftData handles it correctly.
The harder case: two devices modify the same record, and one of them also soft-deletes it. CloudKit's default conflict resolution is last-write-wins on a per-field basis. That means a field update on Device A and a soft delete on Device B can produce a record with isDeleted = true but a freshly updated amount field from Device A. The record is logically deleted but carries a modification timestamp that post-dates the deletion.
If your app uses deletedAt to determine deletion state, this is fine — the deletion timestamp is authoritative. If your app uses isDeleted alone, field-level conflict resolution is still correct, but you lose the ability to reason about which operation was intended to be final.
For most apps, last-write-wins on isDeleted is sufficient. For apps where deletion is irreversible — a sent message, a submitted form, a signed document — treat soft deletion as a terminal state and reject any subsequent field updates in your model layer before they reach the context.
The Purge Pass: Hard Deleting Stale Soft-Deleted Records
Soft-deleted records accumulate. A BackgroundActor-isolated task running on a schedule handles the purge:
actor PurgeScheduler {
private let retentionDays: Int = 30
func purgeExpired(in context: ModelContext) async throws {
let cutoff = Calendar.current.date(
byAdding: .day,
value: -retentionDays,
to: Date()
)!
let descriptor = FetchDescriptor<Transaction>(
predicate: #Predicate {
$0.isDeleted && $0.deletedAt != nil && $0.deletedAt! < cutoff
}
)
let expired = try context.fetch(descriptor)
expired.forEach { context.delete($0) }
try context.save()
}
}
context.delete() on a SwiftData model triggers a hard delete and a CloudKit record removal. After the retention window, the tombstone propagates correctly to all synced devices. Records deleted this way will not reappear — the CKRecord is gone.
Schedule this task using BGAppRefreshTask or BGProcessingTask depending on your app's background execution entitlements. Do not run it on the main actor.
What the @Model Macro Does Not Handle for You
The @Model macro generates the persistence plumbing. It does not implement business logic. Specifically:
- It does not prevent hard deletion of a soft-deleted record from a context that doesn't check
isDeletedfirst. - It does not cascade
isDeletedto relationships. - It does not enforce that
deletedAtis set whenisDeletedbecomestrue.
All three are your responsibility. Enforce them in a dedicated model method or a domain service layer, not inside the @Model class itself. Mixing persistence and business rules inside @Model makes both harder to test.
A clean pattern is a DeletionService that accepts a ModelContext and a model instance, validates preconditions, sets both fields atomically, cascades to relationships, and saves. One entry point for all soft deletions. No scattered model.isDeleted = true calls across the codebase.
Testing Soft Deletion with CloudKit in CI
CloudKit sync is difficult to test in automated pipelines. The practical approach: test local persistence behavior in unit tests using an in-memory ModelContainer, and test CloudKit sync behavior manually in a CloudKit development environment.
For unit tests, an in-memory container is sufficient to verify that your DeletionService sets both fields, cascades correctly, and that fetch descriptors exclude soft-deleted records:
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Transaction.self, configurations: config)
let context = ModelContext(container)
This does not test CloudKit sync. It tests your model logic. Keep those concerns separate.
For CloudKit-specific behavior — field propagation, conflict resolution, tombstone delivery — test in a dedicated CloudKit development container with two simulator instances. It is tedious. There is no automated substitute.
If your app is in health, fintech, or legal and you're building on SwiftData with CloudKit sync, the SwiftUI architecture guide at 3nsofts.com covers the broader structural decisions that interact with your data layer.
For teams evaluating whether their current SwiftData implementation is production-ready, the AI-Native App Architecture Audit at 3nsofts.com delivers 12–20 prioritized findings in 5 business days — including data layer architecture, CloudKit sync correctness, and App Store compliance. Starting from 1,440 euros.
FAQs
Q: Can I use SwiftData's built-in isDeleted property instead of adding my own?
SwiftData's @Model instances do expose an internal deletion state, but it maps to hard deletion in the persistent store. There is no framework-level soft deletion flag. You need to add your own var isDeleted: Bool and var deletedAt: Date? properties and manage them explicitly.
Q: Will CloudKit sync my isDeleted = true change to all devices automatically?
Yes. CloudKit will sync the field change to all devices enrolled in the same container. Timing depends on network availability and CloudKit's push notification delivery. Devices that are offline will receive the change on reconnection. The sync is eventually consistent, not immediate.
Q: What happens if I accidentally hard-delete a record that was soft-deleted?
The CKRecord is removed from CloudKit and a tombstone propagates to all synced devices. The record cannot be recovered from CloudKit. If you need recoverability, implement a separate archive container or export the record before hard deletion.
Q: Should isDeleted be indexed in SwiftData?
Yes. Add @Attribute(.indexed) to isDeleted if you're running frequent filtered fetches across large datasets. Without an index, every fetch that filters on isDeleted performs a full table scan in SQLite. For small datasets — under a few thousand records — the difference is negligible. For field-ops or fintech apps with high record volumes, the index is worth adding from the start.
Q: How do I prevent a soft-deleted record from being modified by another device before the deletion syncs?
You cannot prevent it at the CloudKit layer without a server-side function. Client-side, add a check in your model service layer that rejects modifications to records where isDeleted = true. For records where deletion must be terminal, combine the isDeleted flag with a lockedAt timestamp and reject any write where lockedAt is set.
Q: Does soft deletion affect CloudKit's storage quotas?
Yes. Soft-deleted records remain in the CloudKit container until you hard-delete them and count against storage. The purge pass described above is not just a performance concern — it is a cost and quota concern for apps with high record volumes.
Q: Is there a difference in behavior between ModelContext.delete() and setting isDeleted = true from CloudKit's perspective?
Yes — fundamentally. ModelContext.delete() removes the CKRecord from CloudKit and propagates a tombstone. Setting isDeleted = true updates a field on the existing CKRecord. The record remains in CloudKit. Other devices receive a field update, not a deletion event. These are different sync operations with different behaviors on receiving devices.