--- name: axiom-swiftdata description: Use when working with SwiftData - @Model definitions, @Query in SwiftUI, @Relationship macros, ModelContext patterns, CloudKit integration, iOS 26+ features, and Swift 6 concurrency with @MainActor — Apple's native persistence framework license: MIT metadata: version: "1.0.0" --- # SwiftData ## Overview Apple's native persistence framework using `@Model` classes and declarative queries. Built on Core Data, designed for SwiftUI. **Core principle** Reference types (`class`) + `@Model` macro + declarative `@Query` for reactive SwiftUI integration. **Requires** iOS 17+, Swift 5.9+ **Target** iOS 26+ (this skill focuses on latest features) **License** Proprietary (Apple) ## When to Use SwiftData #### Choose SwiftData when you need - ✅ Native Apple integration with SwiftUI - ✅ Simple CRUD operations - ✅ Automatic UI updates with `@Query` - ✅ CloudKit sync (iOS 17+) - ✅ Reference types (classes) with relationships #### Use SQLiteData instead when - Need value types (structs) - CloudKit record sharing (not just sync) - Large datasets (50k+ records) with specific performance needs #### Use GRDB when - Complex raw SQL required - Fine-grained migration control needed **For migrations** See the `axiom-swiftdata-migration` skill for custom schema migrations with VersionedSchema and SchemaMigrationPlan. For migration debugging, see `axiom-swiftdata-migration-diag`. ## Example Prompts These are real questions developers ask that this skill is designed to answer: #### Basic Operations #### 1. "I have a notes app with folders. I need to filter notes by folder and sort by last modified. How do I set up the @Query?" → The skill shows how to use `@Query` with predicates, sorting, and automatic view updates #### 2. "When a user deletes a task list, all tasks should auto-delete too. How do I set up the relationship?" → The skill explains `@Relationship` with `deleteRule: .cascade` and inverse relationships #### 3. "I have a relationship between User → Messages → Attachments. How do I prevent orphaned data when deleting?" → The skill shows cascading deletes, inverse relationships, and safe deletion patterns #### CloudKit & Sync #### 4. "My chat app syncs messages to other devices via CloudKit. Sometimes messages conflict. How do I handle sync conflicts?" → The skill covers CloudKit integration, conflict resolution strategies (last-write-wins, custom resolution), and sync patterns #### 5. "I'm adding CloudKit sync to my app, but I get 'Property must have a default value' error. What's wrong?" → The skill explains CloudKit constraints: all properties must be optional or have defaults, explains why (network timing), and shows fixes #### 6. "I want to show users when their data is syncing to iCloud and what happens when they're offline." → The skill shows monitoring sync status with notifications, detecting network connectivity, and offline-aware UI patterns #### 7. "I need to share a playlist with other users. How do I implement CloudKit record sharing?" → The skill covers CloudKit record sharing patterns (iOS 26+) with owner/permission tracking and sharing metadata #### Performance & Optimization #### 8. "I need to query 50,000 messages but only display 20 at a time. How do I paginate efficiently?" → The skill covers performance patterns, batch fetching, limiting queries, and preventing memory bloat with chunked imports #### 9. "My app loads 100 tasks with relationships, and displaying them is slow. I think it's N+1 queries." → The skill shows how to identify N+1 problems without prefetching, provides prefetching pattern, and shows 100x performance improvement #### 10. "I'm importing 1 million records from an API. What's the best way to batch them without running out of memory?" → The skill shows chunk-based importing with periodic saves, memory cleanup patterns, and batch operation optimization #### 11. "Which properties should I add indexes to? I'm worried about over-indexing slowing down writes." → The skill explains index optimization patterns: when to index (frequently filtered/sorted properties), when to avoid (rarely used, frequently changing), maintenance costs #### Migration from Legacy Frameworks #### 12. "We're migrating from Realm/Core Data to SwiftData" → See the comparison table in Migration section below, then follow `realm-to-swiftdata-migration` or `axiom-swiftdata-migration` for detailed guides --- ## @Model Definitions ### Basic Model ```swift import SwiftData @Model final class Track { @Attribute(.unique) var id: String var title: String var artist: String var duration: TimeInterval var genre: String? init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) { self.id = id self.title = title self.artist = artist self.duration = duration self.genre = genre } } ``` #### Key patterns - Use `final class`, not `struct` (omit `final` if you need subclasses — see Class Inheritance below) - Use `@Attribute(.unique)` for primary key-like behavior - Provide explicit `init` (SwiftData doesn't synthesize) - Optional properties (`String?`) are nullable - Use `@Attribute(.preserveValueOnDeletion)` on properties whose values should survive even after the object is deleted (useful for analytics, audit trails) ### Relationships ```swift @Model final class Track { @Attribute(.unique) var id: String var title: String @Relationship(deleteRule: .cascade, inverse: \Album.tracks) var album: Album? init(id: String, title: String, album: Album? = nil) { self.id = id self.title = title self.album = album } } @Model final class Album { @Attribute(.unique) var id: String var title: String @Relationship(deleteRule: .cascade) var tracks: [Track] = [] init(id: String, title: String) { self.id = id self.title = title } } ``` ### Many-to-Many Self-Referential Relationships ```swift @MainActor // Required for Swift 6 strict concurrency @Model final class User { @Attribute(.unique) var id: String var name: String // Users following this user (inverse relationship) @Relationship(deleteRule: .nullify, inverse: \User.following) var followers: [User] = [] // Users this user is following @Relationship(deleteRule: .nullify) var following: [User] = [] init(id: String, name: String) { self.id = id self.name = name } } ``` #### CRITICAL: SwiftData automatically manages BOTH sides when you modify ONE side. ✅ **Correct — Only modify ONE side** ```swift // user1 follows user2 (modifying ONE side) user1.following.append(user2) try modelContext.save() // SwiftData AUTOMATICALLY updates user2.followers // Don't manually append to both sides - causes duplicates! ``` ❌ **Wrong — Don't manually update both sides** ```swift user1.following.append(user2) user2.followers.append(user1) // Redundant! Creates duplicates in CloudKit sync ``` #### Unfollowing (remove from ONE side only) ```swift user1.following.removeAll { $0.id == user2.id } try modelContext.save() // user2.followers automatically updated ``` #### Verifying relationship integrity (for debugging) ```swift // Check if relationship is truly bidirectional let user1FollowsUser2 = user1.following.contains { $0.id == user2.id } let user2FollowedByUser1 = user2.followers.contains { $0.id == user1.id } // These MUST always match after save() assert(user1FollowsUser2 == user2FollowedByUser1, "Relationship corrupted!") ``` #### CloudKit Sync Recovery (if relationships become corrupted) ```swift // If CloudKit sync creates duplicate/orphaned relationships: // 1. Backup current state let backup = user.following.map { $0.id } // 2. Clear relationships user.following.removeAll() user.followers.removeAll() try modelContext.save() // 3. Rebuild from source of truth (e.g., API) for followingId in backup { if let followingUser = fetchUser(id: followingId) { user.following.append(followingUser) } } try modelContext.save() // 4. Force CloudKit resync (in ModelConfiguration) // Re-create ModelContainer to force full sync after corruption recovery ``` #### Delete rules - `.cascade` - Delete related objects - `.nullify` - Set relationship to nil - `.deny` - Prevent deletion if relationship exists - `.noAction` - Leave relationship as-is (careful!) ## Class Inheritance SwiftData supports class inheritance for hierarchical models. Use when you have a clear IS-A relationship (e.g., `BusinessTrip` IS-A `Trip`) and need both broad queries (all trips) and type-specific queries. ### Base and Subclass Pattern Apply `@Model` to both base class and subclasses. Omit `final` on the base class. ```swift @Model class Trip { @Attribute(.preserveValueOnDeletion) var name: String var destination: String var startDate: Date var endDate: Date @Relationship(deleteRule: .cascade, inverse: \Accommodation.trip) var accommodation: Accommodation? init(name: String, destination: String, startDate: Date, endDate: Date) { self.name = name self.destination = destination self.startDate = startDate self.endDate = endDate } } @Model class BusinessTrip: Trip { var purpose: String var expenseCode: String @Relationship(deleteRule: .cascade, inverse: \BusinessMeal.trip) var businessMeals: [BusinessMeal] = [] init(name: String, destination: String, startDate: Date, endDate: Date, purpose: String, expenseCode: String) { self.purpose = purpose self.expenseCode = expenseCode super.init(name: name, destination: destination, startDate: startDate, endDate: endDate) } } ``` ### Type-Based Queries with #Predicate Query all base class instances (includes subclasses), or filter by type: ```swift // All trips (includes BusinessTrip, PersonalTrip, etc.) @Query(sort: \Trip.startDate) var allTrips: [Trip] // Only business trips — use `is` in #Predicate @Query(filter: #Predicate { $0 is BusinessTrip }) var businessTrips: [Trip] // Filter on subclass-specific properties — use `as?` cast let vacationPredicate = #Predicate { if let personal = $0 as? PersonalTrip { return personal.reason == .vacation } return false } @Query(filter: vacationPredicate) var vacationTrips: [Trip] ``` ### Polymorphic Relationships Relationships typed to the base class can hold mixed subclass instances: ```swift @Model class TravelPlanner { var name: String @Relationship(deleteRule: .cascade) var upcomingTrips: [Trip] = [] // Can contain BusinessTrip and PersonalTrip init(name: String) { self.name = name } } ``` Cast to access subclass-specific properties: ```swift for trip in planner.upcomingTrips { if let business = trip as? BusinessTrip { print(business.expenseCode) } } ``` ### When to Use Inheritance vs Alternatives | Signal | Use Inheritance | Use Enum/Flag Instead | |--------|----------------|----------------------| | Subclasses share many base properties | Yes | — | | Need type-based queries across all models | Yes | — | | Subclasses have their own relationships | Yes | — | | Only 1-2 distinguishing properties | — | Yes | | Query only on specialized properties | — | Yes | | Protocol conformance suffices | — | Yes | **Keep hierarchies shallow** (1-2 levels). Deep chains complicate schema migrations and queries. ## ModelContainer Setup ### SwiftUI App ```swift import SwiftUI import SwiftData @main struct MusicApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [Track.self, Album.self]) } } ``` ### Custom Configuration ```swift let schema = Schema([Track.self, Album.self]) let config = ModelConfiguration( schema: schema, url: URL(fileURLWithPath: "/path/to/database.sqlite"), cloudKitDatabase: .private("iCloud.com.example.app") ) let container = try ModelContainer( for: schema, configurations: config ) ``` ### In-Memory (Tests) ```swift let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: schema, configurations: config ) ``` ## Queries in SwiftUI ### Basic @Query ```swift import SwiftUI import SwiftData struct TracksView: View { @Query var tracks: [Track] var body: some View { List(tracks) { track in Text(track.title) } } } ``` **Automatic updates** View refreshes when data changes. ### Filtered, Sorted, Combined ```swift // Filtered @Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] // Sorted (single) @Query(sort: \.title, order: .forward) var tracks: [Track] // Sorted (multiple descriptors) @Query(sort: [SortDescriptor(\.artist), SortDescriptor(\.title)]) var tracks: [Track] // Combined filter + sort @Query(filter: #Predicate { $0.duration > 180 }, sort: \.title) var longTracks: [Track] ``` ## ModelContext Operations ### Accessing ModelContext ```swift struct ContentView: View { @Environment(\.modelContext) private var modelContext // ... } ``` ### CRUD Operations ```swift // Insert let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) modelContext.insert(track) // Fetch let descriptor = FetchDescriptor( predicate: #Predicate { $0.genre == "Rock" }, sortBy: [SortDescriptor(\.title)] ) let rockTracks = try modelContext.fetch(descriptor) // Update — just modify properties, SwiftData tracks changes track.title = "Updated Title" // Delete modelContext.delete(track) // Batch delete try modelContext.delete(model: Track.self, where: #Predicate { $0.genre == "Classical" }) // Save (optional — auto-saves on view disappear) try modelContext.save() ``` ## Predicates ### Basic Comparisons ```swift #Predicate { $0.duration > 180 } #Predicate { $0.artist == "Artist Name" } #Predicate { $0.genre != nil } ``` ### Compound Predicates ```swift #Predicate { track in track.genre == "Rock" && track.duration > 180 } #Predicate { track in track.artist == "Artist" || track.artist == "Other Artist" } ``` ### String Matching ```swift // Contains #Predicate { track in track.title.contains("Love") } // Case-insensitive contains #Predicate { track in track.title.localizedStandardContains("love") } // Starts with #Predicate { track in track.artist.hasPrefix("The ") } ``` ### Relationship Predicates ```swift #Predicate { track in track.album?.title == "Album Name" } #Predicate { album in album.tracks.count > 10 } ``` ## Swift 6 Concurrency ### @MainActor Isolation ```swift import SwiftData @MainActor @Model final class Track { var id: String var title: String init(id: String, title: String) { self.id = id self.title = title } } ``` **Why** SwiftData models are not `Sendable`. Use `@MainActor` to ensure safe access from SwiftUI. ### Background Context ```swift import SwiftData actor DataImporter { let modelContainer: ModelContainer init(container: ModelContainer) { self.modelContainer = container } func importTracks(_ tracks: [TrackData]) async throws { // Create background context let context = ModelContext(modelContainer) for track in tracks { let model = Track( id: track.id, title: track.title, artist: track.artist, duration: track.duration ) context.insert(model) } try context.save() } } ``` **Pattern** Use `ModelContext(modelContainer)` for background operations, not `@Environment(\.modelContext)` which is main-actor bound. #### Calling from SwiftUI ```swift struct ContentView: View { @Environment(\.modelContext) private var modelContext var body: some View { Button("Import") { Task { let importer = DataImporter(container: modelContext.container) try await importer.importTracks(data) } } } } ``` ## CloudKit Integration ### Enable CloudKit Sync ```swift let schema = Schema([Track.self]) let config = ModelConfiguration( schema: schema, cloudKitDatabase: .private("iCloud.com.example.MusicApp") ) let container = try ModelContainer( for: schema, configurations: config ) ``` ### Capabilities Required 1. Enable iCloud in Xcode (Signing & Capabilities) 2. Select CloudKit 3. Add iCloud container: `iCloud.com.example.MusicApp` **Note** SwiftData CloudKit sync is automatic - no manual conflict resolution needed. ### CloudKit Constraints (CRITICAL) #### When using CloudKit sync, ALL properties must be optional or have default values ```swift @Model final class Track { @Attribute(.unique) var id: String = UUID().uuidString // ✅ Has default var title: String = "" // ✅ Has default var duration: TimeInterval = 0 // ✅ Has default var genre: String? = nil // ✅ Optional // ❌ These don't work with CloudKit: // var requiredField: String // No default, not optional } ``` **Why** CloudKit only syncs to private zones, and network delays mean new records may not have all fields populated yet. **Relationship Constraint** All relationships must be optional ```swift @Model final class Track { @Relationship(deleteRule: .cascade, inverse: \Album.tracks) var album: Album? // ✅ Must be optional for CloudKit } ``` ### Sync Status, Conflicts, Offline Handling SwiftData CloudKit sync uses **last-write-wins** by default. For sync status monitoring, custom conflict resolution, and offline-aware UI patterns, see `axiom-cloud-sync`. For CKShare-based record sharing, see `axiom-cloudkit-ref`. ### Resolving "Property must be optional or have default value" Error **Problem** You get this error when trying to use CloudKit sync: ``` Property 'title' must be optional or have a default value for CloudKit synchronization ``` #### Solution ```swift // ❌ Wrong - required property @Model final class Track { var title: String } // ✅ Correct - has default @Model final class Track { var title: String = "" } // ✅ Also correct - optional @Model final class Track { var title: String? } ``` ### Testing CloudKit Sync (Without iCloud) ```swift let schema = Schema([Track.self]) // Test configuration (no CloudKit sync) let testConfig = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: schema, configurations: testConfig) ``` #### For real CloudKit testing 1. Sign in to iCloud on test device 2. Enable CloudKit in Capabilities 3. Use real device (simulator CloudKit is unreliable) 4. Check iCloud status in Settings → [Your Name] → iCloud ## iOS 26+ Features ### Enhanced Relationship Handling ```swift @Model final class Track { @Relationship( deleteRule: .cascade, inverse: \Album.tracks, minimum: 0, maximum: 1 // Track belongs to at most one album ) var album: Album? } ``` ### Transient Properties ```swift @Model final class Track { var id: String var duration: TimeInterval @Transient var formattedDuration: String { let minutes = Int(duration) / 60 let seconds = Int(duration) % 60 return String(format: "%d:%02d", minutes, seconds) } } ``` **Transient** Computed property, not persisted. ### History Tracking ```swift // Enable history tracking let config = ModelConfiguration( schema: schema, cloudKitDatabase: .private("iCloud.com.example.app"), allowsSave: true, isHistoryEnabled: true // iOS 26+ ) ``` ## Performance Patterns ### Batch Fetching ```swift let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.title)] ) descriptor.fetchLimit = 100 // Paginate results let tracks = try modelContext.fetch(descriptor) ``` ### Prefetch Relationships (Prevent N+1 Queries) ```swift let descriptor = FetchDescriptor() descriptor.relationshipKeyPathsForPrefetching = [\.album] // Eager load album let tracks = try modelContext.fetch(descriptor) // No N+1 queries - albums already loaded ``` **CRITICAL** Without prefetching, accessing `track.album.title` in a loop triggers individual queries for EACH track: ```swift // ❌ SLOW: N+1 queries (1 fetch tracks + 100 fetch albums) let tracks = try modelContext.fetch(FetchDescriptor()) for track in tracks { print(track.album?.title) // 100 separate queries! } // ✅ FAST: 2 queries total (1 fetch tracks + 1 fetch all albums) let descriptor = FetchDescriptor() descriptor.relationshipKeyPathsForPrefetching = [\.album] let tracks = try modelContext.fetch(descriptor) for track in tracks { print(track.album?.title) // Already loaded } ``` ### Faulting (Lazy Loading) SwiftData uses faulting (lazy loading) by default: ```swift let track = tracks.first // Album is a fault - not loaded yet let albumTitle = track.album?.title // Album loaded on access (separate query) ``` #### Use faulting strategically - ✅ Good when you access relationships in only 10-20% of cases - ✅ Good for large relationship graphs you partially use - ❌ Bad when you access relationships in loops → use prefetching instead ### Batch Operations (Performance for Large Datasets) ```swift // ❌ SLOW: 1000 individual saves for track in largeDataset { track.genre = "Updated" try modelContext.save() // Expensive - 1000 times } // ✅ FAST: Single save operation for track in largeDataset { track.genre = "Updated" } try modelContext.save() // Once for entire batch ``` ### Index Optimization (iOS 26+) Create indexes on frequently queried properties: ```swift @Model final class Track { @Attribute(.unique) var id: String = UUID().uuidString @Attribute(.indexed) // ✅ Add index var genre: String = "" @Attribute(.indexed) var releaseDate: Date = Date() var title: String = "" var duration: TimeInterval = 0 } // Now these queries are faster: @Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] @Query(filter: #Predicate { $0.releaseDate > Date() }) var upcomingTracks: [Track] ``` #### When to add indexes - ✅ Properties used in `@Query` filters frequently - ✅ Properties used in sort operations - ✅ Properties used in relationships - ❌ NOT properties that are rarely filtered - ❌ NOT properties that change frequently (maintenance cost) ### Memory Optimization: Fetch Chunks For very large datasets (100k+ records), fetch in chunks: ```swift actor DataImporter { let modelContainer: ModelContainer func importLargeDataset(_ items: [Item]) async throws { let chunkSize = 1000 let context = ModelContext(modelContainer) for chunk in items.chunked(into: chunkSize) { for item in chunk { let track = Track( id: item.id, title: item.title, artist: item.artist, duration: item.duration ) context.insert(track) } try context.save() // Save after each chunk // Prevent memory bloat context.delete(model: Track.self, where: #Predicate { _ in true }) } } } extension Array { func chunked(into size: Int) -> [[Element]] { stride(from: 0, to: count, by: size).map { Array(self[$0..` | Entity editor + `@NSManaged` | `@Relationship` with automatic inverses | | Background work | `DispatchQueue` + thread-local Realm | `newBackgroundContext()` | `actor` + `ModelContext(modelContainer)` | | Batch delete | Loop + `realm.delete()` | `NSBatchDeleteRequest` | `context.delete(model:where:)` | | CloudKit sync | Realm Sync (deprecated Sept 2025) | `NSPersistentCloudKitContainer` | `ModelConfiguration(cloudKitDatabase:)` | ### Detailed Migration Guides - **`realm-to-swiftdata-migration`** — Complete Realm migration: pattern equivalents, thread safety conversion, relationship migration, CloudKit sync transition, timeline planning - **`axiom-swiftdata-migration`** — SwiftData schema evolution: VersionedSchema, SchemaMigrationPlan, lightweight vs custom migrations - **`axiom-database-migration`** — Safe additive migration patterns applicable to any persistence framework ## Testing ### Test Setup ```swift import XCTest import SwiftData @testable import MusicApp final class TrackTests: XCTestCase { var modelContext: ModelContext! override func setUp() async throws { let schema = Schema([Track.self]) let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: schema, configurations: config) modelContext = ModelContext(container) } func testInsertTrack() throws { let track = Track(id: "1", title: "Test", artist: "Artist", duration: 240) modelContext.insert(track) let descriptor = FetchDescriptor() let tracks = try modelContext.fetch(descriptor) XCTAssertEqual(tracks.count, 1) XCTAssertEqual(tracks.first?.title, "Test") } } ``` ## Comparison: SwiftData vs SQLiteData | Feature | SwiftData | SQLiteData | |---------|-----------|------------| | **Type** | Reference (class) | Value (struct) | | **Macro** | `@Model` | `@Table` | | **Queries** | `@Query` in SwiftUI | `@FetchAll` / `@FetchOne` | | **Relationships** | `@Relationship` macro | Explicit foreign keys | | **CloudKit** | Automatic sync | Manual SyncEngine + sharing | | **Backend** | Core Data | GRDB + SQLite | | **Learning Curve** | Easy (native) | Moderate | | **Performance** | Good | Excellent (raw SQL) | ## tvOS **SwiftData on tvOS has no persistent local storage.** tvOS has no Document directory, and Application Support maps to Caches — the system deletes files under storage pressure. A local-only SwiftData store will lose all data. **You must use CloudKit sync** (`cloudKitDatabase: .private(...)`) for tvOS SwiftData apps. Without iCloud, user data does not survive between app launches. See `axiom-tvos` for full tvOS storage constraints. --- ## Resources **Docs**: /swiftdata, /swiftdata/adopting-inheritance-in-swiftdata **Skills**: axiom-swiftdata-migration, axiom-swiftdata-migration-diag, axiom-database-migration, axiom-sqlitedata, axiom-grdb, axiom-swift-concurrency ## Common Mistakes ### ❌ Forgetting explicit init ```swift @Model final class Track { var id: String var title: String // No init - won't compile } ``` **Fix** Always provide `init` for `@Model` classes ### ❌ Using structs ```swift @Model struct Track { } // Won't work - must be class ``` **Fix** Use `final class` not `struct` ### ❌ Background operations on main context ```swift @Environment(\.modelContext) var context // Main actor only Task { // ❌ Crash - crossing actor boundaries context.insert(track) } ``` **Fix** Use `ModelContext(modelContainer)` for background work ### ❌ Not saving when needed ```swift modelContext.insert(track) // Might not persist immediately ``` **Fix** Call `try modelContext.save()` for immediate persistence --- **Created** 2025-11-28 **Targets** iOS 17+ (focus on iOS 26+ features) **Framework** SwiftData (Apple) **Swift** 5.9+ (Swift 6 concurrency patterns)