iOS Engineer Hub

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:

  1. @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.
  2. 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.
  3. 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:

CaseWhy Core Data wins
Existing app with Core Data + CloudKit liveMigration 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 logicSwiftData'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 historyCore 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

  1. WWDC23 — Meet SwiftData. Authoritative introduction.
  2. WWDC23 — Model your schema with SwiftData. #Predicate grammar at 11:30.
  3. WWDC23 — Migrate your app to SwiftData. VersionedSchema walkthrough.
  4. WWDC24 — What's new in SwiftData. Custom data stores + actor isolation.
  5. Apple Developer — SwiftData framework reference.
  6. Apple Developer — ModelContainer reference (CloudKit configuration).
  7. Apple Developer — iOS 17.4 release notes (SwiftData CloudKit fixes).
  8. 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.