Claude-skill-registry axiom-swiftdata
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
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/axiom-swiftdata" ~/.claude/skills/majiayu000-claude-skill-registry-axiom-swiftdata && rm -rf "$T"
skills/data/axiom-swiftdata/SKILL.mdSwiftData
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 to SwiftData. What are the biggest differences in how we write code?"
→ The skill shows Realm → SwiftData pattern equivalents: @Persisted → @Attribute, threading model differences, relationship handling
13. "We have Core Data in production. What's the safest way to migrate to SwiftData while keeping both running?"
→ The skill covers dual-stack migration: reading Core Data, writing to SwiftData, marking migrated records, gradual cutover, validation
14. "Our Realm app uses background threads for all database operations. How do I convert to SwiftData's async/await model?"
→ The skill explains thread-confinement migration: actor-based safety, removing manual DispatchQueue, proper async context patterns, Swift 6 concurrency
15. "I need to migrate our CloudKit sync from Realm Sync (deprecated) to SwiftData CloudKit integration."
→ The skill shows Realm Sync → SwiftData CloudKit migration, addressing sync feature gaps, testing new sync implementation
@Model Definitions
Basic Model
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
, notfinal classstruct - Use
for primary key-like behavior@Attribute(.unique) - Provide explicit
(SwiftData doesn't synthesize)init - Optional properties (
) are nullableString?
Relationships
@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
@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
// 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
user1.following.append(user2) user2.followers.append(user1) // Redundant! Creates duplicates in CloudKit sync
Unfollowing (remove from ONE side only)
user1.following.removeAll { $0.id == user2.id } try modelContext.save() // user2.followers automatically updated
Verifying relationship integrity (for debugging)
// 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)
// 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
- Delete related objects.cascade
- Set relationship to nil.nullify
- Prevent deletion if relationship exists.deny
- Leave relationship as-is (careful!).noAction
ModelContainer Setup
SwiftUI App
import SwiftUI import SwiftData @main struct MusicApp: App { var body: some Scene { WindowGroup { ContentView() } .modelContainer(for: [Track.self, Album.self]) } }
Custom Configuration
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)
let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer( for: schema, configurations: config )
Queries in SwiftUI
Basic @Query
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 Query
struct RockTracksView: View { @Query(filter: #Predicate<Track> { track in track.genre == "Rock" }) var rockTracks: [Track] var body: some View { List(rockTracks) { track in Text(track.title) } } }
Sorted Query
@Query(sort: \.title, order: .forward) var tracks: [Track] // Multiple sort descriptors @Query(sort: [ SortDescriptor(\.artist), SortDescriptor(\.title) ]) var tracks: [Track]
Combined Filter + Sort
@Query( filter: #Predicate<Track> { $0.duration > 180 }, sort: \.title ) var longTracks: [Track]
ModelContext Operations
Accessing ModelContext
struct ContentView: View { @Environment(\.modelContext) private var modelContext func addTrack() { let track = Track( id: UUID().uuidString, title: "New Song", artist: "Artist", duration: 240 ) modelContext.insert(track) } }
Insert
let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) modelContext.insert(track) // Save immediately (optional - auto-saves on view disappear) try modelContext.save()
Fetch
let descriptor = FetchDescriptor<Track>( 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" // Save if needed immediately try modelContext.save()
Delete
modelContext.delete(track) try modelContext.save()
Batch Delete
try modelContext.delete(model: Track.self, where: #Predicate { track in track.genre == "Classical" })
Predicates
Basic Comparisons
#Predicate<Track> { $0.duration > 180 } #Predicate<Track> { $0.artist == "Artist Name" } #Predicate<Track> { $0.genre != nil }
Compound Predicates
#Predicate<Track> { track in track.genre == "Rock" && track.duration > 180 } #Predicate<Track> { track in track.artist == "Artist" || track.artist == "Other Artist" }
String Matching
// Contains #Predicate<Track> { track in track.title.contains("Love") } // Case-insensitive contains #Predicate<Track> { track in track.title.localizedStandardContains("love") } // Starts with #Predicate<Track> { track in track.artist.hasPrefix("The ") }
Relationship Predicates
#Predicate<Track> { track in track.album?.title == "Album Name" } #Predicate<Album> { album in album.tracks.count > 10 }
Swift 6 Concurrency
@MainActor Isolation
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
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.
CloudKit Integration
Enable CloudKit Sync
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
- Enable iCloud in Xcode (Signing & Capabilities)
- Select CloudKit
- 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
@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
@Model final class Track { @Relationship(deleteRule: .cascade, inverse: \Album.tracks) var album: Album? // ✅ Must be optional for CloudKit }
Monitoring Sync Status (iOS 26+)
struct ContentView: View { @Environment(\.modelContext) private var modelContext @State private var isSyncing = false var body: some View { VStack { if isSyncing { Label("Syncing with iCloud...", systemImage: "icloud.and.arrow.up.fill") .foregroundColor(.blue) } List { // Your content } } .task { // Monitor sync notifications for await notification in NotificationCenter.default .notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) { isSyncing = false } } } }
Handling CloudKit Sync Conflicts
SwiftData uses last-write-wins by default. If you need custom resolution:
@MainActor @Model final class Track { @Attribute(.unique) var id: String = UUID().uuidString var title: String = "" var lastModified: Date = Date() // Track modification time var deviceID: String = "" // Track which device modified init(id: String = UUID().uuidString, title: String = "", deviceID: String) { self.id = id self.title = title self.deviceID = deviceID self.lastModified = Date() } } // Conflict resolution pattern: Keep newest version actor ConflictResolver { let modelContext: ModelContext init(context: ModelContext) { self.modelContext = context } func resolveTrackConflict(_ local: Track, _ remote: Track) { // Remote is newer if remote.lastModified > local.lastModified { local.title = remote.title local.lastModified = remote.lastModified local.deviceID = remote.deviceID } // Local is newer - keep local (do nothing) } }
Offline Handling & Network Status
import Network @MainActor class NetworkMonitor: ObservableObject { @Published var isConnected = false private let monitor = NWPathMonitor() init() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { self?.isConnected = path.status == .satisfied } } monitor.start(queue: DispatchQueue.global()) } } struct OfflineAwareView: View { @StateObject private var networkMonitor = NetworkMonitor() @Query var tracks: [Track] var body: some View { VStack { if !networkMonitor.isConnected { Label("You're offline. Changes will sync when online.", systemImage: "wifi.slash") .font(.caption) .foregroundColor(.orange) } List(tracks) { track in Text(track.title) } } } }
CloudKit Record Sharing (iOS 26+)
@MainActor @Model final class SharedPlaylist { @Attribute(.unique) var id: String = UUID().uuidString var name: String = "" var ownerID: String = "" // CloudKit User ID of owner @Relationship(deleteRule: .cascade, inverse: \Track.playlist) var tracks: [Track] = [] // Share metadata var sharedWith: [String] = [] // Array of shared user IDs var sharePermission: SharePermission = .readOnly init(name: String, ownerID: String) { self.name = name self.ownerID = ownerID } } enum SharePermission: String, Codable { case readOnly case readWrite } // Share a playlist with another user actor PlaylistSharing { let modelContainer: ModelContainer func sharePlaylist(_ playlist: SharedPlaylist, with userID: String) async throws { let context = ModelContext(modelContainer) // Add user to shared list if !playlist.sharedWith.contains(userID) { playlist.sharedWith.append(userID) try context.save() } // Note: Actual CloudKit share URL generation requires CKShare // This is handled by system frameworks } }
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
// ❌ 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)
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
- Sign in to iCloud on test device
- Enable CloudKit in Capabilities
- Use real device (simulator CloudKit is unreliable)
- Check iCloud status in Settings → [Your Name] → iCloud
iOS 26+ Features
Enhanced Relationship Handling
@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
@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
// Enable history tracking let config = ModelConfiguration( schema: schema, cloudKitDatabase: .private("iCloud.com.example.app"), allowsSave: true, isHistoryEnabled: true // iOS 26+ )
Performance Patterns
Batch Fetching
let descriptor = FetchDescriptor<Track>( sortBy: [SortDescriptor(\.title)] ) descriptor.fetchLimit = 100 // Paginate results let tracks = try modelContext.fetch(descriptor)
Prefetch Relationships (Prevent N+1 Queries)
let descriptor = FetchDescriptor<Track>() 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:
// ❌ SLOW: N+1 queries (1 fetch tracks + 100 fetch albums) let tracks = try modelContext.fetch(FetchDescriptor<Track>()) 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<Track>() 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:
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)
// ❌ 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:
@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
filters frequently@Query - ✅ 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:
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..<Swift.min($0 + size, count)]) } } }
Avoiding Retain Cycles in CloudKit Sync
When using CloudKit, avoid capturing
self in closures:
// ❌ Retain cycle with CloudKit sync actor TrackManager { func startSync() { Task { for await notification in NotificationCenter.default .notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) { self.refreshUI() // Potential retain cycle } } } } // ✅ Proper weak capture actor TrackManager { func startSync() { Task { [weak self] in guard let self else { return } for await notification in NotificationCenter.default .notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) { await self.refreshUI() } } } }
Common Patterns
Search
struct SearchableTracksView: View { @Query var tracks: [Track] @State private var searchText = "" var filteredTracks: [Track] { if searchText.isEmpty { return tracks } return tracks.filter { track in track.title.localizedStandardContains(searchText) || track.artist.localizedStandardContains(searchText) } } var body: some View { List(filteredTracks) { track in Text(track.title) } .searchable(text: $searchText) } }
Custom Sort
struct TracksView: View { @Query var tracks: [Track] @State private var sortOrder: SortOrder = .title enum SortOrder { case title, artist, duration } var sortedTracks: [Track] { switch sortOrder { case .title: return tracks.sorted { $0.title < $1.title } case .artist: return tracks.sorted { $0.artist < $1.artist } case .duration: return tracks.sorted { $0.duration < $1.duration } } } }
Undo/Redo
struct ContentView: View { @Environment(\.modelContext) private var modelContext @Environment(\.undoManager) private var undoManager func deleteTrack(_ track: Track) { modelContext.delete(track) // Undo is automatic with modelContext // Use Cmd+Z to undo } }
Migration Strategies: From Realm & Core Data
Migrating from Realm
Realm Pattern → SwiftData Equivalent
// REALM class RealmTrack: Object { @Persisted(primaryKey: true) var id: String @Persisted var title: String @Persisted var artist: String @Persisted var duration: TimeInterval } // SWIFTDATA @Model final class Track { @Attribute(.unique) var id: String = "" var title: String = "" var artist: String = "" var duration: TimeInterval = 0 init(id: String, title: String, artist: String, duration: TimeInterval) { self.id = id self.title = title self.artist = artist self.duration = duration } }
Thread Safety Migration (Realm → SwiftData)
// REALM: Required explicit threading model class RealmDataManager { func fetchTracksOnBackground() { DispatchQueue.global().async { let realm = try! Realm() // Must get Realm on each thread let tracks = realm.objects(RealmTrack.self) DispatchQueue.main.async { self.updateUI(tracks: Array(tracks)) } } } } // SWIFTDATA: Actor-based safety (Swift 6) actor SwiftDataManager { let modelContainer: ModelContainer func fetchTracks() async -> [Track] { let context = ModelContext(modelContainer) let descriptor = FetchDescriptor<Track>() return try! context.fetch(descriptor) } } // Usage (no manual threading needed) @MainActor class ViewController: UIViewController { @State private var tracks: [Track] = [] func loadTracks() async { tracks = await dataManager.fetchTracks() } }
Relationship Migration (Realm → SwiftData)
// REALM: Explicit linking class RealmAlbum: Object { @Persisted(primaryKey: true) var id: String @Persisted var title: String @Persisted var tracks: RealmSwiftCollection<RealmTrack> // Explicit collection } // SWIFTDATA: Inverse relationships automatic @Model final class Album { @Attribute(.unique) var id: String = "" var title: String = "" @Relationship(deleteRule: .cascade, inverse: \Track.album) var tracks: [Track] = [] } @Model final class Track { @Attribute(.unique) var id: String = "" var title: String = "" var album: Album? // Inverse automatically maintained }
Migration Scenario: Small App (< 10,000 records)
actor RealmToSwiftDataMigration { let modelContainer: ModelContainer func migrateFromRealm(_ realmPath: String) async throws { // 1. Read from Realm database file let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath)) let realm = try await Realm(configuration: realmConfig) // 2. Create SwiftData models let context = ModelContext(modelContainer) try realm.objects(RealmTrack.self).forEach { realmTrack in let track = Track( id: realmTrack.id, title: realmTrack.title, artist: realmTrack.artist, duration: realmTrack.duration ) context.insert(track) } // 3. Save to SwiftData try context.save() // 4. Verify migration let descriptor = FetchDescriptor<Track>() let tracks = try context.fetch(descriptor) print("Migrated \(tracks.count) tracks") } }
Migrating from Core Data
Core Data Pattern → SwiftData Equivalent
// CORE DATA @NSManaged class CDTrack: NSManagedObject { @NSManaged var id: String @NSManaged var title: String @NSManaged var duration: TimeInterval @NSManaged var album: CDAlbum? } // SWIFTDATA @Model final class Track { @Attribute(.unique) var id: String = "" var title: String = "" var duration: TimeInterval = 0 var album: Album? }
Thread Confinement Migration (Core Data → SwiftData)
// CORE DATA: Manual thread handling class CoreDataManager { var persistentContainer: NSPersistentContainer func fetchTracks(completion: @escaping ([CDTrack]) -> Void) { let context = persistentContainer.newBackgroundContext() context.perform { let request = NSFetchRequest<CDTrack>(entityName: "Track") let results = try! context.fetch(request) DispatchQueue.main.async { completion(results) // ❌ Can't cross thread boundary with NSManagedObject } } } } // SWIFTDATA: Safe async/await class SwiftDataManager { let modelContainer: ModelContainer func fetchTracks() async -> [Track] { let context = ModelContext(modelContainer) let descriptor = FetchDescriptor<Track>() return (try? context.fetch(descriptor)) ?? [] } }
Batch Operations Migration (Core Data → SwiftData)
// CORE DATA: Complex batch delete class CoreDataBatchDelete { var persistentContainer: NSPersistentContainer func deleteOldTracks(olderThan date: Date) { let context = persistentContainer.newBackgroundContext() let request = NSFetchRequest<CDTrack>(entityName: "Track") request.predicate = NSPredicate(format: "createdAt < %@", date as NSDate) let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) deleteRequest.resultType = .resultTypeCount do { let result = try context.execute(deleteRequest) as? NSBatchDeleteResult print("Deleted \(result?.result ?? 0) tracks") } catch { print("Delete failed: \(error)") } } } // SWIFTDATA: Simple and safe actor SwiftDataBatchDelete { let modelContainer: ModelContainer func deleteOldTracks(olderThan date: Date) async throws { let context = ModelContext(modelContainer) try context.delete(model: Track.self, where: #Predicate { track in track.createdAt < date }) } }
Migration Scenario: Enterprise App (Gradual Migration)
// Phase 1: Parallel persistence (Core Data + SwiftData) class DualStackDataManager { let coreDataStack: CoreDataStack let swiftDataContainer: ModelContainer func migrateRecord(coreDataTrack: CDTrack) async throws { // 1. Read from Core Data let id = coreDataTrack.id let title = coreDataTrack.title let artist = coreDataTrack.artist let duration = coreDataTrack.duration // 2. Write to SwiftData let context = ModelContext(swiftDataContainer) let track = Track( id: id, title: title, artist: artist, duration: duration ) context.insert(track) try context.save() // 3. Mark as migrated in Core Data coreDataTrack.isMigratedToSwiftData = true } // Phase 2: Cutover (mark Core Data as deprecated) func completeMigration() { print("Migration complete — Core Data can be removed") } }
CloudKit Sync Migration (Realm → SwiftData)
// Realm uses Realm Sync (now deprecated) // SwiftData uses CloudKit directly @Model final class SyncedTrack { @Attribute(.unique) var id: String = UUID().uuidString var title: String = "" var syncedAt: Date = Date() init(id: String = UUID().uuidString, title: String) { self.id = id self.title = title } } // Enable CloudKit sync in ModelConfiguration let schema = Schema([SyncedTrack.self]) let config = ModelConfiguration( schema: schema, cloudKitDatabase: .private("iCloud.com.example.MusicApp") ) let container = try ModelContainer(for: schema, configurations: config)
Testing
Test Setup
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<Track>() 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 | | |
| Queries | in SwiftUI | / |
| Relationships | 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) |
Quick Reference
Common Operations
// Insert let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) modelContext.insert(track) // Fetch all @Query var tracks: [Track] // Fetch filtered @Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] // Fetch sorted @Query(sort: \.title) var sortedTracks: [Track] // Update track.title = "Updated" // Delete modelContext.delete(track) // Save try modelContext.save()
Resources
Docs: /swiftdata
Skills: axiom-swiftdata-migration, axiom-swiftdata-migration-diag, axiom-database-migration, axiom-sqlitedata, axiom-grdb, axiom-swift-concurrency
Common Mistakes
❌ Forgetting explicit init
@Model final class Track { var id: String var title: String // No init - won't compile }
Fix Always provide
init for @Model classes
❌ Using structs
@Model struct Track { } // Won't work - must be class
Fix Use
final class not struct
❌ Background operations on main context
@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
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)