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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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)
@QueryFor migrations See the swiftdata-migration skill for custom schema migrations with VersionedSchema and SchemaMigrationPlan. For migration debugging, see swiftdata-migration-diag.
These are real questions developers ask that this skill is designed to answer:
→ The skill shows how to use @Query with predicates, sorting, and automatic view updates
→ The skill explains @Relationship with deleteRule: .cascade and inverse relationships
→ The skill shows cascading deletes, inverse relationships, and safe deletion patterns
→ The skill covers CloudKit integration, conflict resolution strategies (last-write-wins, custom resolution), and sync patterns
→ The skill explains CloudKit constraints: all properties must be optional or have defaults, explains why (network timing), and shows fixes
→ The skill shows monitoring sync status with notifications, detecting network connectivity, and offline-aware UI patterns
→ The skill covers CloudKit record sharing patterns (iOS 26+) with owner/permission tracking and sharing metadata
→ The skill covers performance patterns, batch fetching, limiting queries, and preventing memory bloat with chunked imports
→ The skill shows how to identify N+1 problems without prefetching, provides prefetching pattern, and shows 100x performance improvement
→ The skill shows chunk-based importing with periodic saves, memory cleanup patterns, and batch operation optimization
→ The skill explains index optimization patterns: when to index (frequently filtered/sorted properties), when to avoid (rarely used, frequently changing), maintenance costs
→ The skill shows Realm → SwiftData pattern equivalents: @Persisted → @Attribute, threading model differences, relationship handling
→ The skill covers dual-stack migration: reading Core Data, writing to SwiftData, marking migrated records, gradual cutover, validation
→ The skill explains thread-confinement migration: actor-based safety, removing manual DispatchQueue, proper async context patterns, Swift 6 concurrency
→ The skill shows Realm Sync → SwiftData CloudKit migration, addressing sync feature gaps, testing new sync implementation
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
}
}
final class, not struct@Attribute(.unique) for primary key-like behaviorinit (SwiftData doesn't synthesize)String?) are nullable@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
}
}
@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
}
}
✅ 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
user1.following.removeAll { $0.id == user2.id }
try modelContext.save()
// user2.followers automatically updated
// 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!")
// 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
.cascade - Delete related objects.nullify - Set relationship to nil.deny - Prevent deletion if relationship exists.noAction - Leave relationship as-is (careful!)import SwiftUI
import SwiftData
@main
struct MusicApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Track.self, Album.self])
}
}
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
)
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: schema,
configurations: config
)
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.
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)
}
}
}
@Query(sort: \.title, order: .forward) var tracks: [Track]
// Multiple sort descriptors
@Query(sort: [
SortDescriptor(\.artist),
SortDescriptor(\.title)
]) var tracks: [Track]
@Query(
filter: #Predicate<Track> { $0.duration > 180 },
sort: \.title
) var longTracks: [Track]
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)
}
}
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()
let descriptor = FetchDescriptor<Track>(
predicate: #Predicate { $0.genre == "Rock" },
sortBy: [SortDescriptor(\.title)]
)
let rockTracks = try modelContext.fetch(descriptor)
// Just modify properties — SwiftData tracks changes
track.title = "Updated Title"
// Save if needed immediately
try modelContext.save()
modelContext.delete(track)
try modelContext.save()
try modelContext.delete(model: Track.self, where: #Predicate { track in
track.genre == "Classical"
})
#Predicate<Track> { $0.duration > 180 }
#Predicate<Track> { $0.artist == "Artist Name" }
#Predicate<Track> { $0.genre != nil }
#Predicate<Track> { track in
track.genre == "Rock" && track.duration > 180
}
#Predicate<Track> { track in
track.artist == "Artist" || track.artist == "Other Artist"
}
// 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 ")
}
#Predicate<Track> { track in
track.album?.title == "Album Name"
}
#Predicate<Album> { album in
album.tracks.count > 10
}
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.
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.
let schema = Schema([Track.self])
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.MusicApp")
)
let container = try ModelContainer(
for: schema,
configurations: config
)
iCloud.com.example.MusicAppNote SwiftData CloudKit sync is automatic - no manual conflict resolution needed.
@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
}
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
}
}
}
}
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)
}
}
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)
}
}
}
}
@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
}
}
Problem You get this error when trying to use CloudKit sync:
Property 'title' must be optional or have a default value for CloudKit synchronization
// ❌ 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?
}
let schema = Schema([Track.self])
// Test configuration (no CloudKit sync)
let testConfig = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: schema, configurations: testConfig)
@Model
final class Track {
@Relationship(
deleteRule: .cascade,
inverse: \Album.tracks,
minimum: 0,
maximum: 1 // Track belongs to at most one album
) var album: Album?
}
@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.
// Enable history tracking
let config = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private("iCloud.com.example.app"),
allowsSave: true,
isHistoryEnabled: true // iOS 26+
)
let descriptor = FetchDescriptor<Track>(
sortBy: [SortDescriptor(\.title)]
)
descriptor.fetchLimit = 100 // Paginate results
let tracks = try modelContext.fetch(descriptor)
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
}
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)
// ❌ 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
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]
@Query filters frequentlyFor 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)])
}
}
}
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()
}
}
}
}
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)
}
}
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 }
}
}
}
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
}
}
// 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
}
}
// 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()
}
}
// 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
}
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")
}
}
// 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?
}
// 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)) ?? []
}
}
// 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
})
}
}
// 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")
}
}
// 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)
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")
}
}
| 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) |
// 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()
swiftdata-migration - Custom schema migrations with VersionedSchema and SchemaMigrationPlanswiftdata-migration-diag - Debugging failed SwiftData migrationsdatabase-migration - General migration safety patterns (SQLite/GRDB)sqlitedata - Value types with CloudKit sharinggrdb - Raw SQL when neededswift-concurrency - @MainActor and actor patterns@Model
final class Track {
var id: String
var title: String
// No init - won't compile
}
Fix Always provide init for @Model classes
@Model
struct Track { } // Won't work - must be class
Fix Use final class not struct
@Environment(\.modelContext) var context // Main actor only
Task {
// ❌ Crash - crossing actor boundaries
context.insert(track)
}
Fix Use ModelContext(modelContainer) for background work
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)