SwiftData Expert
When to Use
- Persisting app data locally with Swift-native APIs (replaces Core Data for new projects)
- Setting up model relationships (manga library: Series → Chapters → ReadProgress)
- Migrating schemas when model shapes change between app versions
- Syncing data to iCloud via CloudKit with zero extra code
- Deciding between SwiftData and Core Data for a given use case
Core Rules
- Mark every persistent model with
@Model— the macro generates Core Data boilerplate automatically. - Never access
ModelContextfrom a background thread withoutmodelContext.perform { }or a separate actor — it's not thread-safe. @Queryre-executes automatically when the store changes; don't manually observe or reload.- Relationships default to cascade delete — set
@Relationship(deleteRule: .nullify)when that's wrong. - For CloudKit sync, every
@Modelproperty must be optional or have a default — CloudKit requires it. - Use lightweight migrations (adding optional properties) when possible; only write
SchemaMigrationPlanfor destructive changes. modelContext.autosaveEnabledistrueby default — you rarely need to callsave()manually.- Fetch with predicates at the
@Querylevel, not in a.filteron the results — the former queries SQL, the latter loads everything. - For large datasets (1000+ objects), use
FetchDescriptorwithfetchLimitandfetchOffsetfor pagination. - Background processing (e.g., image processing): create a separate
ModelContext(container)on a background actor.
Model Definition
import SwiftData
import Foundation
@Model
final class MangaSeries {
var title: String
var author: String
var coverImageData: Data?
var lastReadDate: Date?
var isFavorite: Bool = false
// One-to-many: series has many chapters
@Relationship(deleteRule: .cascade, inverse: \Chapter.series)
var chapters: [Chapter] = []
// Many-to-many: series can have many genres
@Relationship var genres: [Genre] = []
init(title: String, author: String) {
self.title = title
self.author = author
}
}
@Model
final class Chapter {
var number: Int
var title: String?
var isDownloaded: Bool = false
var pageCount: Int = 0
var addedDate: Date = Date()
// Back-reference — SwiftData infers the inverse
var series: MangaSeries?
// One-to-one
@Relationship(deleteRule: .cascade, inverse: \ReadProgress.chapter)
var readProgress: ReadProgress?
init(number: Int, series: MangaSeries) {
self.number = number
self.series = series
}
}
@Model
final class ReadProgress {
var currentPage: Int = 0
var totalPages: Int = 0
var lastReadDate: Date = Date()
var chapter: Chapter?
var progressFraction: Double {
guard totalPages > 0 else { return 0 }
return Double(currentPage) / Double(totalPages)
}
}
ModelContainer Setup (App Entry Point)
import SwiftUI
import SwiftData
@main
struct MangaReaderApp: App {
let container: ModelContainer
init() {
let schema = Schema([MangaSeries.self, Chapter.self, ReadProgress.self, Genre.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic // enables CloudKit sync — remove for no sync
)
do {
container = try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
@Query in SwiftUI Views
struct LibraryView: View {
// Basic query — auto-updates when data changes
@Query(sort: \MangaSeries.title) var allSeries: [MangaSeries]
// Filtered + sorted query
@Query(
filter: #Predicate<MangaSeries> { $0.isFavorite == true },
sort: \MangaSeries.lastReadDate,
order: .reverse
) var favorites: [MangaSeries]
var body: some View {
List(allSeries) { series in
SeriesRowView(series: series)
}
}
}
// Dynamic predicate — pass via init (common pattern)
struct FilteredSeriesView: View {
@Query var series: [MangaSeries]
init(searchText: String) {
let predicate = #Predicate<MangaSeries> {
searchText.isEmpty ? true : $0.title.contains(searchText)
}
_series = Query(filter: predicate, sort: \MangaSeries.title)
}
var body: some View {
List(series) { SeriesRowView(series: $0) }
}
}
ModelContext CRUD
struct LibraryViewModel {
@Environment(\.modelContext) private var context
// Create
func addSeries(title: String, author: String) {
let series = MangaSeries(title: title, author: author)
context.insert(series)
// autosave handles persistence — no explicit save() needed
}
// Read (outside SwiftUI — use FetchDescriptor)
func fetchUnread() throws -> [Chapter] {
let descriptor = FetchDescriptor<Chapter>(
predicate: #Predicate { $0.readProgress == nil },
sortBy: [SortDescriptor(\Chapter.addedDate, order: .reverse)]
)
return try context.fetch(descriptor)
}
// Paginated fetch
func fetchPage(_ page: Int, pageSize: Int = 20) throws -> [MangaSeries] {
var descriptor = FetchDescriptor<MangaSeries>(
sortBy: [SortDescriptor(\MangaSeries.title)]
)
descriptor.fetchLimit = pageSize
descriptor.fetchOffset = page * pageSize
return try context.fetch(descriptor)
}
// Update — just mutate; SwiftData tracks changes
func markFavorite(_ series: MangaSeries) {
series.isFavorite.toggle()
}
// Delete
func deleteSeries(_ series: MangaSeries) {
context.delete(series) // cascade deletes chapters + progress
}
}
Schema Migration
// V1 schema
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [MangaSeries.self] }
@Model final class MangaSeries {
var title: String = ""
init(title: String) { self.title = title }
}
}
// V2 schema — added author property
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [MangaSeries.self] }
@Model final class MangaSeries {
var title: String = ""
var author: String = "Unknown" // new field with default = lightweight migration
init(title: String, author: String = "Unknown") {
self.title = title
self.author = author
}
}
}
// Migration plan
enum MangaMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[migrateV1toV2]
}
// Lightweight: just adding an optional/defaulted field
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
// Custom migration example (data transformation needed):
// static let migrateV1toV2 = MigrationStage.custom(
// fromVersion: SchemaV1.self,
// toVersion: SchemaV2.self,
// willMigrate: nil,
// didMigrate: { context in
// let series = try context.fetch(FetchDescriptor<SchemaV2.MangaSeries>())
// series.forEach { $0.author = "Unknown" }
// }
// )
}
// Use in ModelContainer:
// ModelContainer(for: schema, migrationPlan: MangaMigrationPlan.self, configurations: config)
Background Context Operations
actor LibraryActor: ModelActor {
let modelContainer: ModelContainer
let modelExecutor: any ModelExecutor
init(container: ModelContainer) {
self.modelContainer = container
let context = ModelContext(container)
self.modelExecutor = DefaultSerialModelExecutor(modelContext: context)
}
func importChapters(for seriesID: PersistentIdentifier, chapters: [ChapterDTO]) throws {
guard let series = modelContext.model(for: seriesID) as? MangaSeries else { return }
for dto in chapters {
let chapter = Chapter(number: dto.number, series: series)
chapter.title = dto.title
modelContext.insert(chapter)
}
try modelContext.save()
}
}
// Usage:
// let actor = LibraryActor(container: container)
// await actor.importChapters(for: series.persistentModelID, chapters: fetched)
SwiftData vs Core Data Decision Table
| Factor | SwiftData | Core Data |
|---|---|---|
| iOS target | iOS 17+ only | iOS 13+ |
| Swift concurrency | Native actors | Manual performBackgroundTask |
| CloudKit sync | .cloudKitDatabase(.automatic) |
NSPersistentCloudKitContainer |
| Complex queries | Limited #Predicate |
Full NSPredicate + NSExpression |
| Batch operations | Not supported | NSBatchDeleteRequest |
| Migration control | SchemaMigrationPlan |
.xcmappingmodel |
| Maturity | iOS 17+ (newer, some bugs) | Decade+ stable |
Verdict for new iOS 17+ projects: use SwiftData. Drop to Core Data only for iOS 16 support, batch deletes, or complex NSExpression queries.
Common Patterns
In-Memory Container for Previews/Tests
extension ModelContainer {
static var preview: ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(
for: MangaSeries.self, Chapter.self,
configurations: config
)
// Seed preview data
let ctx = container.mainContext
let series = MangaSeries(title: "One Piece", author: "Oda")
ctx.insert(series)
return container
}
}
#Preview {
LibraryView()
.modelContainer(.preview)
}
Count Without Fetching All Objects
let count = try context.fetchCount(FetchDescriptor<Chapter>(
predicate: #Predicate { $0.isDownloaded == true }
))
Related Skills
ios-swiftui-expert— SwiftUI + SwiftDatadatabase-migration-strategies— data migrationsswiftui-data-flow— data flow patterns
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal. Index path: /Users/localuser/.claude/skills/swiftdata-expert/.gitnexus Last indexed: 2026-05-23