SwiftData arrived with iOS 17 as Apple's modern replacement for Core Data — a declarative, Swift-native persistence framework that promised to eliminate boilerplate and play nicely with SwiftUI. Two years and three major releases later, it's matured into something genuinely production-ready. iOS 26 plugged the last major gap with model inheritance support, and ongoing bug fixes have addressed the rough edges that made early adoption feel like a gamble.
But here's the thing: most SwiftData tutorials stop at the basics. They show you how to define a model, save it, and fetch it with @Query. That's fine for a todo app. It's not fine for a real application with complex relationships, background data processing, schema migrations, and performance requirements that go beyond "it works on my simulator."
This guide takes you from zero — your first model — all the way to production patterns that'll actually serve you in shipping apps. We'll cover model design, relationships, queries with complex predicates, background processing with @ModelActor, schema migration, performance optimization, and the new iOS 26 features. So, let's dive in.
Setting Up Your First Model Container
Everything in SwiftData starts with two objects: a ModelContainer and a ModelContext. The container manages the underlying storage — think of it as the database itself. The context is your working scratchpad where you create, modify, and delete objects before persisting them.
For a SwiftUI app, the simplest setup looks like this:
import SwiftUI
import SwiftData
@main
struct RecipeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Recipe.self, Ingredient.self, Tag.self])
}
}
That single line creates a ModelContainer backed by a SQLite database in the app's default storage directory, registers your model types, and injects a ModelContext into the SwiftUI environment. Every view in your hierarchy can now access that context via @Environment(\.modelContext). Pretty slick, honestly.
For more control, you can create the container manually with a ModelConfiguration:
@main
struct RecipeApp: App {
let container: ModelContainer
init() {
let config = ModelConfiguration(
"RecipeDatabase",
schema: Schema([Recipe.self, Ingredient.self, Tag.self]),
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: .identifier("group.com.myapp.recipes"),
cloudKitDatabase: .private("iCloud.com.myapp.recipes")
)
do {
container = try ModelContainer(for: Recipe.self, Ingredient.self, Tag.self,
configurations: config)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
This gives you control over the database name, whether it's in-memory (great for previews and tests), App Group sharing for widgets, and CloudKit sync. You'll almost always want the explicit configuration in production apps.
Defining Models the Right Way
A SwiftData model is just a Swift class annotated with the @Model macro. But the details matter more than most tutorials let on.
import SwiftData
@Model
final class Recipe {
var title: String
var summary: String
var instructions: String
var servings: Int
var prepTimeMinutes: Int
var cookTimeMinutes: Int
var isFavorite: Bool
var createdAt: Date
var updatedAt: Date
@Attribute(.externalStorage) var imageData: Data?
init(title: String, summary: String, instructions: String,
servings: Int = 4, prepTimeMinutes: Int = 0,
cookTimeMinutes: Int = 0) {
self.title = title
self.summary = summary
self.instructions = instructions
self.servings = servings
self.prepTimeMinutes = prepTimeMinutes
self.cookTimeMinutes = cookTimeMinutes
self.isFavorite = false
self.createdAt = Date()
self.updatedAt = Date()
}
}
A few things to note here. First, @Model classes must be class types, not structs — SwiftData needs reference semantics for change tracking. Second, every stored property becomes a persistent attribute automatically. You don't need to annotate each one individually, which is a huge improvement over Core Data's explicit attribute definitions.
The @Attribute Macro
The @Attribute macro gives you fine-grained control over how properties are stored:
@Model
final class Recipe {
// Store large data externally (loaded on demand)
@Attribute(.externalStorage) var imageData: Data?
// Preserve the original property name in the database
// even if you rename it in code
@Attribute(originalName: "desc") var summary: String
// Make a property unique — duplicates trigger an upsert
@Attribute(.unique) var slug: String
// Mark as transient — not persisted
@Transient var isEditing: Bool = false
// Computed properties are automatically transient
var totalTimeMinutes: Int {
prepTimeMinutes + cookTimeMinutes
}
}
The .externalStorage attribute is particularly important for production apps. Without it, binary data like images gets stored directly in the SQLite row, which bloats your database and slows down every query that touches that table — even if you never access the image data. With external storage, the data lives in a separate file and is loaded only when you actually read the property. I've seen this one change cut fetch times in half on a real project with image-heavy records.
Unique Constraints and Indexing (iOS 18+)
Starting with iOS 18, SwiftData supports compound unique constraints and indexes via the #Unique and #Index macros. These are applied at the model level, not on individual properties:
@Model
final class Recipe {
#Unique([\.title, \.authorName])
#Index([\.title], [\.createdAt], [\.isFavorite, \.createdAt])
var title: String
var authorName: String
var createdAt: Date
var isFavorite: Bool
// ... other properties
}
The #Unique macro ensures no two recipes can have the same title and author combination. When you try to insert a duplicate, SwiftData performs an upsert — updating the existing record instead of creating a new one.
The #Index macro creates database indexes that dramatically speed up queries filtering or sorting on those properties. Each array in the macro represents a separate index. The compound index [\.isFavorite, \.createdAt] optimizes queries like "show me all favorites sorted by date" — exactly the kind of query a recipe app runs constantly.
Without this index, the database performs a full table scan every time. Trust me, you'll notice.
Relationships: One-to-Many and Many-to-Many
Relationships are where SwiftData really starts to shine compared to Core Data. You define them as regular Swift properties with type annotations — no separate relationship configuration needed.
One-to-Many Relationships
@Model
final class Recipe {
var title: String
@Relationship(deleteRule: .cascade, inverse: \Ingredient.recipe)
var ingredients: [Ingredient] = []
init(title: String) {
self.title = title
}
}
@Model
final class Ingredient {
var name: String
var quantity: String
var recipe: Recipe?
init(name: String, quantity: String, recipe: Recipe? = nil) {
self.name = name
self.quantity = quantity
self.recipe = recipe
}
}
The @Relationship macro with deleteRule: .cascade means deleting a recipe automatically deletes all its ingredients. This is the behavior you almost always want for parent-child relationships. The alternatives are:
.nullify(default) — Sets the child's reference to the parent tonil.deny— Prevents deletion if children still exist.cascade— Deletes all children along with the parent
The inverse parameter explicitly connects the two sides of the relationship. While SwiftData can often infer inverse relationships automatically, being explicit prevents ambiguity when a model has multiple relationships to the same type. Just be explicit here — future you will thank present you.
Many-to-Many Relationships
@Model
final class Recipe {
var title: String
@Relationship var tags: [Tag] = []
init(title: String) {
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var name: String
@Relationship(inverse: \Recipe.tags) var recipes: [Recipe] = []
init(name: String) {
self.name = name
}
}
When both sides of a relationship are arrays, SwiftData creates a many-to-many relationship automatically. Under the hood, it manages a join table for you — no intermediate model needed. Adding a tag to a recipe is as simple as appending to the array:
let recipe = Recipe(title: "Pasta Carbonara")
let tag = Tag(name: "Italian")
recipe.tags.append(tag)
// tag.recipes now contains the recipe automatically
That bidirectional sync is one of those things that just feels right after years of manually managing join tables.
Querying Data with @Query and FetchDescriptor
SwiftData offers two ways to fetch data: the @Query property wrapper for SwiftUI views, and FetchDescriptor for programmatic fetches anywhere else.
@Query in SwiftUI Views
@Query is honestly SwiftData's killer feature for SwiftUI. It fetches data, observes changes, and automatically triggers view updates — all from a single property wrapper:
struct RecipeListView: View {
@Query(
filter: #Predicate { $0.isFavorite },
sort: [SortDescriptor(\.createdAt, order: .reverse)],
animation: .default
)
private var favoriteRecipes: [Recipe]
var body: some View {
List(favoriteRecipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
You can also make queries dynamic by initializing them in the view's init:
struct RecipeListView: View {
@Query private var recipes: [Recipe]
init(searchText: String, showFavoritesOnly: Bool) {
let predicate = #Predicate { recipe in
(searchText.isEmpty || recipe.title.localizedStandardContains(searchText))
&& (!showFavoritesOnly || recipe.isFavorite)
}
_recipes = Query(
filter: predicate,
sort: [SortDescriptor(\.title)]
)
}
var body: some View {
List(recipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
This pattern is essential for search, filtering, and any dynamic query scenario. The view reinitializes with new parameters, the @Query updates, and SwiftUI handles the diffing. It's surprisingly elegant once you get the hang of it.
Building Complex Predicates
The #Predicate macro supports a wide range of operators and expressions:
// Compound conditions
let predicate = #Predicate { recipe in
recipe.servings >= 4
&& recipe.cookTimeMinutes <= 30
&& recipe.title.localizedStandardContains("chicken")
}
// Relationship predicates — filter by related objects
let predicate = #Predicate { recipe in
recipe.ingredients.contains { ingredient in
ingredient.name.localizedStandardContains("garlic")
}
}
// Optional handling
let predicate = #Predicate { recipe in
recipe.imageData != nil && recipe.isFavorite
}
// Negation
let predicate = #Predicate { recipe in
!recipe.title.isEmpty && recipe.servings > 0
}
One important caveat: the #Predicate macro compiles to NSPredicate under the hood, so it only supports operations that can be translated to SQL. You can't call arbitrary Swift functions inside a predicate. If you need custom logic, fetch a broader set and filter in Swift — though you'll pay the performance cost of loading more objects into memory.
FetchDescriptor for Programmatic Queries
Outside of SwiftUI views, use FetchDescriptor with a ModelContext:
func findRecipes(matching searchText: String,
in context: ModelContext) throws -> [Recipe] {
var descriptor = FetchDescriptor(
predicate: #Predicate { $0.title.localizedStandardContains(searchText) },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = 20
descriptor.propertiesToFetch = [\.title, \.summary, \.createdAt]
return try context.fetch(descriptor)
}
The fetchLimit property is your first line of defense against loading too much data. And propertiesToFetch enables partial object loading — the database only reads the columns you specify, which matters a lot when your model has large properties like image data or long text fields.
For counting records, always use fetchCount() instead of fetching the full array:
let favoriteCount = try context.fetchCount(
FetchDescriptor(
predicate: #Predicate { $0.isFavorite }
)
)
This translates to a SQL COUNT(*) query — orders of magnitude faster than loading every matching object just to read .count. Seriously, I've seen apps burn hundreds of milliseconds doing this the wrong way.
CRUD Operations in Practice
Let's look at the full lifecycle of creating, reading, updating, and deleting objects.
Creating and Inserting
func createRecipe(in context: ModelContext) {
let recipe = Recipe(title: "Margherita Pizza", summary: "Classic Italian pizza",
instructions: "Make dough, add sauce and cheese, bake at 450°F",
servings: 4, prepTimeMinutes: 30, cookTimeMinutes: 15)
let basilTag = Tag(name: "Italian")
let quickTag = Tag(name: "Quick Meals")
recipe.tags = [basilTag, quickTag]
let dough = Ingredient(name: "Pizza Dough", quantity: "1 ball", recipe: recipe)
let sauce = Ingredient(name: "Tomato Sauce", quantity: "1/2 cup", recipe: recipe)
recipe.ingredients = [dough, sauce]
context.insert(recipe)
// Related objects are inserted automatically through relationships
}
You only need to insert the root object. SwiftData follows the relationship graph and inserts all connected objects automatically.
Calling try context.save() is optional — SwiftData autosaves at strategic points (before the app backgrounds, at the end of a run loop cycle, etc.). But for critical data, an explicit save gives you certainty:
do {
try context.save()
} catch {
print("Failed to save: \(error)")
}
Updating
Updates happen through direct property mutation. SwiftData's change tracking detects modifications automatically:
func toggleFavorite(_ recipe: Recipe) {
recipe.isFavorite.toggle()
recipe.updatedAt = Date()
// No need to call save — SwiftData tracks the change
}
If you're coming from Core Data, this feels almost too easy. No NSManagedObjectContext ceremony, no setValue:forKey: — just mutate the property and move on.
Deleting
// Delete a single object
func deleteRecipe(_ recipe: Recipe, from context: ModelContext) {
context.delete(recipe)
// Cascade delete rule handles ingredients automatically
}
// Batch delete with a predicate
func deleteOldRecipes(from context: ModelContext) throws {
let cutoff = Calendar.current.date(byAdding: .year, value: -1, to: Date())!
try context.delete(
model: Recipe.self,
where: #Predicate { $0.createdAt < cutoff && !$0.isFavorite }
)
}
Batch deletion is significantly faster than fetching objects and deleting them one by one, because it translates to a single SQL DELETE statement with a WHERE clause.
Background Processing with @ModelActor
This is where most SwiftData tutorials fall short — and where production apps absolutely need to get things right. The @Query property wrapper and the environment's ModelContext both run on the main actor. That's fine for displaying data, but if you're importing a JSON file with 10,000 recipes, parsing CSV exports, or syncing with a server, you'll freeze the UI.
The solution is @ModelActor.
It's a macro that creates an actor with its own ModelContext running on a background thread:
import SwiftData
@ModelActor
actor RecipeImporter {
func importRecipes(from jsonData: Data) throws -> Int {
let decoder = JSONDecoder()
let dtos = try decoder.decode([RecipeDTO].self, from: jsonData)
var importCount = 0
for dto in dtos {
let recipe = Recipe(
title: dto.title,
summary: dto.summary,
instructions: dto.instructions,
servings: dto.servings,
prepTimeMinutes: dto.prepTime,
cookTimeMinutes: dto.cookTime
)
for tagName in dto.tags {
// Check if tag already exists
let descriptor = FetchDescriptor(
predicate: #Predicate { $0.name == tagName }
)
if let existingTag = try modelContext.fetch(descriptor).first {
recipe.tags.append(existingTag)
} else {
let newTag = Tag(name: tagName)
recipe.tags.append(newTag)
}
}
modelContext.insert(recipe)
importCount += 1
// Save in batches to manage memory
if importCount % 100 == 0 {
try modelContext.save()
}
}
try modelContext.save()
return importCount
}
}
The critical rule here: SwiftData models are not Sendable. You cannot pass model objects between actors. Instead, pass the ModelContainer (which is Sendable) and let each actor create its own context:
struct ImportView: View {
@Environment(\.modelContext) private var context
var body: some View {
Button("Import Recipes") {
Task {
let container = context.container
let importer = RecipeImporter(modelContainer: container)
let jsonData = try await downloadRecipeData()
let count = try await importer.importRecipes(from: jsonData)
print("Imported \(count) recipes")
}
}
}
}
When the background actor saves, the main actor's context picks up the changes automatically — @Query results update and your views refresh. This is one of the genuinely magical parts of SwiftData's architecture, and honestly it's the thing that finally made me stop reaching for Core Data by default.
Passing Identifiers, Not Objects
When you need to reference a specific object across actor boundaries, pass its PersistentIdentifier instead of the object itself:
@ModelActor
actor RecipeUpdater {
func markAsFavorite(recipeID: PersistentIdentifier) throws {
guard let recipe = modelContext.model(for: recipeID) as? Recipe else {
throw RecipeError.notFound
}
recipe.isFavorite = true
recipe.updatedAt = Date()
try modelContext.save()
}
}
// In your view or view model
let recipeID = recipe.persistentModelID
let updater = RecipeUpdater(modelContainer: container)
try await updater.markAsFavorite(recipeID: recipeID)
The PersistentIdentifier type is Sendable and uniquely identifies a model object across contexts. This is the safe way to communicate about specific objects between the main thread and background actors.
Schema Migration
Your data model will evolve. That's just a fact of app development. SwiftData handles simple changes — adding new properties with defaults, removing optional properties — as lightweight migrations with zero effort. But when you rename properties, change types, or transform data, you need a migration plan.
Versioned Schemas
Each version of your schema gets captured as a VersionedSchema:
enum RecipeSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Recipe.self]
}
@Model
final class Recipe {
var title: String
var description: String
var servings: Int
init(title: String, description: String, servings: Int) {
self.title = title
self.description = description
self.servings = servings
}
}
}
enum RecipeSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Recipe.self]
}
@Model
final class Recipe {
var title: String
@Attribute(originalName: "description") var summary: String
var servings: Int
var prepTimeMinutes: Int
var cookTimeMinutes: Int
init(title: String, summary: String, servings: Int,
prepTimeMinutes: Int = 0, cookTimeMinutes: Int = 0) {
self.title = title
self.summary = summary
self.servings = servings
self.prepTimeMinutes = prepTimeMinutes
self.cookTimeMinutes = cookTimeMinutes
}
}
}
Building a Migration Plan
enum RecipeMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[RecipeSchemaV1.self, RecipeSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: RecipeSchemaV1.self,
toVersion: RecipeSchemaV2.self
) { context in
// Pre-migration: runs before the schema change
// Good for data transformations that need the old schema
} didMigrate: { context in
// Post-migration: runs after the schema change
let recipes = try context.fetch(FetchDescriptor())
for recipe in recipes {
// Set default values for new properties
recipe.prepTimeMinutes = 0
recipe.cookTimeMinutes = 0
}
try context.save()
}
}
Then configure your container to use the migration plan:
let container = try ModelContainer(
for: RecipeSchemaV2.Recipe.self,
migrationPlan: RecipeMigrationPlan.self
)
For simple changes like adding new properties with default values, use MigrationStage.lightweight instead of .custom — it's faster and requires no code. Save the custom migrations for when you actually need to transform data.
Model Inheritance in iOS 26
iOS 26 introduced model inheritance — the last major feature SwiftData was missing compared to Core Data. If you've been waiting for this one, it was worth the wait. It lets you create hierarchies of related model types that share a common base:
@Model
class MediaItem {
var title: String
var createdAt: Date
var isFavorite: Bool
init(title: String) {
self.title = title
self.createdAt = Date()
self.isFavorite = false
}
}
@available(iOS 26, *)
@Model
final class Photo: MediaItem {
@Attribute(.externalStorage) var imageData: Data?
var resolution: String
init(title: String, resolution: String) {
self.resolution = resolution
super.init(title: title)
}
}
@available(iOS 26, *)
@Model
final class Video: MediaItem {
var durationSeconds: Int
var codec: String
init(title: String, durationSeconds: Int, codec: String) {
self.durationSeconds = durationSeconds
self.codec = codec
super.init(title: title)
}
}
With inheritance, you can query the base type and get all subtypes:
// Fetches all MediaItems — Photos, Videos, and any future subtypes
@Query var allMedia: [MediaItem]
// Or fetch specific subtypes
@Query var photos: [Photo]
The @available(iOS 26, *) annotation is required because model inheritance is only supported starting with iOS 26. If your deployment target is earlier, you'll need to use protocol-based abstractions instead.
Performance Patterns for Production
Getting SwiftData to work is easy. Getting it to work fast takes deliberate effort. Here are the patterns that matter most when your app hits real-world scale.
1. Fetch Only What You Need
// Bad: loads all properties of all recipes
let allRecipes = try context.fetch(FetchDescriptor())
let titles = allRecipes.map { $0.title }
// Good: loads only title and limits results
var descriptor = FetchDescriptor()
descriptor.propertiesToFetch = [\.title]
descriptor.fetchLimit = 50
let recipes = try context.fetch(descriptor)
2. Use External Storage for Binary Data
@Model
final class Recipe {
// This image won't slow down list queries
@Attribute(.externalStorage) var imageData: Data?
@Attribute(.externalStorage) var videoData: Data?
}
3. Index Your Query Columns
@Model
final class Recipe {
#Index([\.title], [\.createdAt], [\.isFavorite, \.createdAt])
// Properties used in predicates or sort descriptors
// should always be indexed
var title: String
var createdAt: Date
var isFavorite: Bool
}
4. Batch Saves for Bulk Operations
@ModelActor
actor BulkImporter {
func importItems(_ items: [ItemDTO]) throws {
for (index, dto) in items.enumerated() {
let item = Item(from: dto)
modelContext.insert(item)
// Save every 250 items to keep memory usage flat
if index % 250 == 0 {
try modelContext.save()
}
}
try modelContext.save()
}
}
5. Watch Out for Task Priority Throttling
This is a subtle one that bit me in a real project. When creating detached tasks for background work, don't set the priority to .background:
// Bad: dramatically slower fetches
Task.detached(priority: .background) {
let importer = RecipeImporter(modelContainer: container)
try await importer.importRecipes(from: data)
}
// Good: default priority works fine
Task.detached {
let importer = RecipeImporter(modelContainer: container)
try await importer.importRecipes(from: data)
}
Setting .background priority tells the system this task is truly low-priority, and the scheduler may throttle I/O operations — making SwiftData fetches up to five times slower. Use the default priority unless you genuinely don't care when the work completes.
6. Use fetchCount() for Counts
// Bad: loads all objects just to count them
let count = try context.fetch(FetchDescriptor()).count
// Good: SQL COUNT query
let count = try context.fetchCount(FetchDescriptor(
predicate: #Predicate { $0.isFavorite }
))
Testing SwiftData Code
SwiftData's in-memory configuration makes testing straightforward. Create an in-memory container, run your operations, and assert against the results — no file cleanup needed:
import Testing
import SwiftData
@Suite("Recipe CRUD Operations")
struct RecipeCRUDTests {
let container: ModelContainer
init() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(
for: Recipe.self, Ingredient.self, Tag.self,
configurations: config
)
}
@Test func insertRecipeWithIngredients() throws {
let context = ModelContext(container)
let recipe = Recipe(title: "Test Pasta", summary: "A test recipe",
instructions: "Cook the pasta")
let ingredient = Ingredient(name: "Pasta", quantity: "500g", recipe: recipe)
recipe.ingredients = [ingredient]
context.insert(recipe)
try context.save()
let descriptor = FetchDescriptor(
predicate: #Predicate { $0.title == "Test Pasta" }
)
let fetched = try context.fetch(descriptor)
#expect(fetched.count == 1)
#expect(fetched.first?.ingredients.count == 1)
}
@Test func cascadeDeleteRemovesIngredients() throws {
let context = ModelContext(container)
let recipe = Recipe(title: "Delete Me", summary: "Gone soon",
instructions: "N/A")
let ingredient = Ingredient(name: "Nothing", quantity: "0g", recipe: recipe)
recipe.ingredients = [ingredient]
context.insert(recipe)
try context.save()
context.delete(recipe)
try context.save()
let ingredientCount = try context.fetchCount(FetchDescriptor())
#expect(ingredientCount == 0)
}
@Test func batchDeleteWithPredicate() throws {
let context = ModelContext(container)
for i in 1...10 {
let recipe = Recipe(title: "Recipe \(i)", summary: "Summary \(i)",
instructions: "Instructions \(i)")
recipe.isFavorite = i <= 3 // First 3 are favorites
context.insert(recipe)
}
try context.save()
try context.delete(
model: Recipe.self,
where: #Predicate { !$0.isFavorite }
)
try context.save()
let remaining = try context.fetchCount(FetchDescriptor())
#expect(remaining == 3)
}
}
Using the Swift Testing framework with in-memory SwiftData containers gives you fast, isolated tests that don't touch the disk. Each test gets a fresh container, so there's no state leaking between test runs.
Common Pitfalls and How to Avoid Them
After working with SwiftData across several production apps, here are the mistakes I see most often (and have definitely made myself).
1. Accessing Models Across Actor Boundaries
// This will crash or produce undefined behavior
let recipe = try context.fetch(descriptor).first!
Task.detached {
print(recipe.title) // Accessing a non-Sendable object off the main actor
}
// Do this instead
let recipeID = recipe.persistentModelID
Task.detached {
let bgContext = ModelContext(container)
let bgRecipe = bgContext.model(for: recipeID) as? Recipe
print(bgRecipe?.title ?? "not found")
}
2. Forgetting That @Query Runs on Main Actor
If you have a view displaying 50,000 records with @Query, you're loading all of them on the main thread. Use fetchLimit and implement pagination:
struct PaginatedRecipeList: View {
@Query private var recipes: [Recipe]
init(page: Int, pageSize: Int = 50) {
var descriptor = FetchDescriptor(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = pageSize
descriptor.fetchOffset = page * pageSize
_recipes = Query(descriptor)
}
var body: some View {
List(recipes) { recipe in
RecipeRow(recipe: recipe)
}
}
}
3. Not Setting Delete Rules on Relationships
The default delete rule is .nullify, which orphans child objects. For parent-child relationships, you almost always want .cascade. Orphaned objects silently accumulate and bloat your database over time — the kind of bug that only shows up months after launch when users start complaining about app size.
4. Storing Computed Data Instead of Computing It
// Don't persist data you can compute
@Model
final class Recipe {
var prepTimeMinutes: Int
var cookTimeMinutes: Int
// Good: computed, not stored
var totalTimeMinutes: Int {
prepTimeMinutes + cookTimeMinutes
}
// Bad: stored but always derivable
// var totalTimeMinutes: Int // don't do this
}
Putting It All Together: A Production Architecture
Here's how all these pieces fit together in a well-structured production app:
// 1. App entry point with configured container
@main
struct RecipeApp: App {
let container: ModelContainer
init() {
do {
let config = ModelConfiguration(
"Recipes",
groupContainer: .identifier("group.com.myapp.recipes")
)
container = try ModelContainer(
for: Recipe.self, Ingredient.self, Tag.self,
configurations: config,
migrationPlan: RecipeMigrationPlan.self
)
} catch {
fatalError("Database initialization failed: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
// 2. Background importer as a ModelActor
@ModelActor
actor RecipeImporter {
func importFromServer(_ url: URL) async throws -> Int {
let (data, _) = try await URLSession.shared.data(from: url)
let dtos = try JSONDecoder().decode([RecipeDTO].self, from: data)
for (index, dto) in dtos.enumerated() {
let recipe = Recipe(title: dto.title, summary: dto.summary,
instructions: dto.instructions)
modelContext.insert(recipe)
if index % 100 == 0 { try modelContext.save() }
}
try modelContext.save()
return dtos.count
}
}
// 3. View with dynamic query
struct RecipeListView: View {
@Environment(\.modelContext) private var context
@Query private var recipes: [Recipe]
@State private var isImporting = false
init(searchText: String = "", favoritesOnly: Bool = false) {
_recipes = Query(
filter: #Predicate { recipe in
(searchText.isEmpty || recipe.title.localizedStandardContains(searchText))
&& (!favoritesOnly || recipe.isFavorite)
},
sort: [SortDescriptor(\.createdAt, order: .reverse)]
)
}
var body: some View {
List {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeRow(recipe: recipe)
}
}
.onDelete(perform: deleteRecipes)
}
.toolbar {
Button("Import") {
isImporting = true
Task {
let importer = RecipeImporter(modelContainer: context.container)
_ = try? await importer.importFromServer(API.recipesURL)
isImporting = false
}
}
.disabled(isImporting)
}
}
private func deleteRecipes(at offsets: IndexSet) {
for index in offsets {
context.delete(recipes[index])
}
}
}
This architecture gives you a clear separation between UI (views with @Query), background processing (@ModelActor), and configuration (container setup with migration plans). Each piece is testable in isolation, and the whole system handles concurrency safely through Swift's actor model.
Wrapping Up
SwiftData has come a long way since its debut. The combination of declarative model definitions, automatic change tracking, deep SwiftUI integration, and the new iOS 26 features makes it a genuine contender for production data persistence — not just a demo framework.
Here are the key takeaways for building production apps with SwiftData:
- Design models carefully — use
@Attributefor storage hints,#Uniquefor constraints, and#Indexfor query performance - Define relationships explicitly — always set delete rules, always specify inverses
- Move heavy work off the main thread — use
@ModelActorfor imports, syncs, and batch operations - Pass identifiers, not objects — use
PersistentIdentifieracross actor boundaries - Plan for migration from day one — use
VersionedSchemaandSchemaMigrationPlan - Optimize queries — use
fetchLimit,propertiesToFetch,fetchCount(), and indexes - Test with in-memory containers — fast, isolated, no cleanup needed
SwiftData isn't perfect — the predicate macro has limitations, CloudKit sync can still be temperamental, and some Core Data features like sectioned fetch requests require workarounds. But for the vast majority of iOS apps, it's now the right tool for the job. Start with the patterns in this guide, and you'll have a solid foundation to build on.