SwiftData for iOS Engineers (2026): Schemas, Predicates, Migration
In short
SwiftData is Apple's declarative persistence framework, generally available since iOS 17 and the default for new iOS apps in 2026. The framework is a thin macro layer over Core Data — same store, same NSPersistentContainer underneath — so the common SwiftData production failures show up exactly where Core Data's did: migrations, predicate translation, and CloudKit sync. This page shows real @Model relationships, working #Predicate queries, a VersionedSchema + SchemaMigrationPlan walkthrough, and the iOS 17.x regressions you must work around if your deployment target is below iOS 17.4.
Key takeaways
- SwiftData @Model is a macro that synthesises NSManagedObject-equivalent code at compile time. The underlying store is Core Data — view it with the same .sqlite tooling and you'll see familiar Z_PRIMARYKEY tables.
- #Predicate compiles to NSPredicate at runtime. Closures over captured Swift functions don't compile — only literals, KeyPaths, and the operators listed in WWDC23 'Model your schema with SwiftData' (developer.apple.com/videos/play/wwdc2023/10195) at 11:30.
- Migration uses VersionedSchema + SchemaMigrationPlan. Lightweight migrations (added optional property, renamed @Attribute) just work; custom migrations require a MigrationStage with willMigrate/didMigrate closures running on a specific ModelContext.
- CloudKit sync via ModelConfiguration(cloudKitDatabase: .private('iCloud.com.you.app')) had a documented regression in iOS 17.0–17.3 where unique constraints failed silently on initial sync. Fixed in iOS 17.4; the workaround for 17.0+ is to drop @Attribute(.unique) and enforce uniqueness in code.
- FetchDescriptor.fetchLimit and propertiesToFetch are the two performance levers. Without them, @Query on a 50k-row table blocks the main thread on app launch — a known cause of MetricKit hang reports.
A real @Model schema with relationships
This is what an actual production schema looks like — not a Person/Pet toy. A note-taking app with notebooks, notes, and tags:
import SwiftData
import Foundation
@Model
final class Notebook {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
// Cascade — deleting the notebook deletes all notes
@Relationship(deleteRule: .cascade, inverse: \Note.notebook)
var notes: [Note] = []
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
self.createdAt = .now
}
}
@Model
final class Note {
@Attribute(.unique) var id: UUID
var title: String
var body: String
var updatedAt: Date
// Inverse declared on Notebook side
var notebook: Notebook?
// Many-to-many — deleting a tag does NOT delete notes
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
// Stored externally — large blob
@Attribute(.externalStorage) var attachment: Data?
init(id: UUID = UUID(), title: String, body: String) {
self.id = id
self.title = title
self.body = body
self.updatedAt = .now
}
}
@Model
final class Tag {
@Attribute(.unique) var name: String
var notes: [Note] = []
init(name: String) { self.name = name }
}Two specifics that matter: @Attribute(.unique) creates a database-level unique constraint (insert collisions throw); @Attribute(.externalStorage) stores blobs on disk outside the .sqlite file — the same mechanic Core Data calls 'allowsExternalBinaryDataStorage'.
#Predicate: what compiles and what doesn't
The #Predicate macro (SE-0420) compiles a closure into an NSPredicate at compile time. The constraint: only a specific subset of Swift compiles. This is the rule that trips up senior engineers coming from Core Data:
// COMPILES — KeyPaths, literals, comparison operators
let recent = #Predicate<Note> { note in
note.updatedAt > Date.now.addingTimeInterval(-7 * 86400)
}
// COMPILES — string contains, optional binding
let matches = #Predicate<Note> { note in
note.title.localizedStandardContains("swift")
}
// COMPILES — relationship traversal + nil check
let untagged = #Predicate<Note> { note in
note.tags.isEmpty && note.notebook != nil
}
// DOES NOT COMPILE — captured function call
func isStale(_ d: Date) -> Bool { d < .now.addingTimeInterval(-86400) }
let stale = #Predicate<Note> { note in
isStale(note.updatedAt) // ← Cannot capture function
}
// DOES NOT COMPILE — @Transient property reference
// (any property without storage cannot be in a predicate)
// USAGE
let descriptor = FetchDescriptor<Note>(
predicate: recent,
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
)
descriptor.fetchLimit = 50
descriptor.propertiesToFetch = [\.id, \.title, \.updatedAt]
let notes = try modelContext.fetch(descriptor)SE-0420 (github.com/swiftlang/swift-evolution) is the proposal; the fetchLimit + propertiesToFetch tuning is what stops your 50k-row launch from being a hang report. WWDC23 'Model your schema with SwiftData' (developer.apple.com/videos/play/wwdc2023/10195) covers the predicate grammar at 11:30.
Schema migration: VersionedSchema + SchemaMigrationPlan
Production migrations require both a versioned schema and a migration plan. Lightweight changes (adding an optional property, renaming via @Attribute(originalName:)) need only a versioned schema; custom migrations need a stage with willMigrate/didMigrate closures.
// VERSION 1 — initial release
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Notebook.self, Note.self]
}
@Model final class Note {
@Attribute(.unique) var id: UUID
var title: String
var body: String
// ... no updatedAt
init(id: UUID, title: String, body: String) {
self.id = id; self.title = title; self.body = body
}
}
}
// VERSION 2 — added updatedAt, renamed body -> content
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Notebook.self, Note.self]
}
@Model final class Note {
@Attribute(.unique) var id: UUID
var title: String
@Attribute(originalName: "body") var content: String
var updatedAt: Date // new
init(id: UUID, title: String, content: String) {
self.id = id; self.title = title; self.content = content
self.updatedAt = .now
}
}
}
// MIGRATION PLAN
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Backfill updatedAt for existing rows
let notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for note in notes {
note.setValue(forKey: "updatedAt", to: Date.now)
}
try context.save()
},
didMigrate: { _ in /* post-migration validation */ }
)
}
// CONTAINER SETUP
let container = try ModelContainer(
for: SchemaV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(schema: Schema(versionedSchema: SchemaV2.self))
)WWDC23 'Migrate your app to SwiftData' at developer.apple.com/videos/play/wwdc2023/10189 walks through both lightweight and custom migrations starting at 9:00. The pattern that bit production teams in iOS 17.0–17.2: lightweight migrations that included a unique constraint change silently dropped rows. Always test migrations against a real production database snapshot, not just synthetic fixtures.
CloudKit sync container setup
CloudKit sync via SwiftData looks like one configuration line, but it has constraints the documentation hides:
// In your App struct
@main
struct NotesApp: App {
let container: ModelContainer
init() {
do {
let config = ModelConfiguration(
"CloudNotes",
schema: Schema([Notebook.self, Note.self, Tag.self]),
cloudKitDatabase: .private("iCloud.com.example.Notes")
)
container = try ModelContainer(
for: Notebook.self, Note.self, Tag.self,
configurations: config
)
} catch {
fatalError("ModelContainer init: \(error)")
}
}
var body: some Scene {
WindowGroup { ContentView() }
.modelContainer(container)
}
}The constraints CloudKit imposes on your schema:
- All properties must be optional or have a default value. CloudKit cannot synthesise a value for an existing record after a schema migration, so non-optional non-default properties will fail to load on a peer device.
- No @Attribute(.unique) under iOS 17.0–17.3. The unique constraint silently failed during initial sync. Fixed in iOS 17.4 (release notes: developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-17_4-release-notes). If your deployment target is iOS 17.0+, enforce uniqueness in app code.
- @Relationship inverse must be declared — both sides — for CloudKit to translate the relationship to a CKReference correctly.
- Schema must match the CloudKit container schema. First run of the app pushes the schema; subsequent migrations require a CloudKit Dashboard 'Deploy to Production' click.
Performance: where SwiftData costs you and how to fix it
Three performance failure modes show up in production SwiftData apps:
- @Query without fetchLimit blocks the main thread on launch. The reactive @Query property wrapper materializes the full result set on view appearance. On a table with 50,000 rows, this is a 200–600ms hang on iPhone 13 — long enough to surface in MetricKit hang reports. The fix: pass an explicit FetchDescriptor with fetchLimit and propertiesToFetch.
- Predicate translation surprises. #Predicate compiles to NSPredicate; some Swift expressions translate cleanly, others fall back to in-memory evaluation. The compiler does not warn — you find out from a Time Profiler trace. Mitigation: profile predicates that touch relationships or string operations; if they're slow, restructure to use indexed properties.
- CloudKit sync amplification on background launch. Background launches with CloudKit-enabled SwiftData can trigger a sync sweep that runs concurrent with limited background-execution time. Apps that crashed-on-launch in iOS 17.0–17.3 typically had this pattern. Fix: gate CloudKit sync behind a foreground-only flag or use the iOS 17.4+ behavior where the framework itself defers heavy sync to foreground.
// FAST — bounded, only the fields needed
let desc = FetchDescriptor<Note>(
predicate: #Predicate { $0.notebook?.id == notebookID },
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
)
desc.fetchLimit = 50
desc.propertiesToFetch = [\.id, \.title, \.updatedAt]
// SLOW — unbounded, full row materialisation
@Query(sort: \.updatedAt, order: .reverse)
var notes: [Note]
// MEDIUM — bounded but loads all properties
@Query(
filter: #Predicate<Note> { $0.notebook?.id == someID },
sort: \.updatedAt,
order: .reverse
)
var notes: [Note]WWDC24 'What's new in SwiftData' at developer.apple.com/videos/play/wwdc2024/10137 covers performance tuning starting at 14:00. The Instruments SwiftData lane (Xcode 16+) is the canonical profiling surface.
When to keep Core Data: a decision matrix
SwiftData is the default for new code. Three cases keep you on Core Data in 2026:
| Case | Why Core Data wins |
|---|---|
| Existing app with Core Data + CloudKit live | Migration to SwiftData is non-trivial; the underlying store is the same so there's no read perf gain. SwiftData can interop via NSManagedObject inheritance (WWDC23 'Use Core Data with SwiftData', developer.apple.com/videos/play/wwdc2024/10138) — this is the recommended path. |
| Custom NSFetchedResultsController-style logic | SwiftData's @Query is reactive but you don't get incremental delegate callbacks for inserted/deleted/moved rows. If you depend on those for animation, stay on Core Data + NSFetchedResultsController. |
| Heavy custom migration history | Core Data's NSMappingModel + custom NSEntityMigrationPolicy is more powerful than VersionedSchema for one-off complex transforms. Migrate only forward, then port to SwiftData. |
Frequently asked questions
- Can I use SwiftData with UIKit?
- Yes. ModelContainer + ModelContext have no SwiftUI dependency. Inject the container into a UIKit AppDelegate, use modelContext.fetch / .insert / .delete from any view controller. The reactive @Query property wrapper is SwiftUI-only — for UIKit you'll observe changes via NotificationCenter.default.addObserver(for: .NSPersistentStoreRemoteChange).
- Why is my SwiftData fetch slow on app launch?
- Two causes: (1) @Query without a fetchLimit pulls every row before the view appears — set the limit explicitly via FetchDescriptor and pass it to @Query(filter:sort:transaction:); (2) propertiesToFetch unset means SwiftData materializes the full row including any externalStorage attachments — restrict to the columns you render.
- Is SwiftData thread-safe?
- Each ModelContext is bound to one actor. The main-thread context runs on the main actor; for background work, create a context with ModelContext(container) inside a Task — it inherits the calling actor. Crossing contexts requires PersistentIdentifier (Sendable) hand-off, not the model objects themselves. WWDC24 'What's new in SwiftData' (developer.apple.com/videos/play/wwdc2024/10137) at 8:00 covers the actor-isolation rules.
- How do I write tests against SwiftData models?
- Initialise an in-memory ModelContainer in setUp: ModelContainer(for: Note.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)). Write SwiftData tests as integration tests against this container — pure unit tests against @Model classes are limited because the macro generates initialisers and storage you don't see.
- What does SwiftData do that Core Data doesn't?
- Three things: (1) #Predicate type-safe queries replace stringly-typed NSPredicate; (2) macro-generated boilerplate replaces @NSManaged + .xcdatamodeld; (3) CloudKit container configuration in one line. Behind the scenes the engine is the same — Core Data's NSPersistentContainer + NSPersistentCloudKitContainer.
- How do I do full-text search?
- Two paths: (1) localizedStandardContains in a #Predicate works for substring search up to a few thousand rows; (2) for larger corpora, integrate FTS5 via SQLite directly on the underlying store path — SwiftData stores its database at FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent('default.store'). FTS5 indexing has to be set up out-of-band and refreshed on writes.
- Should I use @Transient?
- Yes for derived state that you do not want persisted. Mark with @Transient(...) — the property is excluded from the schema and from migrations. Common use: caching a computed value, holding a reference to an external resource. @Transient properties cannot appear in #Predicate.
Sources
- WWDC23 — Meet SwiftData. Authoritative introduction.
- WWDC23 — Model your schema with SwiftData. #Predicate grammar at 11:30.
- WWDC23 — Migrate your app to SwiftData. VersionedSchema walkthrough.
- WWDC24 — What's new in SwiftData. Custom data stores + actor isolation.
- Apple Developer — SwiftData framework reference.
- Apple Developer — ModelContainer reference (CloudKit configuration).
- Apple Developer — iOS 17.4 release notes (SwiftData CloudKit fixes).
- Hacking with Swift — SwiftData quick-start (Paul Hudson).
About the author. Blake Crosley founded ResumeGeni and writes about product design, hiring technology, and ATS optimization. More writing at blakecrosley.com.