
Swift Data
- 213 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
swift-data: A skill for development. This provides functionality for development workflows.
Key points
- swift-data
Swift Data by the numbers
- 213 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,842 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill swift-dataAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 213 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use swift-data for development tasks?
Use swift-data for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with swift-data.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use swift-data for development tasks, or when swift-data: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to swift-data: swift-data.
Files
SwiftData Best Practices — Modular MVVM-C Data Layer
Comprehensive data modeling, persistence, sync architecture, and error handling guide for SwiftData aligned with the clinic modular MVVM-C stack.
Architecture Alignment
This skill enforces the same modular architecture mandated by swift-ui-architect:
┌───────────────────────────────────────────────────────────────┐
│ Feature modules: View + ViewModel, no SwiftData imports │
├───────────────────────────────────────────────────────────────┤
│ Domain: models + repository/coordinator/error protocols │
├───────────────────────────────────────────────────────────────┤
│ Data: @Model entities, SwiftData stores, repository impls, │
│ remote clients, retry executor, sync queue, conflict handling │
└───────────────────────────────────────────────────────────────┘Key principle: SwiftData types (@Model, ModelContext, @Query, FetchDescriptor) live in Data-only implementation code. Feature Views/ViewModels work with Domain types and protocol dependencies.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Defining @Model entity classes and mapping them to domain structs
- Setting up ModelContainer and ModelContext in the Data layer
- Implementing repository protocols backed by SwiftData
- Writing stale-while-revalidate repository reads (
AsyncStream) - Implementing optimistic writes plus queued sync operations
- Configuring entity relationships (one-to-many, inverse)
- Fetching from APIs and persisting to SwiftData via sync coordinators
- Handling save failures, corrupt stores, and migration errors
- Routing AppError traits to centralized error UI infrastructure
- Building preview infrastructure with sample data
- Planning schema migrations for app updates
Workflow
Use this workflow when designing or refactoring a SwiftData-backed feature:
1. Domain design: define domain structs (Trip, Friend) with validation/computed rules (see model-domain-mapping, state-business-logic-placement) 2. Entity design: define @Model entity classes with mapping methods (see model-*, model-domain-mapping) 3. Repository protocol: define in Domain layer, implement with SwiftData in Data layer (see persist-repository-wrapper) 4. Container wiring: configure ModelContainer once at the app boundary with error recovery (see persist-container-setup, persist-container-error-recovery) 5. Dependency injection: inject repository protocols via @Environment (see state-dependency-injection) 6. ViewModel: create @Observable ViewModel that delegates directly to repository protocols (see state-query-vs-viewmodel) 7. CRUD flows: route all insert/delete/update through ViewModel -> Repository (see crud-*) 8. Sync architecture: queue writes, execute via sync coordinator with retry policy (see sync-*) 9. Relationships: model to-many relationships as arrays; define delete rules (see rel-*) 10. Previews: create in-memory containers and sample data for fast iteration (see preview-*) 11. Schema evolution: plan migrations with versioned schemas (see schema-*)
Troubleshooting
- Data not persisting ->
persist-model-macro,persist-container-setup,persist-autosave,schema-configuration - List not updating after background import ->
query-background-refresh,persist-model-actor - List not updating (same-context) ->
query-property-wrapper,state-wrapper-views - Duplicates from API sync ->
schema-unique-attributes,sync-conflict-resolution - App crashes on launch after model change ->
schema-migration-recovery,persist-container-error-recovery - Save failures silently losing data ->
crud-save-error-handling - Stale data from network ->
sync-offline-first,sync-fetch-persist - Widget/extension can't see data ->
persist-app-group,schema-configuration - Choosing architecture pattern for data views ->
state-query-vs-viewmodel,persist-repository-wrapper
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Data Modeling | CRITICAL | model- |
| 2 | Persistence Setup | CRITICAL | persist- |
| 3 | Querying & Filtering | HIGH | query- |
| 4 | CRUD Operations | HIGH | crud- |
| 5 | Sync & Networking | HIGH | sync- |
| 6 | Relationships | MEDIUM-HIGH | rel- |
| 7 | SwiftUI State Flow | MEDIUM-HIGH | state- |
| 8 | Schema & Migration | MEDIUM-HIGH | schema- |
| 9 | Sample Data & Previews | MEDIUM | preview- |
Quick Reference
1. Data Modeling (CRITICAL)
- `model-domain-mapping` - Map @Model entities to domain structs across Domain/Data boundaries
- `model-custom-types` - Use custom types over parallel arrays
- `model-class-for-persistence` - Use classes for SwiftData entity types
- `model-identifiable` - Conform entities to Identifiable with UUID
- `model-initializer` - Provide custom initializers for entity classes
- `model-computed-properties` - Use computed properties for derived data
- `model-defaults` - Provide sensible default values for entity properties
- `model-transient` - Mark non-persistent properties with @Transient
- `model-external-storage` - Use external storage for large binary data
2. Persistence Setup (CRITICAL)
- `persist-repository-wrapper` - Wrap SwiftData behind Domain repository protocols
- `persist-model-macro` - Apply @Model macro to all persistent types
- `persist-container-setup` - Configure ModelContainer at the App level
- `persist-container-error-recovery` - Handle ModelContainer creation failure with store recovery
- `persist-context-environment` - Access ModelContext via @Environment (Data layer)
- `persist-autosave` - Enable autosave for manually created contexts
- `persist-enumerate-batch` - Use ModelContext.enumerate for large traversals
- `persist-in-memory-config` - Use in-memory configuration for tests and previews
- `persist-app-group` - Use App Groups for shared data storage
- `persist-model-actor` - Use @ModelActor for background SwiftData work
- `persist-identifier-transfer` - Pass PersistentIdentifier across actors
3. Querying & Filtering (HIGH)
- `query-property-wrapper` - Use @Query for declarative data fetching (Data layer)
- `query-background-refresh` - Force view refresh after background context inserts
- `query-sort-descriptors` - Apply sort descriptors to @Query
- `query-predicates` - Use #Predicate for type-safe filtering
- `query-dynamic-init` - Use custom view initializers for dynamic queries
- `query-fetch-descriptor` - Use FetchDescriptor outside SwiftUI views
- `query-fetch-tuning` - Tune FetchDescriptor paging and pending-change behavior
- `query-localized-search` - Use localizedStandardContains for search
- `query-expression` - Use #Expression for reusable predicate components (iOS 18+)
4. CRUD Operations (HIGH)
- `crud-insert-context` - Insert models via repository implementations
- `crud-delete-indexset` - Delete via repository with IndexSet from onDelete
- `crud-sheet-creation` - Use sheets for focused data creation via ViewModel
- `crud-cancel-delete` - Avoid orphaned records by persisting only on save
- `crud-undo-cancel` - Enable undo and use it to cancel edits
- `crud-edit-button` - Provide EditButton for list management
- `crud-dismiss-save` - Dismiss modal after ViewModel save completes
- `crud-save-error-handling` - Handle repository save failures with user feedback
5. Sync & Networking (HIGH)
- `sync-fetch-persist` - Use injected sync services to fetch and persist API data
- `sync-offline-first` - Design offline-first architecture with repository reads and background sync
- `sync-conflict-resolution` - Implement conflict resolution for bidirectional sync
6. Relationships (MEDIUM-HIGH)
- `rel-optional-single` - Use optionals for optional relationships
- `rel-array-many` - Use arrays for one-to-many relationships
- `rel-inverse-auto` - Rely on SwiftData automatic inverse maintenance
- `rel-delete-rules` - Configure cascade delete rules for owned relationships
- `rel-explicit-sort` - Sort relationship arrays explicitly
7. SwiftUI State Flow (MEDIUM-HIGH)
- `state-query-vs-viewmodel` - Route all data access through @Observable ViewModels
- `state-business-logic-placement` - Place business logic in domain value types and repository-backed ViewModels
- `state-dependency-injection` - Inject repository protocols via @Environment
- `state-bindable` - Use @Bindable for two-way model binding
- `state-local-state` - Use @State for view-local transient data
- `state-wrapper-views` - Extract wrapper views for dynamic query state
8. Schema & Migration (MEDIUM-HIGH)
- `schema-define-all-types` - Define schema with all model types
- `schema-unique-attributes` - Use @Attribute(.unique) for natural keys
- `schema-unique-macro` - Use #Unique for compound uniqueness (iOS 18+)
- `schema-index` - Use #Index for hot predicates and sorts (iOS 18+)
- `schema-migration-plan` - Plan migrations before changing models
- `schema-migration-recovery` - Plan migration recovery for schema changes
- `schema-configuration` - Customize storage with ModelConfiguration
9. Sample Data & Previews (MEDIUM)
- `preview-sample-singleton` - Create a SampleData singleton for previews
- `preview-in-memory` - Use in-memory containers for preview isolation
- `preview-static-data` - Define static sample data on model types
- `preview-main-actor` - Annotate SampleData with @MainActor
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on data integrity, persistence, or SwiftUI integration implications.
Incorrect (description of what's wrong):
// Bad code example — production-realistic, not strawmanCorrect (description of what's right):
// Good code example — minimal diff from incorrectReference: Apple Documentation
{
"version": "1.0.12",
"organization": "Apple Developer + Airbnb/OLX Modular MVVM-C",
"technology": "SwiftData (iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Comprehensive data modeling, persistence, state management, sync architecture, and error handling guide for Swift and SwiftUI applications using SwiftData, aligned with modular MVVM-C repository architecture (see swift-ui-architect). Contains 60 rules across 9 categories, prioritized by impact from critical (data modeling, persistence setup) to production architecture (sync, error recovery, state management). SwiftData types (@Model, ModelContext, @Query) stay in Data-layer implementations behind Domain repository protocols. Views read Domain data from @Observable ViewModels; business logic lives in domain value types and repository-backed flows. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://developer.apple.com/tutorials/develop-in-swift/welcome-to-data-modeling",
"https://developer.apple.com/tutorials/develop-in-swift/model-data-with-custom-types",
"https://developer.apple.com/tutorials/develop-in-swift/model-data-with-custom-types-conclusion",
"https://developer.apple.com/tutorials/develop-in-swift/save-data",
"https://developer.apple.com/tutorials/develop-in-swift/models-and-persistence-conclusion",
"https://developer.apple.com/tutorials/develop-in-swift/navigate-sample-data",
"https://developer.apple.com/tutorials/develop-in-swift/navigate-sample-data-conclusion",
"https://developer.apple.com/tutorials/develop-in-swift/create-update-and-delete-data",
"https://developer.apple.com/tutorials/develop-in-swift/create-update-and-delete-data-conclusion",
"https://developer.apple.com/tutorials/develop-in-swift/work-with-relationships",
"https://developer.apple.com/tutorials/develop-in-swift/navigation-editing-and-relationships-conclusion",
"https://developer.apple.com/documentation/swiftdata/preserving-your-apps-model-data-across-launches",
"https://developer.apple.com/videos/play/wwdc2023/10195/",
"https://developer.apple.com/videos/play/wwdc2023/10196/",
"https://developer.apple.com/videos/play/wwdc2024/10137/",
"https://developer.apple.com/documentation/swiftdata/fetchdescriptor/fetchoffset",
"https://developer.apple.com/forums/thread/761118",
"https://azamsharp.com/2025/03/28/swiftdata-architecture-patterns-and-practices.html",
"https://www.hackingwithswift.com/quick-start/swiftdata/common-swiftdata-errors-and-their-solutions",
"https://fatbobman.com/en/posts/key-considerations-before-using-swiftdata/",
"https://matteomanferdini.com/swiftdata-mvvm/",
"https://www.hackingwithswift.com/quick-start/swiftdata/how-to-use-mvvm-to-separate-swiftdata-from-your-views"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Data Modeling (model)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Impact: CRITICAL Description: @Model entity design and entity-to-domain struct mapping are the foundation. Wrong model definitions cascade into broken persistence, faulty queries, and corrupt relationships. Entity classes live in the Data layer; domain structs (Equatable, Sendable) live in the Domain layer.
2. Persistence Setup (persist)
Impact: CRITICAL Description: ModelContainer, ModelContext, @ModelActor, and repository protocol implementations determine whether data survives app launches and whether concurrent access is safe. Repository protocols are defined in the Domain layer; SwiftData implementations live in the Data layer. Incorrect setup silently loses user data or causes crashes.
3. Querying & Filtering (query)
Impact: HIGH Description: @Query, predicates, and FetchDescriptor control how data reaches views. Inefficient queries cause lag and stale UI. Cross-context staleness is a known framework limitation requiring explicit workarounds.
4. CRUD Operations (crud)
Impact: HIGH Description: Insert, update, and delete patterns routed through ViewModels and repository implementations. All mutations flow View -> ViewModel -> Repository -> SwiftData. Wrong patterns cause data corruption and UI inconsistencies. Error handling for save failures is critical to prevent silent data loss.
5. Sync & Networking (sync)
Impact: HIGH Description: Injected sync services fetch from APIs and persist to SwiftData via @ModelActor. Sync protocols are defined in the Domain layer; implementations live in the Data layer. ViewModels coordinate repository reads and background sync. Wrong patterns cause data races, duplicate records, and stale UI.
6. Relationships (rel)
Impact: MEDIUM-HIGH Description: One-to-many, inverse relationships, and delete rules. Misconfigured relationships orphan data or cascade deletes unexpectedly.
7. SwiftUI State Flow (state)
Impact: MEDIUM-HIGH Description: @Observable ViewModels, @Bindable, @State, and @Environment coordinate data flow through the view hierarchy following modular MVVM-C repository architecture. All data access routes through ViewModels backed by repository protocols. Business logic lives in domain value types and repository-backed flows, not in views.
8. Schema & Migration (schema)
Impact: MEDIUM-HIGH Description: Schema definition, @Attribute customizations, and migration strategies. Unplanned schema changes crash existing users on app update — a botched migration causes 100% crash rate.
9. Sample Data & Previews (preview)
Impact: MEDIUM Description: SampleData singleton and in-memory containers ensure reliable previews. Bad preview setup wastes development time with duplicate or missing data.
Avoid Orphaned Records by Persisting Only on Save
In the modular MVVM-C architecture, the creation form works with a domain struct in memory. Persistence only happens when the user confirms via the ViewModel and repository. Cancellation discards the in-memory struct — no cleanup needed. This eliminates the orphaned-record problem that arises when inserting entities before the form is complete.
Incorrect (insert-before-present pattern — orphaned records on cancel):
@Equatable
struct FriendDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Bindable var friend: FriendEntity
var isNew: Bool
var body: some View {
Form {
TextField("Name", text: $friend.name)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
if isNew {
context.delete(friend) // Must clean up orphaned entity
}
dismiss()
}
}
}
}
}Correct (domain struct in memory until save — no orphaned records):
@Observable
final class FriendEditorViewModel {
private let friendRepository: FriendRepository
var friend: Friend
var isSaved = false
let isNew: Bool
init(friend: Friend, isNew: Bool, friendRepository: FriendRepository) {
self.friend = friend
self.isNew = isNew
self.friendRepository = friendRepository
}
func save() async throws {
try await friendRepository.save(friend)
isSaved = true
}
}
@Equatable
struct FriendEditorView: View {
@State private var viewModel: FriendEditorViewModel
@Environment(\.dismiss) private var dismiss
init(friend: Friend, isNew: Bool, friendRepository: FriendRepository) {
_viewModel = State(initialValue: FriendEditorViewModel(
friend: friend, isNew: isNew, friendRepository: friendRepository
))
}
var body: some View {
Form {
TextField("Name", text: $viewModel.friend.name)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss() // No cleanup — nothing was persisted
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Task {
try? await viewModel.save()
dismiss()
}
}
}
}
}
}When NOT to use:
- When editing an existing model — cancellation should revert changes (reload from repository or use undo manager)
Reference: Develop in Swift — Create, Update, and Delete Data
Delete via Repository with IndexSet from onDelete Modifier
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
SwiftUI's .onDelete modifier provides the standard swipe-to-delete gesture for list rows. It passes an IndexSet mapping to the ForEach data source. The view delegates the delete operation to the ViewModel, which calls the repository. The repository handles ModelContext.delete() in the Data layer.
Incorrect (view deletes from ModelContext directly — persistence logic in presentation):
@Equatable
struct FriendList: View {
@Query(sort: \FriendEntity.name) private var friends: [FriendEntity]
@Environment(\.modelContext) private var context
var body: some View {
List {
ForEach(friends) { friend in
Text(friend.name)
}
.onDelete { offsets in
for index in offsets {
context.delete(friends[index]) // Data layer logic in view
}
}
}
}
}Correct (View -> ViewModel -> Repository — clean deletion flow):
@Observable
final class FriendListViewModel {
private let friendRepository: FriendRepository
var friends: [Friend] = []
var errorMessage: String?
init(friendRepository: FriendRepository) {
self.friendRepository = friendRepository
}
func loadFriends() async {
friends = (try? await friendRepository.fetchAll()) ?? []
}
func deleteFriends(at offsets: IndexSet) async {
let idsToDelete = offsets.map { friends[$0].id }
friends.remove(atOffsets: offsets) // Optimistic UI update
for id in idsToDelete {
do {
try await friendRepository.delete(id: id)
} catch {
errorMessage = error.localizedDescription
await loadFriends() // Revert on failure
}
}
}
}
@Equatable
struct FriendList: View {
@State private var viewModel: FriendListViewModel
init(friendRepository: FriendRepository) {
_viewModel = State(initialValue: FriendListViewModel(friendRepository: friendRepository))
}
var body: some View {
List {
ForEach(viewModel.friends) { friend in
Text(friend.name)
}
.onDelete { offsets in
Task { await viewModel.deleteFriends(at: offsets) }
}
}
.task { await viewModel.loadFriends() }
}
}Benefits:
- Provides the familiar iOS swipe-to-delete interaction
- Optimistic UI update keeps the interface responsive
- Repository handles persistence — view only handles layout
- Delete errors are surfaced to the user, not silently ignored
Reference: Develop in Swift — Create, Update, and Delete Data
Dismiss Modal After ViewModel Save Completes
Use @Environment(\.dismiss) to close sheets after the ViewModel confirms a successful save. The ViewModel calls the repository's save() method and sets a isSaved flag. The view observes this flag and dismisses. This ensures the user is never dismissed before their data is persisted.
Incorrect (dismiss without confirming save — may lose data):
@Equatable
struct FriendDetailView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: FriendEditorViewModel
var body: some View {
Form {
TextField("Name", text: $viewModel.friend.name)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Task { await viewModel.save() }
dismiss() // Dismisses immediately — save may not have completed
}
}
}
}
}Correct (dismiss only after save confirms):
@Equatable
struct FriendDetailView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: FriendEditorViewModel
init(friend: Friend, friendRepository: FriendRepository) {
_viewModel = State(initialValue: FriendEditorViewModel(
friend: friend, isNew: true, friendRepository: friendRepository
))
}
var body: some View {
Form {
TextField("Name", text: $viewModel.friend.name)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
Task { await viewModel.save() }
}
}
}
.onChange(of: viewModel.isSaved) { _, saved in
if saved { dismiss() }
}
}
}Reconciliation with autosave: If your repository implementation relies on SwiftData autosave (no explicit context.save()), the repository save() method should still explicitly call context.save() to ensure data is flushed before returning. For flows where autosave is acceptable (e.g., editing existing records with @Bindable), dismiss immediately — but only if you have verified autosave is enabled on the container. See `crud-save-error-handling` for the error handling pattern.
Benefits:
- User data is confirmed persisted before the modal closes
- Async save completion prevents race conditions
- Clean separation: ViewModel owns the save lifecycle, view owns the dismiss
Reference: Develop in Swift — Create, Update, and Delete Data
Provide EditButton for List Management
EditButton toggles the list into edit mode, revealing delete indicators for all rows. It provides an accessible alternative to swipe gestures that some users with motor impairments find difficult to perform.
Incorrect (only swipe-to-delete — poor accessibility):
@Equatable
struct FriendList: View {
@State private var viewModel: FriendListViewModel
var body: some View {
NavigationStack {
List {
ForEach(viewModel.friends) { friend in
Text(friend.name)
}
.onDelete { offsets in
Task { await viewModel.deleteFriends(at: offsets) }
}
}
// No EditButton — users must discover swipe gesture on their own
}
}
}Correct (EditButton alongside swipe-to-delete):
@Equatable
struct FriendList: View {
@State private var viewModel: FriendListViewModel
init(friendRepository: FriendRepository) {
_viewModel = State(initialValue: FriendListViewModel(friendRepository: friendRepository))
}
var body: some View {
NavigationStack {
List {
ForEach(viewModel.friends) { friend in
Text(friend.name)
}
.onDelete { offsets in
Task { await viewModel.deleteFriends(at: offsets) }
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
EditButton()
}
}
}
.task { await viewModel.loadFriends() }
}
}Benefits:
- Accessible to users who cannot perform swipe gestures
- Reveals delete indicators for all rows simultaneously, enabling bulk review
- Standard UIKit/SwiftUI pattern that users recognize
Reference: Develop in Swift — Create, Update, and Delete Data
Insert Models via ModelContext in Repository Implementations
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Creating an entity instance with FriendEntity(name: "x") only allocates it in memory. You must call context.insert() to register it with SwiftData for persistence. This operation belongs in repository implementations (Data layer), not in views or ViewModels.
Incorrect (view inserts directly into ModelContext — bypasses architecture layers):
@Equatable
struct AddFriendView: View {
@Environment(\.modelContext) private var context
var body: some View {
Button("Add Friend") {
let friend = FriendEntity(name: "New Friend")
context.insert(friend) // Persistence logic in view — violates layer boundary
}
}
}Correct (repository handles insertion, ViewModel coordinates, view triggers):
// Data/Repositories/SwiftDataFriendRepository.swift
final class SwiftDataFriendRepository: FriendRepository, @unchecked Sendable {
private let modelContainer: ModelContainer
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
@MainActor
func save(_ friend: Friend) async throws {
let entity = FriendEntity(name: friend.name, birthday: friend.birthday)
modelContainer.mainContext.insert(entity) // Data layer owns insert
try modelContainer.mainContext.save()
}
}
// Presentation/ViewModels/AddFriendViewModel.swift
@Observable
final class AddFriendViewModel {
private let friendRepository: FriendRepository
var isSaved = false
init(friendRepository: FriendRepository) {
self.friendRepository = friendRepository
}
func addFriend(name: String) async {
let friend = Friend(id: UUID().uuidString, name: name, birthday: .now)
try? await friendRepository.save(friend)
isSaved = true
}
}
// Presentation/Views/AddFriendView.swift
@Equatable
struct AddFriendView: View {
@State private var viewModel: AddFriendViewModel
init(friendRepository: FriendRepository) {
_viewModel = State(initialValue: AddFriendViewModel(friendRepository: friendRepository))
}
var body: some View {
Button("Add Friend") {
Task { await viewModel.addFriend(name: "New Friend") }
}
}
}When NOT to use:
- Temporary objects used only for computation that should never be saved
- Preview or test data that uses an in-memory model container
Reference: Develop in Swift — Save Data
Handle Repository Save Failures With User Feedback
Repository save() calls can fail due to uniqueness constraint violations, validation errors, or underlying store issues. The ViewModel must catch errors and surface them to the view as display-ready state. Silently swallowing errors with try? means the user thinks their data was saved when it was not.
Incorrect (save error silently ignored in ViewModel):
@Observable
final class TripEditorViewModel {
private let tripRepository: TripRepository
func save(_ trip: Trip) async {
try? await tripRepository.save(trip) // Failure silently swallowed
// User thinks trip was saved — it was not
}
}Correct (ViewModel catches error, view presents it):
@Observable
final class TripEditorViewModel {
private let tripRepository: TripRepository
var trip: Trip
var saveError: String?
var isSaved = false
init(trip: Trip, tripRepository: TripRepository) {
self.trip = trip
self.tripRepository = tripRepository
}
func save() async {
do {
try await tripRepository.save(trip)
isSaved = true
} catch {
saveError = error.localizedDescription
}
}
}@Equatable
struct TripEditorView: View {
@State private var viewModel: TripEditorViewModel
@Environment(\.dismiss) private var dismiss
init(trip: Trip, tripRepository: TripRepository) {
_viewModel = State(initialValue: TripEditorViewModel(trip: trip, tripRepository: tripRepository))
}
var body: some View {
Form {
TextField("Name", text: $viewModel.trip.name)
DatePicker("Start", selection: $viewModel.trip.startDate)
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save") { Task { await viewModel.save() } }
}
}
.onChange(of: viewModel.isSaved) { _, saved in
if saved { dismiss() }
}
.alert("Unable to Save", isPresented: Binding(
get: { viewModel.saveError != nil },
set: { if !$0 { viewModel.saveError = nil } }
)) {
Button("Retry") { Task { await viewModel.save() } }
Button("Discard", role: .destructive) { dismiss() }
} message: {
Text(viewModel.saveError ?? "An unknown error occurred.")
}
}
}When NOT to use:
- Preview and test code where save failures should crash immediately to surface bugs
Benefits:
- User always knows whether their data was persisted
- Retry option recovers from transient failures
- Error state is testable in the ViewModel without SwiftUI
Reference: SwiftData — Saving Models with ModelContext — Medium
Use Sheets for Focused Data Creation via ViewModel
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Present new item creation in a sheet rather than pushing a navigation view. Sheets keep users focused on the creation task and provide a clear Save/Cancel flow. The ViewModel manages creation state and delegates persistence to the repository. Combined with .interactiveDismissDisabled(), sheets prevent accidental dismissal.
Incorrect (view creates and inserts entity directly — persistence logic in presentation):
@Equatable
struct FriendList: View {
@Environment(\.modelContext) private var context
@Query(sort: \FriendEntity.name) private var friends: [FriendEntity]
@State private var newFriend: FriendEntity?
var body: some View {
NavigationStack {
List(friends) { friend in
Text(friend.name)
}
.toolbar {
Button("Add Friend") {
let friend = FriendEntity(name: "")
context.insert(friend) // Data layer logic in view
newFriend = friend
}
}
}
}
}Correct (ViewModel manages creation flow, repository handles persistence):
@Observable
final class FriendListViewModel {
private let friendRepository: FriendRepository
var friends: [Friend] = []
var isCreating = false
var newFriend = Friend(id: UUID().uuidString, name: "", birthday: .now)
init(friendRepository: FriendRepository) {
self.friendRepository = friendRepository
}
func loadFriends() async {
friends = (try? await friendRepository.fetchAll()) ?? []
}
func startCreation() {
newFriend = Friend(id: UUID().uuidString, name: "", birthday: .now)
isCreating = true
}
func saveNewFriend() async {
try? await friendRepository.save(newFriend)
isCreating = false
await loadFriends()
}
func cancelCreation() {
isCreating = false // No orphaned records — nothing was persisted yet
}
}@Equatable
struct FriendListView: View {
@State private var viewModel: FriendListViewModel
init(friendRepository: FriendRepository) {
_viewModel = State(initialValue: FriendListViewModel(friendRepository: friendRepository))
}
var body: some View {
NavigationStack {
List(viewModel.friends) { friend in
Text(friend.name)
}
.toolbar {
Button("Add Friend") { viewModel.startCreation() }
}
.sheet(isPresented: $viewModel.isCreating) {
NavigationStack {
FriendEditorView(
friend: $viewModel.newFriend,
onSave: { Task { await viewModel.saveNewFriend() } },
onCancel: { viewModel.cancelCreation() }
)
}
.interactiveDismissDisabled()
}
}
.task { await viewModel.loadFriends() }
}
}Key advantage over insert-before-present: The domain struct is created in memory without being persisted. Cancellation simply discards the struct — no orphaned records in the database, no cleanup needed.
Benefits:
- Clear modal context signals "you are creating something new"
.interactiveDismissDisabled()prevents accidental swipe-to-dismiss- Save/Cancel buttons provide explicit intent
Reference: Develop in Swift — Create, Update, and Delete Data
Enable Undo and Use It to Cancel Edits
SwiftData can integrate with the system UndoManager so edits to persistent entities can be undone and redone using standard platform gestures. With undo enabled, you can implement a "Cancel" flow for editing an existing entity without copying every field into a separate draft state. This is a Data layer technique — it applies when editing @Model entities directly (e.g., in a detail view that wraps entity binding via the repository layer).
Incorrect (manual draft state copy for editing):
import SwiftUI
import SwiftData
struct TripEditView: View {
@Bindable var trip: TripEntity
@State private var draftName: String = ""
@State private var draftStart: Date = .now
@State private var draftEnd: Date = .now
var body: some View {
Form {
TextField("Name", text: $draftName)
DatePicker("Start", selection: $draftStart)
DatePicker("End", selection: $draftEnd)
}
.onAppear {
draftName = trip.name
draftStart = trip.startDate
draftEnd = trip.endDate
}
.toolbar {
Button("Cancel") {
// Easy to forget fields/relationships; lots of extra plumbing.
}
Button("Save") {
trip.name = draftName
trip.startDate = draftStart
trip.endDate = draftEnd
}
}
}
}Prerequisite: Enable undo on the container: .modelContainer(for: TripEntity.self, isUndoEnabled: true)
Correct (undo grouping for cancel-on-edit):
struct TripEditView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.undoManager) private var undoManager
@Bindable var trip: Trip
@State private var didBeginUndoGroup = false
var body: some View {
Form {
TextField("Name", text: $trip.name)
DatePicker("Start", selection: $trip.startDate)
DatePicker("End", selection: $trip.endDate)
}
.onAppear {
undoManager?.removeAllActions()
undoManager?.beginUndoGrouping()
didBeginUndoGroup = true
}
.onDisappear {
if didBeginUndoGroup {
undoManager?.endUndoGrouping()
didBeginUndoGroup = false
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
if didBeginUndoGroup {
undoManager?.endUndoGrouping()
didBeginUndoGroup = false
}
if undoManager?.canUndo == true {
undoManager?.undo()
}
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
if didBeginUndoGroup {
undoManager?.endUndoGrouping()
didBeginUndoGroup = false
}
dismiss()
}
}
}
}
}When NOT to use:
- You didn't enable undo on the model container (
isUndoEnabled: true) - You need a "draft review" flow with validation before touching the persistent model (use a separate draft type)
Benefits:
- System undo/redo gestures work automatically for model edits
- "Cancel" for editing existing models can revert without manual state-copy plumbing
- Keeps edit views small and focused (fewer extra
@Stateproperties)
Reference: Dive deeper into SwiftData
Use Classes for SwiftData Persistent Models
SwiftData requires classes (not structs) because persistent instances need built-in identity for sharing across the app. When multiple views reference the same record, they must all point to the same object. Struct copies would break SwiftData's change tracking and cause silent data loss.
Incorrect (struct with @Model — compiler error):
import SwiftData
// ERROR: @Model requires a class declaration
@Model struct Friend {
var name: String
var birthday: Date
}Correct (class with @Model):
import SwiftData
@Model class Friend {
var name: String
var birthday: Date
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
}
}When NOT to use:
- Plain data transfer objects that never touch persistence can stay as structs
- Embedded value types within a model (e.g.,
struct Address: Codable) are fine as stored properties
Reference: Develop in Swift — Save Data
Use Computed Properties for Derived Data
When a value depends entirely on other properties (e.g., "is birthday today", "full name"), compute it rather than storing it. Stored derived data becomes stale the moment a source property changes, leading to bugs that are hard to trace.
Incorrect (stored derived value becomes stale):
@Model class Friend {
var name: String
var birthday: Date
var isBirthdayToday: Bool // Stored — stale after midnight
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
self.isBirthdayToday = Calendar.current.isDateInToday(birthday)
// This value is frozen at creation time and never updates
}
}Correct (computed property is always fresh):
@Model class Friend {
var name: String
var birthday: Date
var isBirthdayToday: Bool {
Calendar.current.isDateInToday(birthday)
}
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
}
}Benefits:
- Always returns the correct value — no risk of staleness
- Not persisted to disk — saves storage and avoids migration headaches
- Single source of truth — logic lives in one place
When NOT to use:
- Expensive computations that rarely change — consider caching with
@Transientand manual invalidation - Values needed in SwiftData predicates —
#Predicatecannot evaluate computed properties
Reference: Develop in Swift — Create, Update, and Delete Data
Use Custom Types Over Parallel Arrays
Modeling related properties in separate arrays (e.g., [String] for names, [Int] for scores) creates inconsistent states when one array is modified without the other. A single struct groups related data so it cannot go out of sync.
Incorrect (parallel arrays drift out of sync):
@Model class Leaderboard {
var names: [String] = []
var scores: [Int] = []
func addPlayer(name: String) {
names.append(name)
// Forgot to append to scores — arrays are now different lengths
// scores[names.count - 1] will crash with index out of range
}
}Correct (custom type keeps data together):
struct Player: Codable {
var name: String
var score: Int
}
@Model class Leaderboard {
var players: [Player] = []
func addPlayer(name: String, score: Int) {
players.append(Player(name: name, score: score))
// Impossible for name and score to go out of sync
}
}Benefits:
- Eliminates an entire class of index-out-of-range crashes
- Makes the data model self-documenting
- Simplifies iteration —
for player in playersvs.for i in 0..<names.count Codableconformance is required because SwiftData stores embedded value types as JSON blobs in the SQLite store
Provide Sensible Default Values for Model Properties
Default values reduce initializer boilerplate and prevent nil-related crashes. For collections, default to empty arrays. For optional relationships, use nil. For dates, use .now. This makes model creation simpler at every call site.
Incorrect (every property required at init):
@Model class Trip {
var destination: String
var startDate: Date
var endDate: Date
var notes: String
var photos: [String]
var rating: Int
// Caller must provide every value, even for a quick draft
init(destination: String, startDate: Date, endDate: Date,
notes: String, photos: [String], rating: Int) {
self.destination = destination
self.startDate = startDate
self.endDate = endDate
self.notes = notes
self.photos = photos
self.rating = rating
}
}
// Painful to use for a quick entry
let trip = Trip(destination: "Paris", startDate: .now, endDate: .now,
notes: "", photos: [], rating: 0)Correct (sensible defaults for optional/collection properties):
@Model class Trip {
var destination: String
var startDate: Date = .now
var endDate: Date = .now
var notes: String = ""
var photos: [String] = []
var rating: Int = 0
init(destination: String, startDate: Date = .now, endDate: Date = .now,
notes: String = "", photos: [String] = [], rating: Int = 0) {
self.destination = destination
self.startDate = startDate
self.endDate = endDate
self.notes = notes
self.photos = photos
self.rating = rating
}
}
// Clean one-liner for quick entries
let trip = Trip(destination: "Paris")Benefits:
- Call sites only specify what they care about
- Fewer optionals means fewer force-unwrap crashes
- New properties with defaults don't break existing code
Map @Model Entities to Domain Structs
@Model classes are persistence entities — they belong in the Data layer. Domain models must be pure Swift structs conforming to Equatable and Sendable, with zero framework imports. This separation ensures domain logic is testable without SwiftData, portable across platforms, and immune to framework changes. Every @Model class should have a corresponding domain struct and bidirectional mapping methods.
Incorrect (using @Model classes as domain models — couples business logic to persistence):
import SwiftData
import SwiftUI
// @Model class used everywhere — domain logic depends on SwiftData
@Model class Trip {
var name: String
var startDate: Date
var endDate: Date
init(name: String, startDate: Date, endDate: Date) {
self.name = name
self.startDate = startDate
self.endDate = endDate
}
// Business logic on a framework type — untestable without SwiftData
func validate() throws {
guard !name.isEmpty else { throw TripError.emptyName }
guard endDate > startDate else { throw TripError.endBeforeStart }
}
var durationInDays: Int {
Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
}
}
// ViewModel directly references @Model — coupled to persistence framework
@Observable
final class TripListViewModel {
var trips: [Trip] = [] // SwiftData type in presentation layer
}Correct (domain struct + @Model entity + mapping — modular Domain/Data layers):
// Domain/Models/Trip.swift — pure Swift, zero imports
struct Trip: Equatable, Sendable {
let id: String
let name: String
let startDate: Date
let endDate: Date
var durationInDays: Int {
Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
}
func validate() throws {
guard !name.trimmingCharacters(in: .whitespaces).isEmpty else {
throw TripValidationError.emptyName
}
guard endDate > startDate else {
throw TripValidationError.endBeforeStart
}
}
}
enum TripValidationError: LocalizedError {
case emptyName, endBeforeStart
var errorDescription: String? {
switch self {
case .emptyName: return "Trip name cannot be empty."
case .endBeforeStart: return "End date must be after start date."
}
}
}// Data/Entities/TripEntity.swift — SwiftData persistence
import SwiftData
@Model class TripEntity {
@Attribute(.unique) var remoteId: String
var name: String
var startDate: Date
var endDate: Date
init(remoteId: String, name: String, startDate: Date, endDate: Date) {
self.remoteId = remoteId
self.name = name
self.startDate = startDate
self.endDate = endDate
}
func toDomain() -> Trip {
Trip(id: remoteId, name: name, startDate: startDate, endDate: endDate)
}
func update(from domain: Trip) {
name = domain.name
startDate = domain.startDate
endDate = domain.endDate
}
}Naming convention:
- Domain models:
Trip,Friend,Event(clean names) - SwiftData entities:
TripEntity,FriendEntity,EventEntity(suffixed) - DTOs:
TripDTO,FriendDTO(for network responses)
When NOT to use:
- Prototype or hackathon apps where speed trumps architecture
- Single-screen utility apps with no business logic beyond CRUD
Benefits:
- Domain models are testable without Xcode simulators or SwiftData framework
- Value semantics prevent shared mutable state bugs across ViewModels
Equatableconformance enables efficient SwiftUI diffingSendableconformance enables safe actor boundary crossing
Reference: Clean Architecture for SwiftUI
Use External Storage for Large Binary Data
When a model stores large binary data (images, PDFs, audio), mark the property with @Attribute(.externalStorage). SwiftData stores the binary as a separate file on disk and keeps only a reference in the SQLite database. Without this, large blobs are inlined into the database, degrading query performance and inflating backup sizes.
Incorrect (large Data inlined into SQLite):
@Model class Photo {
var caption: String
var imageData: Data // Stored inline — a 5 MB image bloats every query
init(caption: String, imageData: Data) {
self.caption = caption
self.imageData = imageData
}
}Correct (external storage for binary data):
@Model class Photo {
var caption: String
@Attribute(.externalStorage) var imageData: Data // Stored as separate file
init(caption: String, imageData: Data) {
self.caption = caption
self.imageData = imageData
}
}When NOT to use:
- Small data (< 100 KB) — the overhead of a separate file is not justified
- Data that must be included in SQLite queries or predicates — external storage data cannot be filtered on
Benefits:
- SQLite database stays small and fast for queries
- Binary data is loaded on demand, not with every fetch
- Reduces memory pressure when iterating over model collections
Conform Models to Identifiable with UUID
SwiftUI's ForEach and List require stable identity to correctly diff and animate changes. Without Identifiable conformance backed by a UUID, items with the same display value collide and UI updates break. @Model classes get Identifiable automatically, but plain structs must add it explicitly.
Incorrect (identity based on non-unique property):
struct Player {
var name: String
var score: Int
}
// Two players named "Alex" will clash — SwiftUI can't tell them apart
ForEach(players, id: \.name) { player in
Text("\(player.name): \(player.score)")
}Correct (UUID-backed Identifiable):
struct Player: Identifiable {
let id = UUID()
var name: String
var score: Int
}
// Each player has a unique id, even if names match
ForEach(players) { player in
Text("\(player.name): \(player.score)")
}When NOT to use:
@Modelclasses already conform toIdentifiable— no need to add it manually- If an external API provides a guaranteed-unique ID (e.g., database primary key), use that instead of generating a new UUID
Provide Custom Initializers for Model Classes
Unlike structs, Swift classes do not get automatic memberwise initializers. Every @Model class must declare its own init or the project will not compile. This is one of the most common mistakes when converting struct-based prototypes to SwiftData models.
Incorrect (missing initializer — won't compile):
import SwiftData
@Model class Friend {
var name: String
var birthday: Date
// ERROR: Class 'Friend' has no initializers
}Correct (explicit memberwise initializer):
import SwiftData
@Model class Friend {
var name: String
var birthday: Date
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
}
}Alternative (defaults reduce init parameters):
@Model class Friend {
var name: String
var birthday: Date = .now
var notes: String = ""
init(name: String, birthday: Date = .now, notes: String = "") {
self.name = name
self.birthday = birthday
self.notes = notes
}
}
// Can now create with just a name
let friend = Friend(name: "Alex")Reference: Develop in Swift — Save Data
Mark Non-Persistent Properties with @Transient
Properties that should not be persisted (runtime-only state, cached computations, ephemeral data) must be marked @Transient. Otherwise SwiftData stores them to disk, wasting space and complicating schema migrations when you later remove them.
Incorrect (ephemeral data persisted to disk):
@Model class Trip {
var destination: String
var startDate: Date
var currentWeather: String = "unknown" // Persisted — stale on next launch
var isSelected: Bool = false // UI state saved to disk unnecessarily
init(destination: String, startDate: Date) {
self.destination = destination
self.startDate = startDate
}
}Correct (transient properties excluded from storage):
@Model class Trip {
var destination: String
var startDate: Date
@Transient var currentWeather: String = "unknown" // Fetched fresh each launch
@Transient var isSelected: Bool = false // UI-only, never persisted
init(destination: String, startDate: Date) {
self.destination = destination
self.startDate = startDate
}
}When NOT to use:
- Data the user expects to survive app restarts must remain persistent
- Properties used in
#Predicatequeries — transient properties cannot be queried
Benefits:
- Smaller database footprint
- No migration needed if transient properties change
- Clear separation between persistent state and runtime state
Use App Groups for Shared Data Storage
By default, SwiftData stores data in the app's sandbox, inaccessible to extensions. To share data between your main app and its extensions (widgets, watch complications, share extensions), configure the ModelContainer with an App Group identifier.
Incorrect (default configuration — widget can't access data):
// Main App
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Trip.self)
// Data stored in app sandbox — widget has its own empty sandbox
}
}
// Widget — has no access to the app's database
struct TripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "trip", provider: TripProvider()) { entry in
TripWidgetView(entry: entry)
}
.modelContainer(for: Trip.self)
// Creates a SEPARATE empty database in the widget's sandbox
}
}Correct (shared App Group container):
// Shared configuration used by both app and widget
extension ModelContainer {
static let shared: ModelContainer = {
let config = ModelConfiguration(
groupContainer: .identifier("group.com.example.myapp")
)
return try! ModelContainer(for: Trip.self, configurations: config)
}()
}
// Main App
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(ModelContainer.shared)
}
}
// Widget — reads the same database
struct TripWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "trip", provider: TripProvider()) { entry in
TripWidgetView(entry: entry)
.modelContainer(ModelContainer.shared)
}
}
}When NOT to use:
- Apps without extensions do not need App Groups
- If extensions only need a small subset of data, consider passing it via UserDefaults(suiteName:) instead
Enable Autosave for Manually Created Contexts
The environment-provided ModelContext has autosave enabled by default — changes are persisted automatically at appropriate intervals. However, if you create a context manually (for background work or batch operations), autosave is disabled. You must either enable it or call try context.save() explicitly, or all inserted data is lost when the app quits.
Incorrect (manual context without save — data lost):
func importFriends(from data: [FriendData], container: ModelContainer) {
let context = ModelContext(container)
for item in data {
let friend = Friend(name: item.name, birthday: item.birthday)
context.insert(friend)
}
// Context is deallocated — all inserts are lost
// No autosave, no explicit save
}Correct (explicit save — recommended for batch operations):
func importFriends(from data: [FriendData], container: ModelContainer) throws {
let context = ModelContext(container)
for item in data {
let friend = Friend(name: item.name, birthday: item.birthday)
context.insert(friend)
}
try context.save() // Persists all changes atomically
}Alternative: For long-running producers where partial persistence is acceptable, you can enable autosave: context.autosaveEnabled = true. Avoid this for imports — autosave may fire mid-import, persisting an incomplete dataset if the import fails halfway.
When NOT to use:
- The
@Environment(\.modelContext)context already has autosave — no action needed - For batch imports where you want to validate before committing, keep autosave off and call
save()only after validation passes
Handle ModelContainer Creation Failure with Store Recovery
ModelContainer creation can fail due to a corrupt store file, an incompatible schema change without a migration plan, or file permission issues. Using .modelContainer(for:) in the App declaration crashes the app immediately with no recovery path. In production, wrap container creation in a do-catch and provide a fallback — either delete the corrupt store and recreate, or fall back to an in-memory container so the app remains usable.
Incorrect (.modelContainer crashes on failure — permanent app death):
import SwiftUI
import SwiftData
@main
struct TripApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// If the store is corrupt, this crashes on launch with no recovery
.modelContainer(for: Trip.self)
}
}Correct (do-catch with store recovery fallback):
import SwiftUI
import SwiftData
import os
@main
struct TripApp: App {
private let modelContainer: ModelContainer
init() {
let schema = Schema([Trip.self, Accommodation.self])
do {
modelContainer = try ModelContainer(for: schema)
} catch {
Logger.persistence.error("Store creation failed: \(error). Attempting recovery.")
// Attempt recovery: delete the corrupt store and recreate
let storeURL = URL.applicationSupportDirectory
.appending(path: "default.store")
try? FileManager.default.removeItem(at: storeURL)
do {
modelContainer = try ModelContainer(for: schema)
Logger.persistence.info("Store recovery succeeded — data was reset.")
} catch {
// Last resort: in-memory container so the app at least launches
Logger.persistence.critical("Store recovery failed: \(error). Using in-memory store.")
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try! ModelContainer(for: schema, configurations: [config])
}
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(modelContainer)
}
}
extension Logger {
static let persistence = Logger(subsystem: Bundle.main.bundleIdentifier ?? "app", category: "persistence")
}When NOT to use:
- Test targets and previews — use in-memory containers directly, no recovery needed
- When data loss is absolutely unacceptable — consider backing up the store file before deletion
Benefits:
- App always launches, even with a corrupt store
- Structured logging captures the failure for diagnostics
- In-memory fallback prevents permanent app death while alerting the user
- Recovery path can be extended to attempt backup restoration before deletion
Reference: Common SwiftData errors and their solutions — Hacking with Swift
Configure ModelContainer at the App Level
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
The ModelContainer must be attached to the top-level App or WindowGroup using the .modelContainer(for:) modifier. Without it, @Query returns empty results and context.insert() has nowhere to save — the app compiles and runs but silently loses all data.
Incorrect (no model container — data never persists):
import SwiftUI
@main
struct FriendsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
// No .modelContainer — @Query always returns []
// context.insert() silently fails or crashes
}
}
}Correct (container configured at app level):
import SwiftUI
import SwiftData
@main
struct FriendsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Friend.self)
}
}Alternative (multiple model types):
@main
struct FriendsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Friend.self, Event.self, Photo.self])
}
}Production caveat: .modelContainer(for:) crashes the app if the store cannot be created (corrupt file, incompatible schema without migration). For production apps, create the ModelContainer manually in the App.init() with a do-catch and fallback. See `persist-container-error-recovery` for the full recovery pattern.
When NOT to use:
- Unit tests and previews should use in-memory containers instead of the shared app container
Reference: Develop in Swift — Save Data
Access ModelContext via @Environment (Data Layer)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Within the Data layer, always access the ModelContext through the container you set up at the app level. Creating your own context without proper configuration leads to data saving to a separate store that the rest of your app never reads.
Architecture note: In modular MVVM-C, ModelContext is a Data-layer concern. It belongs in repository implementations, not in feature views or ViewModels. Views access data through @Observable ViewModels backed by repository protocols. See `persist-repository-wrapper` for the repository pattern and `state-dependency-injection` for injecting repositories via @Environment.
Incorrect (manually created context — separate store):
@Equatable
struct FriendListView: View {
// Creates a completely separate store — data saved here is invisible to @Query
let context = try! ModelContext(ModelContainer(for: Friend.self))
var body: some View {
Button("Add Friend") {
let friend = Friend(name: "Alex", birthday: .now)
context.insert(friend)
// Saved to a different database file — won't appear in the app's queries
}
}
}Correct (environment-provided context):
@Equatable
struct FriendListView: View {
@Environment(\.modelContext) private var context
@Query private var friends: [Friend]
var body: some View {
List(friends) { friend in
Text(friend.name)
}
Button("Add Friend") {
let friend = Friend(name: "Alex", birthday: .now)
context.insert(friend)
// Uses the same store as @Query — friend appears in the list immediately
}
}
}When NOT to use:
- Background tasks that need their own context should create one from the shared container:
ModelContext(container)— but pass the same container, not a new one - Non-View code (services, repositories, background tasks) can accept a
ModelContextas a dependency to keep SwiftUI out of the data layer
Reference: Develop in Swift — Save Data
Use ModelContext.enumerate for Large Traversals
For large traversals (imports, maintenance jobs, background processing), prefer ModelContext.enumerate over fetch + for-in. enumerate batches objects automatically (default batch size is 5,000) and includes a mutation guard to prevent performance issues from a "dirty" context during enumeration.
Incorrect (fetch everything, then traverse a large array in memory):
import SwiftData
func normalizeTripNames(context: ModelContext) throws {
// Pulls the entire dataset into memory at once.
let trips = try context.fetch(FetchDescriptor<Trip>())
for trip in trips {
trip.name = trip.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
try context.save()
}Correct (enumerate in batches; opt into mutations when you intend to modify models):
import SwiftData
func normalizeTripNames(context: ModelContext) throws {
let descriptor = FetchDescriptor<Trip>()
try context.enumerate(
descriptor,
batchSize: 1000,
allowEscapingMutations: true
) { trip in
trip.name = trip.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
try context.save()
}Pitfalls:
enumeratethrows if it detects theModelContextis dirty andallowEscapingMutationsis not set- If you mutate a lot of objects, consider saving periodically (and structuring work so the context can release objects) to avoid unbounded memory growth
Benefits:
- Efficient traversal for large datasets via batching
- Mutation guard prevents a common class of large-traversal performance problems
- Configurable batch size to trade memory for I/O based on your object graph
Reference: Dive deeper into SwiftData
Pass PersistentIdentifier Instead of Model Objects Across Actors
PersistentModel objects are not Sendable — they are tied to the ModelContext that created them. To reference a model from another actor (e.g., a background importer returning results to the main actor), pass its PersistentIdentifier and re-fetch the object in the target context.
Incorrect (passing model object across actors — crash or silent corruption):
@ModelActor
actor TripProcessor {
func processTrip(_ trip: Trip) throws {
// BUG: trip belongs to the main actor's context
// Accessing it here causes a data race
trip.name = trip.name.trimmingCharacters(in: .whitespaces)
try modelContext.save()
}
}Correct (pass identifier, re-fetch in local context):
@ModelActor
actor TripProcessor {
func processTrip(id: PersistentIdentifier) throws {
guard let trip = modelContext.model(for: id) as? Trip else { return }
trip.name = trip.name.trimmingCharacters(in: .whitespaces)
try modelContext.save()
}
}
// Caller passes the identifier, not the object
let processor = TripProcessor(modelContainer: container)
try await processor.processTrip(id: trip.persistentModelID)When NOT to use:
- When both sender and receiver share the same
ModelContext(same actor) - Passing simple value-type data extracted from a model (e.g.,
trip.name) is already safe
Benefits:
PersistentIdentifierconforms toSendable— safe to pass anywhere- Each actor fetches from its own context, respecting actor isolation
- Works with both
@ModelActorand manually created background contexts
Reference: Dive deeper into SwiftData
Use In-Memory Configuration for Tests and Previews
Tests and previews that use persistent (on-disk) storage accumulate duplicate data across runs and leak state between test cases. Use ModelConfiguration(isStoredInMemoryOnly: true) to get a clean database every time — no leftover data, no test ordering issues, no preview pollution.
Incorrect (previews using default persistent container):
#Preview {
ContentView()
.modelContainer(for: Friend.self)
// Each preview refresh adds duplicate sample data to disk
// Preview data persists across Xcode restarts
}Correct (in-memory container for previews):
struct SampleData {
static let container: ModelContainer = {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Friend.self, configurations: config)
let context = container.mainContext
context.insert(Friend(name: "Alex", birthday: .now))
context.insert(Friend(name: "Jordan", birthday: .now))
return container
}()
}
#Preview {
ContentView()
.modelContainer(SampleData.container)
// Fresh sample data every time — no duplicates
}Alternative (in-memory container for unit tests):
@Test func addFriend() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Friend.self, configurations: config)
let context = ModelContext(container)
let friend = Friend(name: "Alex", birthday: .now)
context.insert(friend)
try context.save()
let friends = try context.fetch(FetchDescriptor<Friend>())
#expect(friends.count == 1)
// Database is discarded when test ends — no cleanup needed
}Reference: Develop in Swift — Navigate Sample Data
Use @ModelActor for Background SwiftData Work
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
PersistentModel and ModelContext are not Sendable — passing them across actor boundaries causes data races and crashes. For background work (imports, batch updates, sync), use the @ModelActor macro to create an actor with its own ModelContext isolated from the main actor.
Incorrect (sharing model context across actors — data race):
import SwiftData
struct FriendImporter {
let context: ModelContext // Not Sendable — unsafe to use off main actor
func importFriends(from data: [FriendDTO]) async {
await Task.detached {
for dto in data {
// BUG: accessing context from a non-main-actor task
// causes data races and intermittent crashes
context.insert(Friend(name: dto.name, birthday: dto.birthday))
}
try? context.save()
}.value
}
}Correct (@ModelActor creates an actor-isolated context):
import SwiftData
@ModelActor
actor FriendImporter {
// @ModelActor provides: modelContainer, modelExecutor, and a private modelContext
func importFriends(from data: [FriendDTO]) throws {
for dto in data {
let friend = Friend(name: dto.name, birthday: dto.birthday)
modelContext.insert(friend)
}
try modelContext.save()
}
}
// Usage from a view or main-actor code:
let importer = FriendImporter(modelContainer: container)
try await importer.importFriends(from: dtos)When NOT to use:
- Simple CRUD from SwiftUI views — use
@Environment(\.modelContext)instead - One-off fetches that are fast enough on the main actor
UI refresh caveat: Inserts from a @ModelActor do not reliably trigger @Query updates in SwiftUI views. If your UI depends on seeing background-inserted data immediately, you must observe ModelContext.didSave notifications and force a view refresh. See `query-background-refresh` for the full workaround pattern.
Benefits:
- Actor isolation guarantees serial access to the context — no data races
DefaultSerialModelExecutorensures operations run one-by-oneModelContainerisSendableand safe to pass to the actor initializer
Reference: Dive deeper into SwiftData
Apply @Model Macro to All Persistent Types
The @Model macro converts a Swift class into a SwiftData persistent model with change tracking, relationship management, and schema generation. Non-@Model types aren't persistent models, so SwiftData APIs won't accept them for insert/fetch.
Incorrect (plain class — not a SwiftData persistent model):
class Friend {
var name: String
var birthday: Date
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
}
}
// This instance lives only in memory and can't be inserted into SwiftData.
let friend = Friend(name: "Alex", birthday: .now)
context.insert(friend) // ERROR: Cannot convert value of type 'Friend' to expected argumentCorrect (@Model class — fully persisted):
import SwiftData
@Model class Friend {
var name: String
var birthday: Date
init(name: String, birthday: Date) {
self.name = name
self.birthday = birthday
}
}
// SwiftData can track and persist this instance.
let friend = Friend(name: "Alex", birthday: .now)
context.insert(friend)Benefits:
- Automatic change tracking — no manual save calls needed with environment context
- Schema generated from property declarations
- Relationship inference from type references between
@Modelclasses
Reference: Develop in Swift — Save Data
Wrap SwiftData Behind Repository Protocols
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Define repository protocols in the Domain layer describing WHAT data operations are available. Place SwiftData implementations (using ModelContext, FetchDescriptor, @ModelActor) in the Data layer. ViewModels depend only on the protocol — never on SwiftData types. This ensures persistence logic is swappable, testable with mocks, and invisible to the presentation layer.
Incorrect (ViewModel directly uses SwiftData types — coupled to persistence framework):
import SwiftData
@Observable
final class TripListViewModel {
private let context: ModelContext // SwiftData type in presentation layer
var trips: [TripEntity] = [] // @Model class leaks into ViewModel
init(context: ModelContext) {
self.context = context
}
func loadTrips() throws {
// FetchDescriptor in ViewModel — persistence logic in wrong layer
let descriptor = FetchDescriptor<TripEntity>(sortBy: [SortDescriptor(\.startDate)])
trips = try context.fetch(descriptor)
}
func deleteTrip(_ trip: TripEntity) {
context.delete(trip) // Direct ModelContext mutation in ViewModel
}
}Correct (protocol in Domain, SwiftData implementation in Data):
// Domain/Repositories/TripRepository.swift — pure Swift protocol
protocol TripRepository: Sendable {
func fetchAll() async throws -> [Trip]
func fetch(id: String) async throws -> Trip
func save(_ trip: Trip) async throws
func delete(id: String) async throws
}// Data/Repositories/SwiftDataTripRepository.swift
import SwiftData
final class SwiftDataTripRepository: TripRepository, @unchecked Sendable {
private let modelContainer: ModelContainer
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
@MainActor
func fetchAll() async throws -> [Trip] {
let descriptor = FetchDescriptor<TripEntity>(sortBy: [SortDescriptor(\.startDate)])
return try modelContainer.mainContext.fetch(descriptor).map { $0.toDomain() }
}
@MainActor
func save(_ trip: Trip) async throws {
let context = modelContainer.mainContext
let predicate = #Predicate<TripEntity> { $0.remoteId == trip.id }
if let entity = try context.fetch(FetchDescriptor(predicate: predicate)).first {
entity.update(from: trip)
} else {
context.insert(TripEntity(from: trip))
}
try context.save()
}
@MainActor
func delete(id: String) async throws {
let context = modelContainer.mainContext
let predicate = #Predicate<TripEntity> { $0.remoteId == id }
guard let entity = try context.fetch(FetchDescriptor(predicate: predicate)).first else { return }
context.delete(entity)
try context.save()
}
}// ViewModel — depends on protocol, not SwiftData
@Observable
final class TripListViewModel {
private let tripRepository: TripRepository
var trips: [Trip] = []
var errorMessage: String?
init(tripRepository: TripRepository) {
self.tripRepository = tripRepository
}
func loadTrips() async {
do { trips = try await tripRepository.fetchAll() }
catch { errorMessage = error.localizedDescription }
}
func deleteTrip(_ trip: Trip) async {
do {
try await tripRepository.delete(id: trip.id)
trips.removeAll { $0.id == trip.id }
} catch {
errorMessage = error.localizedDescription
}
}
}// Testing — mock repository, no SwiftData needed
final class MockTripRepository: TripRepository {
var trips: [Trip] = []
func fetchAll() async throws -> [Trip] { trips }
func fetch(id: String) async throws -> Trip {
guard let trip = trips.first(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
return trip
}
func save(_ trip: Trip) async throws { trips.append(trip) }
func delete(id: String) async throws { trips.removeAll { $0.id == id } }
}Dependency injection setup:
// App/DependencyContainer.swift
extension EnvironmentValues {
@Entry var tripRepository: any TripRepository = SwiftDataTripRepository(
modelContainer: try! ModelContainer(for: TripEntity.self)
)
}When NOT to use:
- Prototype apps where speed matters more than testability
- Single-screen apps with trivial persistence needs
Benefits:
- ViewModels are testable without SwiftData or simulators
- Data source is swappable (SwiftData, CoreData, network-only) without touching presentation code
- Domain layer has zero framework imports
- Mock repositories enable fast, deterministic unit tests
Reference: Clean Architecture for SwiftUI
Use In-Memory Containers for Preview Isolation
Previews using persistent storage accumulate data with every Xcode refresh — you see 50 copies of "Elena" after 50 refreshes. In-memory containers start fresh every time, ensuring previews always show exactly the data you defined.
Incorrect (persistent store — data duplicates on every refresh):
#Preview {
ContentView()
.modelContainer(for: Friend.self)
// Uses on-disk storage by default
// Each Xcode refresh inserts another copy of sample data
// After 50 refreshes: 50 Elenas, 50 Grahams
}Correct (in-memory container — clean data every refresh):
#Preview {
ContentView()
.modelContainer(SampleData.shared.modelContainer)
// SampleData uses ModelConfiguration(isStoredInMemoryOnly: true)
// Every refresh starts with exactly the sample data you defined
}When NOT to use:
- When testing persistence behavior itself (e.g., verifying data survives a container reload)
- Integration tests that need to validate on-disk storage paths
Benefits:
- Predictable preview output regardless of how many times you refresh
- Faster preview startup — no disk I/O
- No leftover test data polluting the simulator's persistent store
Reference: Develop in Swift — Navigate Sample Data
Annotate SampleData with @MainActor
SampleData accesses modelContainer.mainContext, which is bound to the main actor. Without @MainActor on the class, the compiler emits concurrency warnings, and access from previews may trigger runtime threading issues under strict concurrency checking.
Incorrect (missing @MainActor — concurrency warnings, potential crashes):
class SampleData {
static let shared = SampleData()
let modelContainer: ModelContainer
var context: ModelContext {
modelContainer.mainContext // Warning: main actor-isolated property accessed from nonisolated context
}
private init() {
let schema = Schema([Friend.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try! ModelContainer(for: schema, configurations: [config])
}
}Correct (class isolated to main actor — safe access guaranteed):
@MainActor
class SampleData {
static let shared = SampleData()
let modelContainer: ModelContainer
var context: ModelContext {
modelContainer.mainContext // Safe — class is on the main actor
}
private init() {
let schema = Schema([Friend.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try! ModelContainer(for: schema, configurations: [config])
}
}When NOT to use:
- Background data processing classes that intentionally work off the main thread should use a dedicated
ModelContextcreated from the container, notmainContext
Benefits:
- Zero concurrency warnings with Swift 6 strict concurrency
- Guaranteed thread safety when previews access
mainContext - Clear intent that this class exists for UI-facing preview data
Reference: Develop in Swift — Navigate Sample Data
Create a SampleData Singleton for Previews
A single SampleData class shared across all previews ensures consistency and avoids duplicate setup code. Use static let shared with a private init to guarantee a single instance that every preview provider references.
Incorrect (each preview creates its own container — duplicated code, inconsistent data):
#Preview {
let schema = Schema([Friend.self, Movie.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: schema, configurations: [config])
let context = container.mainContext
context.insert(Friend(name: "Elena", birthday: .now))
context.insert(Friend(name: "Graham", birthday: .now))
return ContentView()
.modelContainer(container)
}
// Another file repeats the exact same setup with slightly different data
#Preview {
let schema = Schema([Friend.self, Movie.self])
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: schema, configurations: [config])
let context = container.mainContext
context.insert(Friend(name: "Elena", birthday: .now))
return FriendDetailView(friend: Friend(name: "Elena", birthday: .now))
.modelContainer(container)
}Correct (singleton shared across all previews):
@MainActor
class SampleData {
static let shared = SampleData()
let modelContainer: ModelContainer
var context: ModelContext {
modelContainer.mainContext
}
private init() {
let schema = Schema([Friend.self, Movie.self])
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do {
modelContainer = try ModelContainer(for: schema, configurations: [configuration])
insertSampleData()
} catch {
fatalError("Failed to create model container: \(error)")
}
}
private func insertSampleData() {
for friend in Friend.sampleData {
context.insert(friend)
}
try? context.save()
}
}
// Every preview uses the same shared instance
#Preview {
ContentView()
.modelContainer(SampleData.shared.modelContainer)
}Benefits:
- One source of truth for all preview data
- Changes to sample data propagate to every preview automatically
- No risk of inconsistent data between preview providers
Reference: Develop in Swift — Navigate Sample Data
Define Static Sample Data on Model Types
Add a static let sampleData array to each @Model class. This keeps sample data next to the model definition, making it easy to find and update when properties change.
Incorrect (sample data scattered across preview files — hard to keep in sync):
// PreviewHelpers.swift — far from model definition
let sampleFriends = [
Friend(name: "Elena", birthday: Date(timeIntervalSince1970: 0)),
Friend(name: "Graham", birthday: Date(timeIntervalSince1970: 86400))
]
// AnotherPreview.swift — slightly different data, now out of sync
let previewFriends = [
Friend(name: "Elena", birthday: .now),
Friend(name: "Graham", birthday: .now),
Friend(name: "Jaya", birthday: .now)
]Correct (sample data co-located with model via extension):
extension Friend {
static let sampleData = [
Friend(name: "Elena", birthday: Date(timeIntervalSince1970: 0)),
Friend(name: "Graham", birthday: Date(timeIntervalSince1970: 86400)),
Friend(name: "Jaya", birthday: Date(timeIntervalSince1970: 172800))
]
}
// Usage in SampleData singleton:
for friend in Friend.sampleData {
context.insert(friend)
}When NOT to use:
- Models with complex relationship graphs where sample data requires multiple interdependent inserts — use a dedicated factory method instead
Benefits:
- When a model property changes, the compiler flags the sample data immediately
- Discoverable via autocomplete:
Friend.sampleData - Single array reused by previews, unit tests, and UI tests
Reference: Develop in Swift — Navigate Sample Data
Force View Refresh After Background Context Inserts
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@Query automatically updates when mutations happen on the same ModelContext (typically mainContext). However, inserts from a @ModelActor background context do not reliably trigger @Query updates — this is a known framework limitation. Deletes sync correctly, but inserts and updates from background actors leave the UI stale until the next autosave merge.
Incorrect (UI stays stale after background import):
@ModelActor
actor TripImporter {
func importTrips(from dtos: [TripDTO]) throws {
for dto in dtos {
modelContext.insert(Trip(name: dto.name, startDate: dto.startDate))
}
try modelContext.save()
// Main context @Query will NOT reliably update after this save
}
}
@Equatable
struct TripListView: View {
@Query(sort: \.startDate) private var trips: [Trip]
var body: some View {
List(trips) { trip in
Text(trip.name) // Shows stale data until user navigates away and back
}
}
}Correct (observe didSave notification to force refresh):
@ModelActor
actor TripImporter {
func importTrips(from dtos: [TripDTO]) throws {
for dto in dtos {
modelContext.insert(Trip(name: dto.name, startDate: dto.startDate))
}
try modelContext.save()
}
}
@Equatable
struct TripListView: View {
@Query(sort: \.startDate) private var trips: [Trip]
@State private var refreshToken = UUID()
var body: some View {
List(trips) { trip in
Text(trip.name)
}
.id(refreshToken)
.onReceive(
NotificationCenter.default.publisher(
for: ModelContext.didSave,
object: nil
)
) { _ in
refreshToken = UUID()
}
}
}When NOT to use:
- All mutations happen on the same
mainContext(e.g., simple CRUD from SwiftUI views) —@Queryupdates automatically in this case - You are using iOS 26+ where Apple may have resolved this framework limitation
Benefits:
- Ensures UI reflects background inserts and updates immediately
- Works around the known
@Query/@ModelActorsync gap - Low overhead — notification fires only on actual saves
Reference: SwiftData Query not updating after background import — Apple Developer Forums
Use Custom View Initializers for Dynamic Queries
@Query parameters are fixed at init time. To enable dynamic filtering (e.g., from a search bar or picker), create a child view with a custom initializer that constructs the Query from parameters passed by the parent. This preserves automatic view updates while allowing runtime filter changes.
Incorrect (filtering @Query results in the view body):
@Equatable
struct FriendList: View {
@Query(sort: \Friend.name) private var friends: [Friend]
var searchText: String
var body: some View {
// Filters in memory — defeats the purpose of @Query optimization
let filtered = searchText.isEmpty
? friends
: friends.filter { $0.name.contains(searchText) }
List(filtered) { friend in
Text(friend.name)
}
}
}Correct (custom init constructs @Query dynamically):
@Equatable
struct FriendList: View {
@Query private var friends: [Friend]
init(titleFilter: String = "") {
if titleFilter.isEmpty {
_friends = Query(sort: \.name)
} else {
let predicate = #Predicate<Friend> { friend in
friend.name.localizedStandardContains(titleFilter)
}
_friends = Query(filter: predicate, sort: \.name)
}
}
var body: some View {
List(friends) { friend in
Text(friend.name)
}
}
}
// Parent view drives the filter
@Equatable
struct ContentView: View {
@State private var searchText = ""
var body: some View {
NavigationStack {
FriendList(titleFilter: searchText)
.searchable(text: $searchText)
}
}
}When NOT to use:
- If the query never changes at runtime, a simple
@Querydeclaration is sufficient - For one-time fetches in background tasks, use
FetchDescriptorinstead
Reference: Develop in Swift — Create, Update, and Delete Data
Use #Expression for Reusable Predicate Components (iOS 18+)
#Expression (iOS 18+) lets you define reusable, composable predicate building blocks that return arbitrary types — not just booleans. This avoids duplicating complex filter logic across multiple predicates and enables aggregate-style queries that #Predicate alone cannot express.
Incorrect (duplicated filter logic across predicates):
// Same "high priority" logic repeated in two predicates
let urgentTrips = #Predicate<Trip> { trip in
trip.priority >= 3 && trip.startDate < .now
}
let urgentUpcoming = #Predicate<Trip> { trip in
trip.priority >= 3 && trip.startDate < .now && trip.endDate > .now
}Correct (#Expression extracts reusable logic):
let isUrgent = #Expression<Trip, Bool> { trip in
trip.priority >= 3 && trip.startDate < .now
}
let urgentTrips = #Predicate<Trip> { trip in
isUrgent.evaluate(trip)
}
let urgentUpcoming = #Predicate<Trip> { trip in
isUrgent.evaluate(trip) && trip.endDate > .now
}When NOT to use:
- Simple, one-off predicates that don't need reuse
- Apps targeting iOS 17 —
#Expressionrequires iOS 18+
Benefits:
- Single source of truth for complex filter logic
- Expressions compose into predicates without runtime overhead
- Enables aggregate patterns (counting, min/max) within SwiftData queries
Reference: What's new in SwiftData
Use FetchDescriptor Outside SwiftUI Views
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@Query only works inside SwiftUI views. For background tasks, services, or unit tests, use FetchDescriptor with context.fetch(). It supports the same predicates and sort descriptors, giving you full query power outside the view layer.
Incorrect (using @Query in a non-view class):
class TripService {
// ERROR: @Query can only be used in a SwiftUI View
@Query private var trips: [Trip]
func upcomingTrips() -> [Trip] {
return trips.filter { $0.startDate > Date.now }
}
}Correct (FetchDescriptor with context.fetch):
class TripService {
private let context: ModelContext
init(context: ModelContext) {
self.context = context
}
func upcomingTrips() throws -> [Trip] {
var descriptor = FetchDescriptor<Trip>(
predicate: #Predicate { $0.startDate > Date.now },
sortBy: [SortDescriptor(\.startDate)]
)
descriptor.fetchLimit = 50
return try context.fetch(descriptor)
}
}When NOT to use:
- Inside SwiftUI views — prefer
@Queryfor automatic view updates - If you need live-updating results in a view,
FetchDescriptorwill not trigger re-renders
Tune FetchDescriptor with fetchLimit/fetchOffset and includePendingChanges
When you need paging, background reporting, or predictable performance outside SwiftUI views, tune FetchDescriptor instead of fetching everything and slicing in memory. Use fetchLimit and fetchOffset for paging, and choose whether to include unsaved in-context edits with includePendingChanges.
Incorrect (fetch everything, then page in memory):
import SwiftData
func pageTrips(context: ModelContext, page: Int) throws -> [Trip] {
let pageSize = 50
// Loads the full result set into memory.
let allTrips = try context.fetch(FetchDescriptor<Trip>())
let start = page * pageSize
return Array(allTrips.dropFirst(start).prefix(pageSize))
}Correct (page at the fetch layer with a stable sort):
import SwiftData
func pageTrips(context: ModelContext, page: Int) throws -> [Trip] {
let pageSize = 50
var descriptor = FetchDescriptor<Trip>(
sortBy: [SortDescriptor(\.startDate)]
)
descriptor.fetchLimit = pageSize
descriptor.fetchOffset = page * pageSize
// For background work and stable paging, avoid mixing in unsaved edits.
descriptor.includePendingChanges = false
return try context.fetch(descriptor)
}When NOT to use:
- Inside SwiftUI views that should live-update from persistence: prefer
@Query - Very small datasets where simplicity matters more than tuning
Benefits:
- Bounded memory usage for large result sets
- More predictable paging behavior (pair offset/limit with a stable sort)
- Control over whether unsaved edits in the current context affect fetch results
Reference: Dive deeper into SwiftData and FetchDescriptor.fetchOffset
Use localizedStandardContains for Search
Using .contains() for search text matching is case-sensitive and breaks with accented characters. localizedStandardContains handles case, diacritics, and locale variations automatically — matching how users expect search to work on Apple platforms.
Incorrect (case-sensitive, diacritic-sensitive matching):
let predicate = #Predicate<Friend> { friend in
// "café" won't match "Cafe", "CAFÉ" won't match "café"
friend.name.contains(searchText)
}Correct (locale-aware, case-insensitive, diacritic-insensitive):
let predicate = #Predicate<Friend> { friend in
// Matches regardless of case or accents: "cafe" matches "Café", "CAFE", etc.
friend.name.localizedStandardContains(searchText)
}Benefits:
- Matches "cafe" to "Café", "CAFÉ", "café", and "Cafe"
- Follows the same search behavior as Spotlight, Mail, and other Apple apps
- Respects the user's locale settings for language-specific matching rules
Reference: Develop in Swift — Create, Update, and Delete Data
Use #Predicate for Type-Safe Filtering
#Predicate provides type-safe, compile-time checked filtering that SwiftData can optimize at the storage level. Manual in-memory filtering with .filter() bypasses SwiftData's query optimizer, loads all records into memory, and does not benefit from indexing.
Incorrect (in-memory filtering after fetching all records):
@Equatable
struct FriendList: View {
@Query private var friends: [Friend]
@State private var searchText = ""
var body: some View {
// Fetches ALL friends, then filters in memory — wasteful with large datasets
let filtered = friends.filter { $0.name.contains(searchText) }
List(filtered) { friend in
Text(friend.name)
}
}
}Correct (predicate pushes filtering to SwiftData):
@Equatable
struct FriendList: View {
@Query private var friends: [Friend]
init(searchText: String) {
let predicate = #Predicate<Friend> { friend in
friend.name.localizedStandardContains(searchText)
}
_friends = Query(filter: predicate, sort: \.name)
}
var body: some View {
List(friends) { friend in
Text(friend.name)
}
}
}Benefits:
- Compile-time type checking catches typos in property names
- SwiftData can use indexes to speed up filtering
- Only matching records are loaded into memory
Reference: Develop in Swift — Create, Update, and Delete Data
Use @Query for Declarative Data Fetching (Data Layer)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@Query fetches SwiftData entities and automatically updates the view when underlying data changes — no onAppear, NotificationCenter, or manual refresh logic needed. Manual fetching with context.fetch() in views misses updates, requires extra state management, and inevitably produces stale UI states.
Architecture note: In modular MVVM-C architecture, @Query and FetchDescriptor are Data layer implementation details — they belong in repository implementations, not in views or ViewModels. Views read data from @Observable ViewModels, which read from repository protocols. See `state-query-vs-viewmodel` for the recommended architecture and `persist-repository-wrapper` for the repository pattern.
Incorrect (manual fetch misses live updates):
@Equatable
struct FriendList: View {
@Environment(\.modelContext) private var context
@State var friends: [Friend] = []
var body: some View {
List(friends) { friend in
Text(friend.name)
}
.onAppear {
// Must be called manually; view never updates when data changes elsewhere
friends = (try? context.fetch(FetchDescriptor<Friend>())) ?? []
}
}
}Correct (declarative @Query with automatic updates):
@Equatable
struct FriendList: View {
@Query private var friends: [Friend]
var body: some View {
List(friends) { friend in
Text(friend.name)
}
// No onAppear needed — view updates automatically when friends change
}
}Known limitation — cross-context inserts: @Query does not reliably update when data is inserted from a @ModelActor background context. Deletes propagate correctly, but inserts and updates from background actors may leave the UI stale until the next autosave merge. If your app uses background imports or sync services, see `query-background-refresh` for the workaround pattern using ModelContext.didSave notifications.
When NOT to use:
- In non-SwiftUI contexts (background tasks, services, unit tests) — use
FetchDescriptorwithcontext.fetch()instead - When you need a one-shot fetch that should not trigger view re-renders
Reference: Develop in Swift — Save Data
Apply Sort Descriptors to @Query
Without explicit sorting, @Query returns items in undefined order that changes between app launches. Users see items shuffle randomly, creating a confusing experience. Always specify a sort key path for deterministic ordering.
Incorrect (undefined order changes on every launch):
@Equatable
struct FriendList: View {
// Order is unpredictable — items shuffle between launches
@Query private var friends: [Friend]
var body: some View {
List(friends) { friend in
Text(friend.name)
}
}
}Correct (explicit sort for stable ordering):
@Equatable
struct FriendList: View {
@Query(sort: \Friend.name) private var friends: [Friend]
var body: some View {
List(friends) { friend in
Text(friend.name)
}
}
}Alternative:
For multiple sort criteria or descending order, use an array of SortDescriptor:
@Query(sort: [
SortDescriptor(\Friend.name),
SortDescriptor(\Friend.birthday, order: .reverse)
]) private var friends: [Friend]Reference: Develop in Swift — Navigate Sample Data
Use Arrays for One-to-Many Relationships
For one-to-many relationships (e.g., a movie favorited by many friends), use an array property with a default empty array. Apple’s SwiftData examples model to-many relationships as arrays, which integrates cleanly with SwiftUI’s ForEach and keeps relationship ordering explicit.
Incorrect (Set-based to-many relationship — non-canonical, harder to iterate and reason about):
import SwiftData
@Model class Movie {
var title: String
var favoritedBy: Set<Friend> = []
init(title: String) {
self.title = title
}
}Correct (Array with empty default):
import SwiftData
@Model class Movie {
var title: String
var favoritedBy: [Friend] = []
init(title: String) {
self.title = title
}
}Benefits:
- SwiftData automatically appends/removes elements when the inverse side changes
- Array contents are persisted and restored across launches
- Compatible with SwiftUI's
ForEachfor direct iteration
Reference: Develop in Swift — Work with Relationships
Configure Cascade Delete Rules for Owned Relationships
When a parent model owns its children (e.g., a trip owns its accommodations), use @Relationship(deleteRule: .cascade) so deleting the parent also deletes all children. Without an explicit cascade rule, SwiftData uses the default nullify behavior, which sets the child's reference to nil but leaves the child record in the store — orphaned and inaccessible forever.
Incorrect (default delete rule — deleting trip orphans accommodations):
import SwiftData
@Model class Trip {
var name: String
var accommodations: [Accommodation] = []
init(name: String) {
self.name = name
}
}
@Model class Accommodation {
var address: String
var trip: Trip?
init(address: String, trip: Trip? = nil) {
self.address = address
self.trip = trip
}
}
// Deleting a trip leaves its accommodations in the database with trip == nilCorrect (cascade delete rule — children removed with parent):
import SwiftData
@Model class Trip {
var name: String
@Relationship(deleteRule: .cascade) var accommodations: [Accommodation] = []
init(name: String) {
self.name = name
}
}
@Model class Accommodation {
var address: String
var trip: Trip?
init(address: String, trip: Trip? = nil) {
self.address = address
self.trip = trip
}
}
// Deleting a trip now also deletes all its accommodationsWhen NOT to use:
- Shared children referenced by multiple parents (e.g., tags used by many items) — cascade would delete the tag when any single parent is removed
- Use the default nullify rule when children should survive parent deletion
Sort Relationship Arrays Explicitly
Relationship arrays (e.g., movie.favoritedBy) have no guaranteed order. SwiftData may return elements in insertion order, but this is not contractual and can change after background saves or migration. Displaying an unsorted relationship array in a ForEach produces unpredictable ordering that shifts between renders, confusing users.
Incorrect (unsorted relationship array — random order on every render):
@Equatable
struct MovieDetail: View {
var movie: Movie
var body: some View {
List {
Section("Favorited By") {
// Order may change between renders — unstable UI
ForEach(movie.favoritedBy) { friend in
Text(friend.name)
}
}
}
}
}Correct (explicitly sorted before display):
@Equatable
struct MovieDetail: View {
var movie: Movie
var body: some View {
List {
Section("Favorited By") {
ForEach(movie.favoritedBy.sorted(by: { $0.name < $1.name })) { friend in
Text(friend.name)
}
}
}
}
}Alternative:
- For large arrays, compute the sorted result in a computed property or cache it to avoid re-sorting on every view evaluation:
private var sortedFriends: [Friend] {
movie.favoritedBy.sorted(by: { $0.name < $1.name })
}Reference: Develop in Swift — Work with Relationships
Rely on SwiftData Automatic Inverse Maintenance
When you set one side of a relationship (e.g., friend.favoriteMovie = movie), SwiftData automatically updates the inverse side (movie.favoritedBy). Manually maintaining both sides introduces bugs where a friend appears twice in favoritedBy or the relationship becomes inconsistent after undo operations.
Incorrect (manually updating both sides — causes duplicates):
func setFavorite(friend: Friend, movie: Movie) {
friend.favoriteMovie = movie
// BUG: SwiftData already added friend to movie.favoritedBy
// This line adds a duplicate entry
movie.favoritedBy.append(friend)
}Correct (set one side only — SwiftData handles the inverse):
func setFavorite(friend: Friend, movie: Movie) {
friend.favoriteMovie = movie
// movie.favoritedBy now automatically includes this friend
}
func clearFavorite(friend: Friend) {
friend.favoriteMovie = nil
// friend is automatically removed from the movie's favoritedBy array
}When NOT to use:
- If you define only one side of a relationship without a corresponding inverse property, there is no inverse to maintain — but this pattern is discouraged for data integrity
Reference: Develop in Swift — Work with Relationships
Use Optionals for Optional Relationships
When a model may or may not have a related object (e.g., a friend's favorite movie), declare the relationship as optional. Non-optional relationships crash at runtime if the related object is deleted or was never set, because SwiftData cannot fulfill the non-nil contract.
Incorrect (non-optional relationship crashes if no movie assigned):
import SwiftData
@Model class Friend {
var name: String
var favoriteMovie: Movie // Crashes if no movie is assigned or movie is deleted
init(name: String, favoriteMovie: Movie) {
self.name = name
self.favoriteMovie = favoriteMovie
}
}Correct (optional relationship safely represents "no favorite"):
import SwiftData
@Model class Friend {
var name: String
var favoriteMovie: Movie?
init(name: String, favoriteMovie: Movie? = nil) {
self.name = name
self.favoriteMovie = favoriteMovie
}
}
// Usage in a Picker — include a "None" option with nil tag
@Equatable
struct FriendEditor: View {
@Bindable var friend: Friend
@Query(sort: \Movie.title) private var movies: [Movie]
var body: some View {
Picker("Favorite Movie", selection: $friend.favoriteMovie) {
Text("None").tag(nil as Movie?)
ForEach(movies) { movie in
Text(movie.title).tag(movie as Movie?)
}
}
}
}When NOT to use:
- Required relationships where the child cannot exist without the parent (e.g., an order line item always belongs to an order) should remain non-optional
Reference: Develop in Swift — Work with Relationships
Related skills
FAQ
What does swift-data do?
swift-data: A skill for development. This provides functionality for development workflows.
When should I use swift-data?
When you need to use swift-data for development tasks, or when swift-data: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
swift-data.