ios-persistence-complete/SKILL.md
Complete iOS persistence guide covering SwiftData (iOS 17-26), CloudKit sync, ModelActor concurrency, schema migrations, UserDefaults, Keychain, File System, and Core Data. Production patterns, common pitfalls, and performance optimization. Triggers on SwiftData, persistence, CloudKit sync, CloudKit constraints, ModelActor, ModelContext, batch operations, schema migration, VersionedSchema, history tracking, UserDefaults, Keychain, Core Data, storage, database, @Model, @Query,
npx skillsauth add abanoub-ashraf/manus-skills-import ios-persistence-completeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
4 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Comprehensive guide for iOS data persistence covering all storage options from simple preferences to complex relational data with cloud sync. Covers iOS 17 through iOS 26 features.
| Use Case | Solution | |----------|----------| | User preferences, settings | UserDefaults | | Sensitive data (tokens, passwords) | Keychain | | Structured app data (iOS 17+) | SwiftData | | Structured app data (iOS 16 and earlier) | Core Data | | Large files (images, documents) | File System | | Temporary cache | NSCache + Disk cache | | Simple key-value store | UserDefaults or Property List | | Cloud-synced structured data | SwiftData + CloudKit |
Decision Tree:
Is the data sensitive (passwords, tokens, API keys)?
└── YES → Keychain
└── NO → Is it a simple preference/setting?
└── YES → UserDefaults / @AppStorage
└── NO → Is it a large binary file (image, video)?
└── YES → File System (Documents or Caches directory)
└── NO → Is it structured/relational data?
└── YES → SwiftData (iOS 17+) or Core Data
└── NO → UserDefaults with Codable
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
let container: UserDefaults
init(_ key: String, defaultValue: T, container: UserDefaults = .standard) {
self.key = key
self.defaultValue = defaultValue
self.container = container
}
var wrappedValue: T {
get { container.object(forKey: key) as? T ?? defaultValue }
set { container.set(newValue, forKey: key) }
}
}
struct SettingsView: View {
@AppStorage("isDarkMode") private var isDarkMode = false
@AppStorage("username") private var username = ""
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("selectedTheme", store: UserDefaults(suiteName: "group.com.myapp"))
private var selectedTheme = "default"
var body: some View {
Form {
Toggle("Dark Mode", isOn: $isDarkMode)
TextField("Username", text: $username)
Toggle("Notifications", isOn: $notificationsEnabled)
}
}
}
extension UserDefaults {
func setCodable<T: Codable>(_ value: T, forKey key: String) {
if let data = try? JSONEncoder().encode(value) {
set(data, forKey: key)
}
}
func codable<T: Codable>(forKey key: String) -> T? {
guard let data = data(forKey: key) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}
// Usage
struct UserPreferences: Codable {
var theme: String
var fontSize: Int
var recentSearches: [String]
}
UserDefaults.standard.setCodable(preferences, forKey: "userPreferences")
let prefs: UserPreferences? = UserDefaults.standard.codable(forKey: "userPreferences")
Best Practices:
actor KeychainManager {
enum KeychainError: Error {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
}
func save(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
try update(data, for: key)
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}
func read(for key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.itemNotFound
}
return data
}
func delete(for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
private func update(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
}
// Convenience extension for Codable
extension KeychainManager {
func save<T: Codable>(_ item: T, for key: String) throws {
let data = try JSONEncoder().encode(item)
try save(data, for: key)
}
func read<T: Codable>(for key: String) throws -> T {
let data = try read(for: key)
return try JSONDecoder().decode(T.self, from: data)
}
}
// Usage
let keychain = KeychainManager()
Task {
// Save API token
try await keychain.save("secret_token_123".data(using: .utf8)!, for: "apiToken")
// Read token
let tokenData = try await keychain.read(for: "apiToken")
let token = String(data: tokenData, encoding: .utf8)
// Save Codable credentials
struct Credentials: Codable {
let username: String
let refreshToken: String
}
try await keychain.save(Credentials(username: "user", refreshToken: "refresh_123"), for: "credentials")
}
Accessibility Options:
kSecAttrAccessibleAfterFirstUnlock - Available after first unlock (recommended for most cases)kSecAttrAccessibleWhenUnlocked - Only when device is unlockedkSecAttrAccessibleAlways - Always available (less secure)kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly - Requires passcode, device-onlyactor FileStorageManager {
private let fileManager = FileManager.default
var documentsDirectory: URL {
fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
var cachesDirectory: URL {
fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}
func save(_ data: Data, filename: String, in directory: URL? = nil) throws -> URL {
let targetDir = directory ?? documentsDirectory
let fileURL = targetDir.appendingPathComponent(filename)
try data.write(to: fileURL)
return fileURL
}
func load(filename: String, from directory: URL? = nil) throws -> Data {
let targetDir = directory ?? documentsDirectory
let fileURL = targetDir.appendingPathComponent(filename)
return try Data(contentsOf: fileURL)
}
func delete(filename: String, from directory: URL? = nil) throws {
let targetDir = directory ?? documentsDirectory
let fileURL = targetDir.appendingPathComponent(filename)
try fileManager.removeItem(at: fileURL)
}
func listFiles(in directory: URL? = nil) throws -> [URL] {
let targetDir = directory ?? documentsDirectory
return try fileManager.contentsOfDirectory(at: targetDir, includingPropertiesForKeys: nil)
}
func fileExists(filename: String, in directory: URL? = nil) -> Bool {
let targetDir = directory ?? documentsDirectory
let fileURL = targetDir.appendingPathComponent(filename)
return fileManager.fileExists(atPath: fileURL.path)
}
}
actor ImageCache {
private let memoryCache = NSCache<NSString, UIImage>()
private let diskCacheURL: URL
init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
diskCacheURL = caches.appendingPathComponent("ImageCache")
try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
// Configure memory cache
memoryCache.countLimit = 100
memoryCache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func image(for key: String) async -> UIImage? {
// Check memory first
if let image = memoryCache.object(forKey: key as NSString) {
return image
}
// Check disk
let fileURL = diskCacheURL.appendingPathComponent(key.hash.description)
if let data = try? Data(contentsOf: fileURL),
let image = UIImage(data: data) {
memoryCache.setObject(image, forKey: key as NSString)
return image
}
return nil
}
func store(_ image: UIImage, for key: String) {
memoryCache.setObject(image, forKey: key as NSString)
let fileURL = diskCacheURL.appendingPathComponent(key.hash.description)
if let data = image.jpegData(compressionQuality: 0.8) {
try? data.write(to: fileURL)
}
}
func clear() {
memoryCache.removeAllObjects()
try? FileManager.default.removeItem(at: diskCacheURL)
try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
}
}
Directory Guidelines:
import SwiftData
@Model
class Document {
// MARK: - Basic Properties
var title: String
var content: String
// MARK: - Unique Constraint
@Attribute(.unique)
var id: UUID
// MARK: - External Storage (large binary data)
// Stored outside SQLite for better performance
@Attribute(.externalStorage)
var imageData: Data?
// MARK: - Encryption (sensitive data)
@Attribute(.encrypt)
var sensitiveNotes: String?
// MARK: - Preserve on Deletion (audit trails)
@Attribute(.preserveValueOnDeletion)
var createdAt: Date
// MARK: - Spotlight Indexing
@Attribute(.spotlight)
var searchableTitle: String
// MARK: - Transformable (custom types)
@Attribute(.transformable(by: ColorTransformer.self))
var themeColor: UIColor?
// MARK: - Original Name (for migrations)
@Attribute(originalName: "oldPropertyName")
var renamedProperty: String
// MARK: - Ephemeral (not persisted)
@Transient
var isSelected: Bool = false
@Transient
var wordCount: Int {
content.split(separator: " ").count
}
init(title: String, content: String = "") {
self.id = UUID()
self.title = title
self.searchableTitle = title
self.content = content
self.createdAt = Date()
}
}
@Model
class TypeShowcase {
// Primitives
var string: String
var int: Int
var double: Double
var bool: Bool
var float: Float
// Foundation Types
var date: Date
var data: Data
var url: URL
var uuid: UUID
var decimal: Decimal
// Optionals
var optionalString: String?
var optionalDate: Date?
// Collections (of supported types)
var stringArray: [String]
var intSet: Set<Int>
var dictionary: [String: Int] // Keys must be String
// Codable Enums
var status: Status
// Codable Structs (stored as JSON)
var metadata: Metadata
init() {
self.string = ""
self.int = 0
self.double = 0.0
self.bool = false
self.float = 0.0
self.date = Date()
self.data = Data()
self.url = URL(string: "https://example.com")!
self.uuid = UUID()
self.decimal = 0
self.stringArray = []
self.intSet = []
self.dictionary = [:]
self.status = .draft
self.metadata = Metadata()
}
}
enum Status: String, Codable {
case draft, published, archived
}
struct Metadata: Codable {
var version: Int = 1
var tags: [String] = []
}
@Model
class Author {
var name: String
// CASCADE: Deleting author deletes ALL their books
// Use for: owned children that have no meaning without parent
@Relationship(deleteRule: .cascade)
var ownedBooks: [Book]
// NULLIFY (default): Deleting author sets book.editor to nil
// Use for: optional references
@Relationship(deleteRule: .nullify)
var editedBooks: [Book]
// DENY: Cannot delete author if they have published books
// Use for: required references, preventing orphans
@Relationship(deleteRule: .deny)
var publishedBooks: [Book]
// NO_ACTION: Do nothing (can create orphaned data!)
// Use for: rarely, only when manually handling cleanup
@Relationship(deleteRule: .noAction)
var reviewedBooks: [Book]
init(name: String) {
self.name = name
self.ownedBooks = []
self.editedBooks = []
self.publishedBooks = []
self.reviewedBooks = []
}
}
@Model
class Book {
var title: String
// Inverse relationships for bidirectional sync
@Relationship(inverse: \Author.ownedBooks)
var author: Author?
@Relationship(inverse: \Author.editedBooks)
var editor: Author?
init(title: String) {
self.title = title
}
}
@Model
class Trip {
var name: String
var startDate: Date
var endDate: Date
var destination: String
init(name: String, startDate: Date, endDate: Date, destination: String) {
self.name = name
self.startDate = startDate
self.endDate = endDate
self.destination = destination
}
}
// Compound unique: same name allowed if dates differ
#Unique<Trip>([\.name, \.startDate, \.endDate])
// On collision, SwiftData performs UPSERT (update existing)
@Model
class Event {
var title: String
var date: Date
var category: String
var priority: Int
var isCompleted: Bool
init(title: String, date: Date, category: String, priority: Int) {
self.title = title
self.date = date
self.category = category
self.priority = priority
self.isCompleted = false
}
}
// Single property index - speeds up date-based queries
#Index<Event>([\.date])
// Compound index - for queries filtering both fields
#Index<Event>([\.category, \.priority])
// Multiple indexes on same model
#Index<Event>([\.isCompleted])
// USE indexes for:
// - Frequently filtered properties (WHERE clauses)
// - Sort keys used in large datasets
// - Foreign key-like relationships
// AVOID indexes for:
// - Rarely queried properties
// - Properties that change frequently (index rebuild cost)
// - Small datasets (< 1000 rows)
// - Boolean properties on small tables
import SwiftData
// Base class with shared properties
@Model
class MediaItem {
var title: String
var dateAdded: Date
var isFavorite: Bool
var fileSize: Int64
init(title: String) {
self.title = title
self.dateAdded = Date()
self.isFavorite = false
self.fileSize = 0
}
}
// Photo subclass
@Model
class Photo: MediaItem {
var imageData: Data
var width: Int
var height: Int
var location: String?
init(title: String, imageData: Data, width: Int, height: Int) {
self.imageData = imageData
self.width = width
self.height = height
super.init(title: title)
}
}
// Video subclass
@Model
class Video: MediaItem {
var duration: TimeInterval
var resolution: String
var hasAudio: Bool
init(title: String, duration: TimeInterval, resolution: String) {
self.duration = duration
self.resolution = resolution
self.hasAudio = true
super.init(title: title)
}
}
struct MediaLibraryView: View {
// Query ALL media items (includes photos, videos)
@Query(sort: \.dateAdded, order: .reverse)
var allMedia: [MediaItem]
// Query only photos
@Query var photos: [Photo]
// Query only videos with filter
@Query(filter: #Predicate<Video> { $0.duration > 60 })
var longVideos: [Video]
var body: some View {
List {
Section("All Media (\(allMedia.count))") {
ForEach(allMedia) { item in
MediaRow(item: item)
}
}
}
}
}
// Type checking at runtime
func processMedia(_ item: MediaItem) {
switch item {
case let photo as Photo:
print("Photo: \(photo.width)x\(photo.height)")
case let video as Video:
print("Video: \(video.duration)s")
default:
print("Unknown media type")
}
}
WARNING: Wide Table Problem
SwiftData stores ALL subclasses in a SINGLE table (like Core Data). Each subclass adds columns to the parent table.
// Example: 3 subclasses with distinct properties
// Results in ONE table with columns:
// | id | title | dateAdded | isFavorite | fileSize | -- MediaItem
// | imageData | width | height | location | -- Photo (NULL for others)
// | duration | resolution | hasAudio | -- Video (NULL for others)
// Problems with MANY subclasses:
// 1. Wide tables with many NULL columns
// 2. Memory bandwidth waste loading NULL fields
// 3. Index bloat slowing inserts/updates
// 4. Query performance degradation
// RECOMMENDATION:
// - Limit to 3-5 subclasses maximum
// - Consider composition pattern for 10+ types
// - Use protocols instead if no shared queries needed
// GOOD USE CASES:
// - Natural "is-a" relationships (Meeting IS an Event)
// - Need to query parent + all children together
// - Shared behavior/properties (> 3 shared fields)
// - Hierarchical domain (Employee -> Manager, Developer)
// AVOID INHERITANCE WHEN:
// - Only 1-2 shared properties (use protocol)
// - Many subclass types (> 5-7 types)
// - Subclasses have mostly distinct properties
// - Never query parent type directly
// - Performance is critical with large datasets
import SwiftData
import SwiftUI
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([
Trip.self,
Activity.self,
Photo.self
])
// Full configuration options
let configuration = ModelConfiguration(
"MyAppDatabase", // Custom store name
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: .identifier("group.com.myapp.shared"), // App Groups
cloudKitDatabase: .private("iCloud.com.myapp.container")
)
do {
container = try ModelContainer(
for: schema,
configurations: configuration
)
} catch {
fatalError("Failed to initialize ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
// Separate stores for different data types
let userDataConfig = ModelConfiguration(
"UserData",
schema: Schema([User.self, Preferences.self]),
cloudKitDatabase: .private("iCloud.com.myapp")
)
let cacheConfig = ModelConfiguration(
"CacheData",
schema: Schema([CachedImage.self, CachedResponse.self]),
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // No sync for cache
)
let container = try ModelContainer(
for: Schema([User.self, Preferences.self, CachedImage.self, CachedResponse.self]),
configurations: userDataConfig, cacheConfig
)
class DataManager {
let context: ModelContext
init(container: ModelContainer) {
self.context = ModelContext(container)
}
// MARK: - Insert
func createTrip(name: String, destination: String) -> Trip {
let trip = Trip(name: name, destination: destination)
context.insert(trip)
// Note: Related objects in graph are auto-inserted
return trip
}
// MARK: - Fetch with Pagination
func fetchTrips(page: Int, pageSize: Int = 20) throws -> [Trip] {
var descriptor = FetchDescriptor<Trip>(
sortBy: [SortDescriptor(\.startDate)]
)
descriptor.fetchLimit = pageSize
descriptor.fetchOffset = page * pageSize
return try context.fetch(descriptor)
}
// MARK: - Count (Memory Efficient)
func tripCount() throws -> Int {
let descriptor = FetchDescriptor<Trip>()
return try context.fetchCount(descriptor)
}
// MARK: - Fetch Identifiers Only
func tripIdentifiers() throws -> [PersistentIdentifier] {
let descriptor = FetchDescriptor<Trip>()
return try context.fetchIdentifiers(descriptor)
}
// MARK: - Transaction (Atomic Operations)
func transferActivities(from source: Trip, to destination: Trip) throws {
try context.transaction {
for activity in source.activities {
activity.trip = destination
}
// All changes saved atomically
}
}
// MARK: - Manual Save
func save() throws {
if context.hasChanges {
try context.save()
}
}
// MARK: - Rollback
func discardChanges() {
context.rollback()
}
// MARK: - Autosave Control
func configureAutosave(enabled: Bool) {
context.autosaveEnabled = enabled
}
}
struct TripListView: View {
// Simple fetch all
@Query var trips: [Trip]
// With sorting
@Query(sort: \.startDate, order: .reverse)
var recentTrips: [Trip]
// With predicate
@Query(filter: #Predicate<Trip> { $0.isFavorite })
var favoriteTrips: [Trip]
// With animation
@Query(sort: \.startDate, animation: .default)
var animatedTrips: [Trip]
// Complex: filter + sort + animation
@Query(
filter: #Predicate<Trip> { $0.destination.contains("Paris") },
sort: [
SortDescriptor(\.startDate, order: .reverse),
SortDescriptor(\.name)
],
animation: .spring
)
var parisTrips: [Trip]
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
}
}
}
// PROBLEM: #Predicate is compile-time, can't use runtime values directly
// SOLUTION: Wrapper view pattern
struct SearchableTripsView: View {
@State private var searchText = ""
@State private var showFavoritesOnly = false
var body: some View {
// Pass dynamic values to child view
FilteredTripsView(
searchText: searchText,
favoritesOnly: showFavoritesOnly
)
.searchable(text: $searchText)
.toolbar {
Toggle("Favorites", isOn: $showFavoritesOnly)
}
}
}
struct FilteredTripsView: View {
@Query var trips: [Trip]
// Initialize @Query with dynamic predicate
init(searchText: String, favoritesOnly: Bool) {
let predicate: Predicate<Trip>?
if searchText.isEmpty && !favoritesOnly {
predicate = nil
} else if searchText.isEmpty {
predicate = #Predicate { $0.isFavorite }
} else if !favoritesOnly {
predicate = #Predicate {
$0.name.localizedStandardContains(searchText)
}
} else {
predicate = #Predicate {
$0.isFavorite && $0.name.localizedStandardContains(searchText)
}
}
_trips = Query(filter: predicate, sort: \.startDate)
}
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
}
}
}
// WARNING 1: @Query only works in SwiftUI views
// This compiles but DOES NOT WORK:
class ViewModel: ObservableObject {
@Query var trips: [Trip] // Will always be empty!
}
// WARNING 2: @Query runs immediately on view init
// Avoid heavy queries in frequently recreated views
// WARNING 3: Large datasets can freeze UI
// Use fetchLimit for pagination:
@Query(sort: \.date) var allEvents: [Event] // BAD for 10k+ records
// WARNING 4: @Query limitations in previews
// Need explicit modelContainer setup
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Trip.self, configurations: config)
// Insert sample data
let context = container.mainContext
context.insert(Trip(name: "Sample", destination: "Paris"))
return TripListView()
.modelContainer(container)
}
struct AddTaskView: View {
@Environment(\.modelContext) private var context
@State private var title = ""
var body: some View {
Form {
TextField("Title", text: $title)
Button("Add Task") {
let task = Task(title: title)
context.insert(task)
// Auto-saves by default
}
}
}
}
struct TaskDetailView: View {
@Bindable var task: Task // Use @Bindable for two-way binding
var body: some View {
Form {
TextField("Title", text: $task.title)
Toggle("Complete", isOn: $task.isComplete)
DatePicker("Due Date", selection: Binding(
get: { task.dueDate ?? Date() },
set: { task.dueDate = $0 }
))
}
// Changes auto-save
}
}
struct TaskListView: View {
@Environment(\.modelContext) private var context
@Query var tasks: [Task]
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.onDelete(perform: deleteTasks)
}
}
private func deleteTasks(at offsets: IndexSet) {
for index in offsets {
context.delete(tasks[index])
}
}
}
@Model
class Project {
var name: String
// One project has many tasks
@Relationship(deleteRule: .cascade, inverse: \Task.project)
var tasks: [Task] = []
init(name: String) {
self.name = name
}
}
@Model
class Task {
var title: String
// Many tasks belong to one project
var project: Project?
init(title: String) {
self.title = title
}
}
// Usage
let project = Project(name: "Work")
let task = Task(title: "Finish report")
task.project = project
// Or: project.tasks.append(task)
@Model
class Task {
var title: String
@Relationship(inverse: \Tag.tasks)
var tags: [Tag] = []
}
@Model
class Tag {
var name: String
var tasks: [Task] = []
init(name: String) {
self.name = name
}
}
// Usage
let task = Task(title: "Important work")
let urgentTag = Tag(name: "Urgent")
task.tags.append(urgentTag)
var descriptor = FetchDescriptor<Project>()
descriptor.relationshipKeyPathsForPrefetching = [\Project.tasks]
let projects = try context.fetch(descriptor)
// tasks are already loaded, no additional fetches
// 1. Add capabilities in Xcode:
// - iCloud (CloudKit selected)
// - Background Modes (Remote notifications)
// 2. Configure ModelContainer
let config = ModelConfiguration(
cloudKitDatabase: .private("iCloud.com.myapp.container")
)
let container = try ModelContainer(
for: Trip.self, Activity.self,
configurations: config
)
// ALL these rules are MANDATORY for CloudKit sync:
@Model
class CloudSyncedTrip {
// RULE 1: All properties must have defaults OR be optional
var name: String = "" // Default value
var destination: String = "" // Default value
var startDate: Date = Date() // Default value
var notes: String? // Optional
var rating: Int? // Optional
// RULE 2: NO @Attribute(.unique) - CloudKit can't enforce uniqueness
// @Attribute(.unique) var id: UUID // WRONG!
var id: UUID = UUID() // Regular property, OK
// RULE 3: All relationships MUST be optional
@Relationship var activities: [Activity]? // Optional array
@Relationship var owner: User? // Optional reference
init(name: String, destination: String) {
self.name = name
self.destination = destination
self.startDate = Date()
self.id = UUID()
}
}
// Once your app is LIVE with CloudKit, you CANNOT:
// 1. DELETE entities or attributes
// 2. RENAME entities or attributes (CloudKit sees as DELETE + ADD = DATA LOSS)
// 3. CHANGE data types
// You CAN only:
// 4. ADD new attributes with defaults
// 5. ADD new optional attributes
// RECOMMENDATION:
// Keep unused fields in model, mark deprecated in comments
@Model
class Trip {
var name: String = ""
// DEPRECATED: Use 'destination' instead. Kept for CloudKit compatibility.
var location: String = ""
var destination: String = "" // New field
}
func initializeCloudKitSchema() {
#if DEBUG
do {
try container.mainContext.container.initializeCloudKitSchema()
print("CloudKit schema initialized successfully")
} catch {
print("Failed to initialize CloudKit schema: \(error)")
}
#endif
}
// GOTCHA 1: Simulator is unreliable for CloudKit testing
// Always test on real devices!
// GOTCHA 2: Sync is asynchronous and unpredictable
// Apple throttles based on: network, battery, user settings
// GOTCHA 3: Account switching clears local data
// When user switches iCloud account, local data is REPLACED
// GOTCHA 4: Conflict resolution is last-write-wins
// Implement custom tracking if needed:
@Model
class ConflictAwareTrip {
var name: String = ""
var lastModifiedAt: Date = Date()
var lastModifiedBy: String = "" // Device identifier
func update(name: String, deviceID: String) {
self.name = name
self.lastModifiedAt = Date()
self.lastModifiedBy = deviceID
}
}
// EFFICIENT: Bypasses object instantiation
func deleteOldTrips(olderThan date: Date) throws {
try context.delete(
model: Trip.self,
where: #Predicate { $0.endDate < date }
)
}
func deleteAllCompleted() throws {
try context.delete(
model: Task.self,
where: #Predicate { $0.isCompleted }
)
}
@ModelActor
actor DataImporter {
func importTrips(_ items: [TripDTO]) async throws {
// Process in chunks to manage memory
for batch in items.chunks(ofCount: 1000) {
for item in batch {
let trip = Trip(
name: item.name,
destination: item.destination
)
modelContext.insert(trip)
}
// Save after each batch to free memory
try modelContext.save()
}
}
func importWithProgress(_ items: [TripDTO],
progress: @escaping (Double) -> Void) async throws {
let total = Double(items.count)
var processed = 0.0
for batch in items.chunks(ofCount: 500) {
for item in batch {
let trip = Trip(name: item.name, destination: item.destination)
modelContext.insert(trip)
processed += 1
}
try modelContext.save()
await MainActor.run { progress(processed / total) }
}
}
}
// Usage
let importer = DataImporter(modelContainer: container)
Task.detached { // IMPORTANT: detached to run on background
try await importer.importTrips(largeDataset)
}
@ModelActor
actor BackgroundProcessor {
func processExpensiveTask() async throws {
// This automatically runs on a background thread
// modelContext is isolated to this actor
let descriptor = FetchDescriptor<Trip>()
let trips = try modelContext.fetch(descriptor)
for trip in trips {
trip.processedAt = Date()
}
try modelContext.save()
}
}
// ModelActor inherits execution context from WHERE it's created!
// WRONG: Created on main thread = runs on main thread!
class ViewController: UIViewController {
func badExample() {
let processor = BackgroundProcessor(modelContainer: container)
Task {
// This still runs on main thread!
try await processor.process()
}
}
}
// CORRECT: Create inside Task.detached for background execution
class ViewController: UIViewController {
func goodExample() {
Task.detached {
// Created on background = runs on background
let processor = BackgroundProcessor(modelContainer: self.container)
try await processor.process()
}
}
}
// NEVER pass PersistentModel objects between actors/contexts!
// They're tied to their ModelContext.
// WRONG: Passing model object
func badExample(trip: Trip) async {
await backgroundActor.process(trip) // CRASH or undefined behavior!
}
// CORRECT: Pass PersistentIdentifier
func goodExample(trip: Trip) async throws {
let result = await backgroundActor.process(tripID: trip.persistentModelID)
}
@ModelActor
actor BackgroundProcessor {
func process(tripID: PersistentIdentifier) async throws -> TripDTO {
// Fetch the model in THIS context using the identifier
guard let trip = modelContext.model(for: tripID) as? Trip else {
throw ProcessingError.notFound
}
trip.processedAt = Date()
try modelContext.save()
// Return a DTO (plain struct) for cross-context transfer
return TripDTO(
id: trip.id,
name: trip.name,
destination: trip.destination
)
}
}
// DTO for safe cross-boundary transfer
struct TripDTO: Sendable {
let id: UUID
let name: String
let destination: String
}
// Version 1: Initial schema
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[TripV1.self]
}
@Model
class TripV1 {
var name: String
var location: String
init(name: String, location: String) {
self.name = name
self.location = location
}
}
}
// Version 2: Renamed property + new field
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[TripV2.self]
}
@Model
class TripV2 {
@Attribute(.unique) var name: String
var destination: String // Renamed from 'location'
var startDate: Date? // New optional field
init(name: String, destination: String) {
self.name = name
self.destination = destination
}
}
}
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
// Lightweight migration: rename property
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
// Or custom migration for data transformation:
static let customMigration = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Pre-migration: clean up, dedupe, transform
let trips = try context.fetch(FetchDescriptor<SchemaV1.TripV1>())
var seen = Set<String>()
for trip in trips {
if seen.contains(trip.name) {
context.delete(trip)
} else {
seen.insert(trip.name)
}
}
try context.save()
},
didMigrate: { context in
// Post-migration: validation
let count = try context.fetchCount(FetchDescriptor<SchemaV2.TripV2>())
print("Migrated \(count) trips to V2")
}
)
}
// Apply migration plan
let container = try ModelContainer(
for: Trip.self,
migrationPlan: TripMigrationPlan.self
)
SwiftData handles simple changes automatically:
@Attribute(originalName:))@Model
class Task {
var title: String
// Renamed property - SwiftData migrates automatically
@Attribute(originalName: "done")
var isComplete: Bool
// New property with default - no migration needed
var notes: String = ""
}
import XCTest
import SwiftData
@testable import MyApp
final class TripTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUp() {
super.setUp()
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try! ModelContainer(
for: Trip.self, Activity.self,
configurations: config
)
context = ModelContext(container)
}
override func tearDown() {
container = nil
context = nil
super.tearDown()
}
func test_createTrip_insertsSuccessfully() throws {
// Given
let trip = Trip(name: "Paris", destination: "France")
// When
context.insert(trip)
try context.save()
// Then
let fetched = try context.fetch(FetchDescriptor<Trip>())
XCTAssertEqual(fetched.count, 1)
XCTAssertEqual(fetched.first?.name, "Paris")
}
func test_cascadeDelete_removesChildren() throws {
// Given
let trip = Trip(name: "London", destination: "UK")
let activity = Activity(title: "Big Ben")
trip.activities.append(activity)
context.insert(trip)
try context.save()
// When
context.delete(trip)
try context.save()
// Then
let trips = try context.fetch(FetchDescriptor<Trip>())
let activities = try context.fetch(FetchDescriptor<Activity>())
XCTAssertTrue(trips.isEmpty)
XCTAssertTrue(activities.isEmpty) // Cascade deleted
}
}
import Testing
import SwiftData
@testable import MyApp
@Suite
struct TripTests {
let container: ModelContainer
let context: ModelContext
init() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: Trip.self, configurations: config)
context = ModelContext(container)
}
@Test
func createTrip() throws {
let trip = Trip(name: "Test", destination: "Somewhere")
context.insert(trip)
let fetched = try context.fetch(FetchDescriptor<Trip>())
#expect(fetched.count == 1)
#expect(fetched.first?.name == "Test")
}
}
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(
for: Trip.self, Activity.self,
configurations: config
)
let context = container.mainContext
let paris = Trip(name: "Paris Adventure", destination: "France")
paris.activities = [
Activity(title: "Eiffel Tower"),
Activity(title: "Louvre Museum")
]
context.insert(paris)
return NavigationStack {
TripListView()
}
.modelContainer(container)
}
import SwiftData
class HistoryManager {
let context: ModelContext
private var lastToken: DefaultHistoryToken?
init(context: ModelContext) {
self.context = context
self.lastToken = loadSavedToken()
}
func fetchRecentChanges() throws -> [DefaultHistoryTransaction] {
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let token = lastToken {
descriptor.predicate = #Predicate { transaction in
transaction.token > token
}
}
return try context.fetchHistory(descriptor)
}
func processChanges() throws {
let transactions = try fetchRecentChanges()
for transaction in transactions {
for change in transaction.changes {
switch change {
case .insert(let model):
handleInsert(model)
case .update(let model):
handleUpdate(model)
case .delete(let identifier):
handleDelete(identifier)
}
}
lastToken = transaction.token
}
saveToken(lastToken)
}
private func loadSavedToken() -> DefaultHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: "historyToken") else { return nil }
return try? JSONDecoder().decode(DefaultHistoryToken.self, from: data)
}
private func saveToken(_ token: DefaultHistoryToken?) {
guard let token, let data = try? JSONEncoder().encode(token) else { return }
UserDefaults.standard.set(data, forKey: "historyToken")
}
}
import WidgetKit
import SwiftData
struct TripWidgetProvider: TimelineProvider {
let container: ModelContainer
func getTimeline(in context: Context, completion: @escaping (Timeline<TripEntry>) -> Void) {
let modelContext = ModelContext(container)
let lastUpdate = UserDefaults.standard.object(forKey: "widgetLastUpdate") as? Date ?? .distantPast
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
descriptor.predicate = #Predicate { transaction in
transaction.timestamp > lastUpdate
}
let hasChanges = (try? modelContext.fetchHistory(descriptor).isEmpty == false) ?? false
if hasChanges {
// Refresh widget data
let trips = try? modelContext.fetch(
FetchDescriptor<Trip>(predicate: #Predicate { $0.isFavorite })
)
let entry = TripEntry(date: Date(), trips: trips ?? [])
let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(3600)))
UserDefaults.standard.set(Date(), forKey: "widgetLastUpdate")
completion(timeline)
}
}
}
Custom DataStore allows SwiftData to persist to backends other than SQLite (JSON files, remote APIs, in-memory stores, etc.).
// USE Custom DataStore for:
// - Syncing with a remote API/database
// - JSON file-based storage for simpler debugging
// - In-memory stores for testing
// - Bridging to existing storage systems
// DON'T USE for:
// - Standard app persistence (SQLite is optimized)
// - CloudKit sync (use built-in support)
// - Performance-critical large datasets
┌─────────────────────────────────────────────────────────────┐
│ ModelContainer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DataStoreConfiguration │ │
│ │ - Describes HOW to connect to the store │ │
│ │ - Contains: name, schema, connection details │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DataStore │ │
│ │ - Implements fetch(), save(), erase() │ │
│ │ - Converts between Snapshots and your storage │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DataStoreSnapshot │ │
│ │ - Carries model data across the boundary │ │
│ │ - DefaultSnapshot works for most cases │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
import SwiftData
import Foundation
// MARK: - Configuration
struct JSONFileStoreConfiguration: DataStoreConfiguration {
typealias Store = JSONFileStore
let name: String
let schema: Schema?
let fileURL: URL
init(name: String = "JSONStore", schema: Schema? = nil, fileURL: URL? = nil) {
self.name = name
self.schema = schema
self.fileURL = fileURL ?? FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("\(name).json")
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.name == rhs.name && lhs.fileURL == rhs.fileURL
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(fileURL)
}
}
// MARK: - Store Implementation
final class JSONFileStore: DataStore {
typealias Configuration = JSONFileStoreConfiguration
typealias Snapshot = DefaultSnapshot
let configuration: Configuration
let name: String
let schema: Schema
// In-memory cache of all stored data
// Key: Entity name, Value: [PersistentIdentifier: Snapshot data]
private var entityStorage: [String: [String: [String: Any]]] = [:]
private let queue = DispatchQueue(label: "JSONFileStore.queue")
init(_ configuration: Configuration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
self.configuration = configuration
self.name = configuration.name
self.schema = configuration.schema ?? Schema([])
// Load existing data from disk
try loadFromDisk()
}
// MARK: - DataStore Protocol
func fetch<T: PersistentModel>(
_ request: DataStoreFetchRequest<T>
) throws -> DataStoreFetchResult<T, Snapshot> {
let entityName = String(describing: T.self)
// Get all snapshots for this entity type
let entityData = entityStorage[entityName] ?? [:]
var snapshots: [Snapshot] = []
for (identifierString, values) in entityData {
// Reconstruct PersistentIdentifier from stored string
// Note: In production, you'd need proper identifier serialization
let snapshot = Snapshot(
persistentIdentifier: try reconstructIdentifier(from: identifierString, entityName: entityName),
values: values
)
snapshots.append(snapshot)
}
// Apply predicate filtering (simplified - real impl needs predicate evaluation)
// SwiftData handles most filtering, but you can optimize here
return DataStoreFetchResult(
descriptor: request.descriptor,
fetchedSnapshots: snapshots
)
}
func save(
_ request: DataStoreSaveChangesRequest<Snapshot>
) throws -> DataStoreSaveChangesResult<Snapshot> {
var remappedIdentifiers: [PersistentIdentifier: PersistentIdentifier] = [:]
// Process inserts
for snapshot in request.inserted {
let entityName = snapshot.persistentIdentifier.entityName
let idString = snapshot.persistentIdentifier.id.description
if entityStorage[entityName] == nil {
entityStorage[entityName] = [:]
}
entityStorage[entityName]?[idString] = snapshot.values
}
// Process updates
for snapshot in request.updated {
let entityName = snapshot.persistentIdentifier.entityName
let idString = snapshot.persistentIdentifier.id.description
entityStorage[entityName]?[idString] = snapshot.values
}
// Process deletes
for identifier in request.deleted {
let entityName = identifier.entityName
let idString = identifier.id.description
entityStorage[entityName]?.removeValue(forKey: idString)
}
// Persist to disk
try saveToDisk()
return DataStoreSaveChangesResult(
for: name,
remappedIdentifiers: remappedIdentifiers
)
}
func erase() throws {
entityStorage.removeAll()
try? FileManager.default.removeItem(at: configuration.fileURL)
}
// MARK: - Persistence Helpers
private func loadFromDisk() throws {
guard FileManager.default.fileExists(atPath: configuration.fileURL.path) else {
return
}
let data = try Data(contentsOf: configuration.fileURL)
if let decoded = try JSONSerialization.jsonObject(with: data) as? [String: [String: [String: Any]]] {
entityStorage = decoded
}
}
private func saveToDisk() throws {
let data = try JSONSerialization.data(withJSONObject: entityStorage, options: .prettyPrinted)
try data.write(to: configuration.fileURL, options: .atomic)
}
private func reconstructIdentifier(from string: String, entityName: String) throws -> PersistentIdentifier {
// Note: PersistentIdentifier reconstruction requires internal knowledge
// This is a simplified placeholder - real implementation needs schema access
fatalError("Identifier reconstruction requires SwiftData internals")
}
}
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Trip.self, Activity.self])
// Use custom JSON store
let jsonConfig = JSONFileStoreConfiguration(
name: "MyAppData",
schema: schema
)
do {
container = try ModelContainer(
for: schema,
configurations: jsonConfig
)
} catch {
fatalError("Failed to create container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
struct InMemoryStoreConfiguration: DataStoreConfiguration {
typealias Store = InMemoryStore
let name: String
let schema: Schema?
init(name: String = "InMemoryStore", schema: Schema? = nil) {
self.name = name
self.schema = schema
}
static func == (lhs: Self, rhs: Self) -> Bool { lhs.name == rhs.name }
func hash(into hasher: inout Hasher) { hasher.combine(name) }
}
final class InMemoryStore: DataStore {
typealias Configuration = InMemoryStoreConfiguration
typealias Snapshot = DefaultSnapshot
let configuration: Configuration
let name: String
let schema: Schema
private var storage: [String: [PersistentIdentifier: Snapshot]] = [:]
init(_ configuration: Configuration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
self.configuration = configuration
self.name = configuration.name
self.schema = configuration.schema ?? Schema([])
}
func fetch<T: PersistentModel>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, Snapshot> {
let entityName = String(describing: T.self)
let snapshots = Array(storage[entityName]?.values ?? [])
return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots)
}
func save(_ request: DataStoreSaveChangesRequest<Snapshot>) throws -> DataStoreSaveChangesResult<Snapshot> {
for snapshot in request.inserted {
let entityName = snapshot.persistentIdentifier.entityName
if storage[entityName] == nil { storage[entityName] = [:] }
storage[entityName]?[snapshot.persistentIdentifier] = snapshot
}
for snapshot in request.updated {
let entityName = snapshot.persistentIdentifier.entityName
storage[entityName]?[snapshot.persistentIdentifier] = snapshot
}
for id in request.deleted {
storage[id.entityName]?.removeValue(forKey: id)
}
return DataStoreSaveChangesResult(for: name, remappedIdentifiers: [:])
}
func erase() throws { storage.removeAll() }
}
// Usage in tests
@Suite
struct TripTests {
@Test
func createTrip() throws {
let config = InMemoryStoreConfiguration(schema: Schema([Trip.self]))
let container = try ModelContainer(for: Trip.self, configurations: config)
let context = ModelContext(container)
let trip = Trip(name: "Test", destination: "Paris")
context.insert(trip)
let fetched = try context.fetch(FetchDescriptor<Trip>())
#expect(fetched.count == 1)
}
}
// LIMITATIONS to be aware of:
// 1. PersistentIdentifier is opaque
// - You can't easily serialize/deserialize identifiers
// - Cross-device sync requires your own ID mapping
// 2. Predicate evaluation
// - SwiftData may pass predicates you need to evaluate
// - Complex predicates require manual implementation
// 3. Relationship handling
// - You must maintain referential integrity yourself
// - Cascade deletes need manual implementation
// 4. No CloudKit integration
// - Custom stores can't use built-in CloudKit sync
// - Implement your own sync if needed
// 5. Migration complexity
// - Schema migrations are your responsibility
// - VersionedSchema doesn't auto-migrate custom stores
// 1. USE INDEXES for frequently queried properties
#Index<Event>([\.date])
#Index<Event>([\.category, \.priority])
// 2. LIMIT fetch results
var descriptor = FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.startDate)])
descriptor.fetchLimit = 50
descriptor.fetchOffset = 0 // For pagination
// 3. FETCH COUNT instead of objects when possible
let count = try context.fetchCount(descriptor) // Memory efficient
// 4. FETCH IDENTIFIERS for existence checks
let ids = try context.fetchIdentifiers(descriptor) // Very memory efficient
// 1. PROCESS IN BATCHES with autoreleasepool
func processLargeDataset(_ items: [ImportItem]) throws {
for batch in items.chunks(ofCount: 1000) {
autoreleasepool {
for item in batch {
let model = MyModel(data: item)
context.insert(model)
}
try? context.save()
}
}
}
// 2. USE @Transient for computed properties
@Model
class Document {
var content: String
@Transient
var wordCount: Int {
content.split(separator: " ").count
}
}
// 3. RESET CONTEXT after large operations
func processAllItems() throws {
var offset = 0
let batchSize = 100
while true {
var descriptor = FetchDescriptor<Item>()
descriptor.fetchLimit = batchSize
descriptor.fetchOffset = offset
let batch = try context.fetch(descriptor)
if batch.isEmpty { break }
for item in batch {
processItem(item)
}
try context.save()
context.reset() // Free memory
offset += batchSize
}
}
// 1. USE ModelActor for heavy operations
@ModelActor
actor HeavyProcessor {
func process() async throws {
// Runs on background thread
}
}
// 2. CREATE ACTOR IN DETACHED TASK
Task.detached {
let processor = HeavyProcessor(modelContainer: container)
try await processor.process()
}
// 3. DISABLE AUTOSAVE for batch operations
func batchImport(_ items: [Item]) throws {
modelContext.autosaveEnabled = false
defer { modelContext.autosaveEnabled = true }
for item in items {
context.insert(MyModel(item: item))
}
try context.save() // Single save at end
}
// WRONG: @Query doesn't work outside SwiftUI views
class ViewModel: ObservableObject {
@Query var trips: [Trip] // Always empty!
}
// CORRECT: Use FetchDescriptor
@MainActor
class ViewModel: ObservableObject {
@Published var trips: [Trip] = []
private let context: ModelContext
func loadTrips() throws {
trips = try context.fetch(FetchDescriptor<Trip>())
}
}
// WRONG: Created on main thread = runs on main thread
let processor = BackgroundProcessor(container: container)
// CORRECT: Create in detached task
Task.detached {
let processor = BackgroundProcessor(container: container)
await processor.work()
}
// WRONG: Models are tied to their context
await backgroundActor.process(trip) // CRASH!
// CORRECT: Pass identifier
await backgroundActor.process(tripID: trip.persistentModelID)
// WRONG: @Attribute(.unique) breaks CloudKit
@Attribute(.unique) var id: UUID // Sync fails!
// CORRECT: Regular property for CloudKit
var id: UUID = UUID()
// SLOW: Fetches ALL then filters in memory
let all = try context.fetch(FetchDescriptor<Trip>())
let filtered = all.filter { $0.isFavorite }
// FAST: Database-level filtering
let descriptor = FetchDescriptor<Trip>(
predicate: #Predicate { $0.isFavorite }
)
let filtered = try context.fetch(descriptor)
// SLOW: Loads 10k records on every view appear
@Query var allEvents: [Event]
// BETTER: Paginate or limit
@Query(sort: \.date) var recentEvents: [Event]
init() {
var descriptor = FetchDescriptor<Event>(sortBy: [SortDescriptor(\.date, order: .reverse)])
descriptor.fetchLimit = 50
_recentEvents = Query(descriptor)
}
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data failed: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func save() {
let context = container.viewContext
if context.hasChanges {
try? context.save()
}
}
}
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Task.createdAt, ascending: false)],
predicate: NSPredicate(format: "isCompleted == %@", NSNumber(value: false)),
animation: .default
)
private var tasks: FetchedResults<Task>
| Core Data | SwiftData |
|-----------|-----------|
| NSManagedObject | @Model class |
| NSPersistentContainer | ModelContainer |
| NSManagedObjectContext | ModelContext |
| NSFetchRequest | FetchDescriptor |
| NSPredicate | #Predicate |
| @FetchRequest | @Query |
| .xcdatamodeld | Swift code with @Model |
| Feature | iOS 17 | iOS 18 | iOS 26 | |---------|:------:|:------:|:------:| | @Model, @Attribute | ✅ | ✅ | ✅ | | @Relationship | ✅ | ✅ | ✅ | | @Query | ✅ | ✅ | ✅ | | @Attribute(.unique) | ✅ | ✅ | ✅ | | #Unique (compound) | ❌ | ✅ | ✅ | | #Index | ❌ | ✅ | ✅ | | Custom DataStore | ❌ | ✅ | ✅ | | History Tracking API | ❌ | ✅ | ✅ | | Class Inheritance | ❌ | ❌ | ✅ | | Batch Delete | ✅ | ✅ | ✅ | | CloudKit Sync | ✅ | ✅ | ✅ | | ModelActor | ✅ | ✅ | ✅ | | VersionedSchema | ✅ | ✅ | ✅ |
| Need | Solution | Key Class/API |
|------|----------|---------------|
| Simple preferences | UserDefaults | @AppStorage |
| Secure tokens/passwords | Keychain | SecItemAdd/Copy |
| Large files | File System | FileManager |
| Structured data (iOS 17+) | SwiftData | @Model, @Query |
| Structured data (iOS 16-) | Core Data | NSManagedObject |
| Cloud sync | SwiftData + CloudKit | cloudKitDatabase: |
| Image cache | NSCache + Disk | NSCache |
development
Design principles for building polished, native-feeling SwiftUI apps and widgets. Use this skill when creating or modifying SwiftUI views, iOS widgets (WidgetKit), or any native Apple UI. Ensures proper spacing, typography, colors, and widget implementations that look and feel like quality apps rather than AI-generated slop.
data-ai
Design and implement SwiftUI views, components, and app architecture. Use when creating new SwiftUI views, implementing MVVM/TCA patterns, managing state with @Observable, @State, @Binding, or @Environment, designing navigation flows, or structuring iOS app architecture. Triggers on SwiftUI, view model, state management, navigation, coordinator pattern.
development
Implement, review, or improve SwiftUI animations and transitions. Use when adding implicit or explicit animations with withAnimation, configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
testing
Audit SwiftUI views for accessibility (iOS + macOS) with patch-ready fixes