| name | swiftdata |
| description | SwiftData best practices and architecture expert - advises on @Model schema design, ModelContext/ModelContainer lifecycle, @Query fetching, relationships, migrations, SwiftUI integration, performance, testing, and CloudKit sync. Use when the user asks about SwiftData, @Model, @Query, ModelContext, ModelContainer, SwiftData relationships, SwiftData migrations, SwiftData CloudKit, SwiftData performance, or testing with SwiftData. |
SwiftData Architecture & Best Practices Expert
Expert guidance on building correct, performant, and maintainable SwiftData-backed applications. Covers schema design, context lifecycle, reactive querying, relationships, migrations, SwiftUI integration, and testing.
Core Mental Model: Three-Layer Stack
SwiftData's architecture maps cleanly to three layers:
ModelContainer — persistent store configuration (one per app)
↓
ModelContext — unit of work / scratch pad (main context is @MainActor-bound)
↓
@Model instances — in-memory representations of persisted objects
ModelContainer owns the schema and the underlying store file. Created once at app startup.
ModelContext tracks in-memory objects and coordinates inserts, deletes, and saves. The main context runs on @MainActor; create background contexts for heavy writes.
@Model instances are live objects. Any property change on the main context is automatically tracked and can trigger @Query view updates.
@Query is a property wrapper that keeps a SwiftUI view in sync with the store. It runs a live fetch and re-renders the view whenever matching objects change.
ModelContainer → provides context → @Model instances mutated → @Query re-fetches → View updates
@Model & Schema Design
Defining a Model
import SwiftData
@Model
final class Book {
var title: String
var author: String
var publishedYear: Int
var rating: Double?
@Attribute(.unique) var isbn: String
@Attribute(originalName: "desc") var summary: String
@Attribute(.externalStorage) var coverImage: Data?
@Relationship(deleteRule: .cascade) var chapters: [Chapter] = []
@Transient var displayTitle: String { title.isEmpty ? "Untitled" : title }
init(title: String, author: String, isbn: String, publishedYear: Int) {
.title title
.author author
.isbn isbn
.publishedYear publishedYear
.summary
}
}
@Attribute Options
| Option | Purpose |
|---|
.unique | Enforce uniqueness; upsert on duplicate insert |
.externalStorage | Store large Data blobs outside the SQLite row (e.g., images) |
originalName: "old" | Rename a property without a migration stage |
.spotlight | Index for Spotlight search |
.allowsCloudEncryption | Encrypt field in CloudKit (iCloud Keychain-backed) |
Transient vs Computed Properties
@Model final class Product {
var priceInCents: Int
@Transient var formattedPrice: String {
"$\(Double(priceInCents) / 100.0)"
}
}
ModelContainer & ModelContext
App Entry Point Setup
@main
struct BookshelfApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Book.self, Chapter.self])
}
}
Custom Container Configuration
let config = ModelConfiguration(
"Bookshelf",
schema: Schema([Book.self, Chapter.self]),
url: customStoreURL,
isStoredInMemoryOnly: false,
allowsSave: true
)
let container = try ModelContainer(
for: Schema([Book.self, Chapter.self]),
configurations: config
)
Background Context for Heavy Writes
Task.detached(priority: .background) {
let context = ModelContext(container)
context.autosaveEnabled = false
for item in largeDataset {
context.insert(MyModel(data: item))
}
try context.save()
}
autosaveEnabled
The main context autosaves on each run-loop tick by default. Disable for explicit control (e.g., form editing with a "Cancel" button):
@Environment(\.modelContext) private var context
func discardChanges() {
context.rollback()
}
func saveChanges() throws {
try context.save()
}
@Query & Fetching
Basic @Query
struct BookListView: View {
@Query(sort: \Book.title) private var books: [Book]
var body: some View {
List(books) { book in Text(book.title) }
}
}
Filtering with #Predicate
@Query(filter: #Predicate<Book> { $0.rating ?? 0 >= 4.0 },
sort: \Book.title)
private var topRatedBooks: [Book]
struct BookListView: View {
init(authorFilter: String) {
_books = Query(
filter: #Predicate<Book> { $0.author == authorFilter },
sort: \Book.publishedYear, order: .reverse
)
}
@Query private var books: [Book]
}
#Predicate limitations: Only a subset of Swift expressions is supported — no custom functions, no regex. Supported string ops: .contains, .hasPrefix, .hasSuffix, .localizedStandardContains. Use FetchDescriptor for predicates that #Predicate can't express.
Imperative Fetching with FetchDescriptor
func fetchTopRated(in context: ModelContext) throws -> [Book] {
var descriptor = FetchDescriptor<Book>(
predicate: #Predicate { $0.rating ?? 0 >= 4.0 },
sortBy: [SortDescriptor(\Book.title)]
)
descriptor.fetchLimit = 20
descriptor.includePendingChanges = true
return try context.fetch(descriptor)
}
@Query Animation
@Query(sort: \Book.title, animation: .default)
private var books: [Book]
Relationships
One-to-Many
@Model final class Author {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Book.author)
var books: [Book] = []
}
@Model final class Book {
var title: String
var author: Author?
}
Delete Rules
| Rule | Behavior |
|---|
.nullify (default) | Set the inverse to nil on delete |
.cascade | Delete all related objects when parent is deleted |
.deny | Prevent deletion if related objects exist |
.noAction | Do nothing — manage manually |
Inverse Relationship Requirement
SwiftData requires every relationship to have an inverse. Omitting it causes silent data corruption:
@Model final class Shelf {
@Relationship(deleteRule: .cascade) var books: [Book] = []
}
@Model final class Shelf {
@Relationship(deleteRule: .cascade, inverse: \Book.shelf)
var books: [Book] = []
}
@Model final class Book {
var shelf: Shelf?
}
Many-to-Many
@Model final class Book {
@Relationship(inverse: \Tag.books) var tags: [Tag] = []
}
@Model final class Tag {
var name: String
@Relationship(inverse: \Book.tags) var books: [Book] = []
}
SwiftUI Integration
modelContainer Modifier
.modelContainer(for: Book.self)
.modelContainer(for: [Book.self, Author.self, Tag.self])
.modelContainer(myContainer)
Inserting & Deleting from Views
struct BookListView: View {
@Environment(\.modelContext) private var context
@Query private var books: [Book]
var body: some View {
List {
ForEach(books) { book in Text(book.title) }
.onDelete(perform: deleteBooks)
}
.toolbar {
Button("Add") { addBook() }
}
}
private func addBook() {
let book = Book(title: "New Book", author: "Unknown",
isbn: UUID().uuidString, publishedYear: 2024)
context.insert(book)
}
private func deleteBooks(at offsets: IndexSet) {
offsets.map { books[$0] }.forEach { context.delete($0) }
}
}
Editing Model Properties
SwiftUI bindings work directly on @Model properties via @Bindable:
struct BookEditView: View {
@Bindable var book: Book
var body: some View {
Form {
TextField("Title", text: $book.title)
TextField("Author", text: $book.author)
}
}
}
Performance & Optimization
Use fetchLimit to Avoid Loading Everything
@Query(sort: \Book.title) private var books: [Book]
var descriptor = FetchDescriptor<Book>(sortBy: [SortDescriptor(\Book.title)])
descriptor.fetchLimit = 50
let page = try context.fetch(descriptor)
Prefetch Relationships to Avoid N+1
for book in books { print(book.author?.name ?? "") }
var descriptor = FetchDescriptor<Book>()
descriptor.relationshipKeyPathsForPrefetching = [\Book.author]
let books = try context.fetch(descriptor)
Push Filters into #Predicate
@Query private var allBooks: [Book]
var filtered: [Book] { allBooks.filter { $0.publishedYear >= 2020 } }
@Query(filter: #Predicate<Book> { $0.publishedYear >= 2020 })
private var recentBooks: [Book]
Background Saves for Bulk Operations
func importBooks(_ data: [BookData], into container: ModelContainer) async throws {
try await Task.detached(priority: .background) {
let context = ModelContext(container)
context.autosaveEnabled = false
for item in data {
context.insert(Book(title: item.title, author: item.author,
isbn: item.isbn, publishedYear: item.year))
}
try context.save()
}.value
}
Testing Strategies
In-Memory Container
func makeTestContainer() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
return try ModelContainer(for: Book.self, configurations: config)
}
final class BookTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUpWithError() throws {
container = try makeTestContainer()
context = ModelContext(container)
}
func test_insertBook_persistsToStore() throws {
let book = Book(title: "Test", author: "Author", isbn: "1234", publishedYear: 2024)
context.insert(book)
try context.save()
let fetched = try context.fetch(FetchDescriptor<Book>())
XCTAssertEqual(fetched.count, 1)
XCTAssertEqual(fetched[0].title, "Test")
}
() {
book (title: , author: , isbn: , publishedYear: )
context.insert(book)
context.save()
context.delete(book)
context.save()
fetched context.fetch(<>())
(fetched.isEmpty)
}
}
Previews with Pre-Populated Container
#Preview {
let container = try! ModelContainer(
for: Book.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let ctx = container.mainContext
ctx.insert(Book(title: "Swift in Depth", author: "Tjeerd in 't Veen",
isbn: "9781617294600", publishedYear: 2019))
return BookListView()
.modelContainer(container)
}
Testing Business Logic Without SwiftData
Extract logic into plain Swift types for fast, dependency-free unit tests:
struct BookRatingCalculator {
static func average(for ratings: [Double?]) -> Double {
let rated = ratings.compactMap { $0 }
return rated.isEmpty ? 0 : rated.reduce(0, +) / Double(rated.count)
}
}
func test_averageRating_ignoresNils() {
let ratings: [Double?] = [4.0, 5.0, nil]
XCTAssertEqual(BookRatingCalculator.average(for: ratings), 4.5)
}
Common Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|
Using main ModelContext off @MainActor | Data race — main context is not thread-safe | Create a new ModelContext(container) on the background task |
| Omitting inverse relationships | Silent data corruption, broken cascades | Always declare inverse: on both sides of a relationship |
Filtering @Query results in Swift | Over-fetches all rows; re-renders on any change | Push the filter into #Predicate inside @Query |
| Saving in the wrong context | Changes don't persist (different in-memory graph) | Always save the same context you inserted/deleted from |
| Schema changes without a migration plan | Store incompatibility crash on upgrade | Add a SchemaMigrationPlan stage before shipping breaking changes |
.unique attribute with CloudKit | CloudKit doesn't support unique constraints | Remove .unique and enforce uniqueness in app logic |
Large Data blobs stored inline | Bloats SQLite row, slows all fetches | Use @Attribute(.externalStorage) for images/files |
Fetching inside a @Model init | Initializer runs during fetch — infinite recursion risk | Never call context.fetch inside a @Model initializer |
Non-Codable custom types as properties | Won't serialize to the store | Use primitives or conform custom types to Codable |
Decision Guide
@Query vs FetchDescriptor:
- Driving a SwiftUI view with live updates →
@Query
- One-off fetch in a store method, background task, or unit test →
FetchDescriptor
- Complex predicate
#Predicate can't express → FetchDescriptor
Main context vs background context:
- Reading data for display → main context via
@Query or @Environment(\.modelContext)
- Bulk inserts, imports → background
ModelContext(container) with autosaveEnabled = false
- Editing a single object in a form → main context with
context.rollback() on cancel
Which delete rule:
- Parent owns children (e.g., post → comments) →
.cascade
- Children can exist without parent →
.nullify
- Prevent accidental deletion of parent when children exist →
.deny
Migration approach:
- Adding an optional property, or renaming with
originalName → no migration stage needed
- Changing non-optional to optional, splitting/merging properties, changing types →
SchemaMigrationPlan with a custom stage
- See Migrations deep dive for full details
CloudKit sync:
- Simple sync, no unique constraints → SwiftData + CloudKit works well
- Custom migrations, unique constraints, or write-heavy offline-first → reconsider or avoid CloudKit
- See CloudKit deep dive for full details
Reference Docs
- Migrations & Versioning — VersionedSchema, SchemaMigrationPlan, lightweight vs custom stages, pitfalls
- CloudKit Sync — configuration, schema constraints, conflict resolution, limitations, testing