Make SwiftData Searches Fast With #Index And #Unique
SwiftData models start out nicely small. Add @Model, write normal Swift properties, and let the framework build the persistence schema. That is great for the first version of a feature, but production data usually needs two more things: fast fetches for the queries your UI runs all the time, and a clear way to prevent duplicate records.
The iOS 18 generation of SwiftData added two schema macros for that work: #Index and #Unique. #Index tells SwiftData which key paths should get stored index metadata for filtering and sorting. #Unique tells SwiftData which key paths, or combinations of key paths, must be unique for a model.
Both macros are available on iOS 18, iPadOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2. They are schema declarations, so treat adding or removing them like a schema change and test existing stores before shipping.
Start with the fetch
Indexes are useful when they match a real query. Before adding one, look at the fetches your app repeats constantly.
Here is a simple notes model and a common fetch for one folder:
import Foundation
import SwiftData
@Model
final class Note {
var remoteID: String
var folderID: UUID
var slug: String
var title: String
var body: String
var isArchived: Bool
var isPinned: Bool
var updatedAt: Date
init(
remoteID: String,
folderID: UUID,
slug: String,
title: String,
body: String
) {
self.remoteID = remoteID
self.folderID = folderID
self.slug = slug
self.title = title
self.body = body
self.isArchived = false
self.isPinned = false
self.updatedAt = .now
}
}
func recentNotes(
in folderID: UUID,
context: ModelContext
) throws -> [Note] {
let descriptor = FetchDescriptor<Note>(
predicate: #Predicate { note in
note.folderID == folderID && !note.isArchived
},
sortBy: [
SortDescriptor(\.updatedAt, order: .reverse)
]
)
return try context.fetch(descriptor)
}
That query filters by folderID and isArchived, then sorts by updatedAt. Those are better index candidates than a property that almost never appears in a predicate or sort descriptor.
Add binary indexes
The key-path form of #Index creates binary indexes. Put it inside the model type, alongside the other schema declarations.
@Model
final class Note {
#Index<Note>(
[\.folderID],
[\.updatedAt],
[\.folderID, \.isArchived, \.updatedAt],
[\.folderID, \.isPinned, \.updatedAt]
)
var remoteID: String
var folderID: UUID
var slug: String
var title: String
var body: String
var isArchived: Bool
var isPinned: Bool
var updatedAt: Date
// Same initializer as before.
}
The bracket grouping is important:
#Index<Note>([\.folderID], [\.updatedAt])
That declares two separate indexes. This declares one compound index:
#Index<Note>([\.folderID, \.updatedAt])
Compound indexes are for queries that use those properties together. In the notes example, [\.folderID, \.isArchived, \.updatedAt] matches the shape of “not archived notes in this folder, newest first” better than three unrelated indexes.
Apple’s documentation also exposes an overload that can describe binary or R-tree indexes through Schema.Index. Most app code will start with the key-path form shown above. If you are considering the more specialized form, read the current docs for the exact index type and test with data that looks like your real store.
Do not index everything
An index is extra metadata that SwiftData stores in the container. That is useful when it helps a hot fetch, but it is not free.
I would start with properties that appear in repeated equality filters, range filters, and sort descriptors. Good candidates are things like folderID, updatedAt, createdAt, status, isArchived, or a stable server identifier. Be more skeptical of large text fields and one-off filters.
Also be careful with string search. An index on title can be useful when a query sorts by title or filters by exact title. It does not automatically make every substring search cheap. If your feature is a real search experience across long text, measure it and consider whether Spotlight, a server search endpoint, or a dedicated local search strategy is a better fit.
Add uniqueness rules
#Unique gives the schema a duplicate-prevention rule. You can declare a single-property constraint, a compound constraint, or both.
@Model
final class Note {
#Unique<Note>(
[\.remoteID],
[\.folderID, \.slug]
)
#Index<Note>(
[\.folderID],
[\.folderID, \.isArchived, \.updatedAt]
)
var remoteID: String
var folderID: UUID
var slug: String
var title: String
var body: String
var isArchived: Bool
var isPinned: Bool
var updatedAt: Date
// Same initializer as before.
}
This says two things:
- No two
Noteinstances should have the sameremoteID. - No two
Noteinstances in the same folder should have the sameslug.
That second rule is the reason #Unique is more expressive than a single @Attribute(.unique) property. A slug only needs to be unique inside its folder. Two different folders can still have a welcome note.
Apple describes a uniqueness collision as an upsert. If a new model has the same unique values as an existing model, SwiftData updates the existing model instead of storing a duplicate. That is useful for import and sync code, but it is still worth being explicit in your UI. If the user is creating a note and you want to show “that slug is already taken”, perform your own preflight fetch and show a normal validation message.
func slugIsAvailable(
_ slug: String,
in folderID: UUID,
context: ModelContext
) throws -> Bool {
let descriptor = FetchDescriptor<Note>(
predicate: #Predicate { note in
note.folderID == folderID && note.slug == slug
}
)
return try context.fetchCount(descriptor) == 0
}
The unique rule is the persistence safety net. The preflight fetch is the user experience.
Use both together
It is common for the same properties to appear in both uniqueness and indexing. A remoteID is often unique and also used for lookups during sync. A compound identity like [\.folderID, \.slug] is also likely to appear in a fetch when opening a note from a URL.
func note(
folderID: UUID,
slug: String,
context: ModelContext
) throws -> Note? {
let descriptor = FetchDescriptor<Note>(
predicate: #Predicate { note in
note.folderID == folderID && note.slug == slug
}
)
return try context.fetch(descriptor).first
}
If this lookup is important, an index matching [\.folderID, \.slug] is reasonable:
#Index<Note>(
[\.folderID, \.slug],
[\.folderID, \.isArchived, \.updatedAt]
)
That does not replace the unique rule. The index is for finding rows efficiently. The unique rule is for preserving a data invariant.
A few practical rules
Use #Index for fetch shapes you can point to in real code. If you cannot name the predicate or sort descriptor that benefits, wait.
Prefer compound indexes when the query uses a group of properties together. Separate indexes on folderID, isArchived, and updatedAt are not the same thing as an index on [\.folderID, \.isArchived, \.updatedAt].
Use #Unique for facts that must remain true even when data arrives from sync, import, shortcuts, widgets, or background work. UI checks are nice, but the persistence schema should still know the rule.
Do not use uniqueness as a substitute for human-friendly validation. An upsert might be perfect for sync and surprising for a create form.
For relationship properties, Apple’s #Unique documentation calls out an important limit: unique constraints support relationships that reference a single persistent model, not arrays of persistent models.
Finally, measure the important paths. The point of these macros is not to decorate every model with schema cleverness. The point is to make the store reflect the way your app actually reads and protects its data.
Further reading: