Highlight
SwiftData adds the
#Uniquemacro for compound uniqueness constraints, the#Indexmacro to accelerate query performance, and support for custom data store implementations.
Core Content
SwiftData launched with iOS 17 last year, aiming to simplify data persistence using Swift’s modern language features like macros. Developers decorate classes with @Model for automatic persistence, then use @Query in SwiftUI views to fetch data.
This year addresses a practical problem: duplicate data. When users sync data, bulk import, or network retries occur, duplicate model instances easily appear. Previously developers had to deduplicate manually or rely on a single unique identifier. But in many business scenarios, uniqueness is a combination of fields—a travel app might allow trips with the same name if dates differ; only when name, start date, and end date all match is it truly a duplicate.
SwiftData now introduces the #Unique macro to declare compound uniqueness constraints across multiple properties. When an inserted model conflicts with an existing model on these unique fields, SwiftData automatically performs upsert (update rather than insert), preventing duplicates at the source.
Another performance concern is query efficiency. As data grows, frequent sorting and filtering on specific fields slows down. The #Index macro lets developers declare database indexes—like a book’s table of contents—significantly accelerating these operations. Additionally, SwiftData now supports fully custom data stores, no longer limited to the SQLite backend; you can use JSON, other formats, or completely custom persistence schemes.
Detailed Content
Compound Uniqueness Constraints
The #Unique macro accepts a set of KeyPaths declaring these property combinations must be unique (03:08):
import SwiftData
@Model
class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
var name: String
var destination: String
var startDate: Date
var endDate: Date
var bucketList: [BucketListItem] = [BucketListItem]()
var livingAccommodation: LivingAccommodation?
}
#Unique<Trip>declares uniqueness constraints for the Trip model\.name, \.startDate, \.endDatemust be unique as a combination- When an inserted model matches an existing model on all three fields, SwiftData updates the existing model rather than inserting a new one
These unique fields also represent model identity. To let SwiftData’s History API identify these identities after model deletion, add @Attribute(.preserveValueOnDeletion) (03:36):
@Model
class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
@Attribute(.preserveValueOnDeletion)
var name: String
var destination: String
@Attribute(.preserveValueOnDeletion)
var startDate: Date
@Attribute(.preserveValueOnDeletion)
var endDate: Date
var bucketList: [BucketListItem] = [BucketListItem]()
var livingAccommodation: LivingAccommodation?
}
@Attribute(.preserveValueOnDeletion)ensures these field values persist in history after model deletion- History API tracks model insert, update, and delete; preserved values serve as tombstone information for processing changes
Custom Data Stores
iOS 18 allows fully custom data stores (05:59):
import SwiftUI
import SwiftData
@main
struct TripsApp: App {
var container: ModelContainer = {
do {
let configuration = JSONStoreConfiguration(schema: Schema([Trip.self]), url: jsonFileURL)
return try ModelContainer(for: Trip.self, configurations: configuration)
}
catch { ... }
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
JSONStoreConfigurationreplaces the defaultModelConfiguration, using a custom data store- Custom stores can implement features progressively for a quick start
- You can still use
@Model,@Query, and other SwiftData APIs
Preview Data Support
SwiftUI Previews can now create sample data with PreviewModifier (06:58):
struct SampleData: PreviewModifier {
static func makeSharedContext() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Trip.self, configurations: config)
Trip.makeSampleTrips(in: container)
return container
}
func body(content: Content, context: ModelContainer) -> some View {
content.modelContainer(context)
}
}
extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleData: Self = .modifier(SampleData())
}
PreviewModifierdefines shared preview contextisStoredInMemoryOnly: trueensures previews don’t write to diskTrip.makeSampleTripspopulates sample data
Usage (08:15):
#Preview(traits: .sampleData) {
ContentView()
}
For views depending on model parameters, use the @Previewable macro to create queries in previews (08:50):
#Preview(traits: .sampleData) {
@Previewable @Query var trips: [Trip]
BucketListItemView(trip: trips.first)
}
@Previewablelets you create@Queryin preview declarations- Returned arrays can be passed directly to views
Complex Queries and Expressions
#Predicate can build compound filter conditions (09:55):
let predicate = #Predicate<Trip> {
searchText.isEmpty ? true :
$0.name.localizedStandardContains(searchText) ||
$0.destination.localizedStandardContains(searchText)
}
iOS 18 adds the #Expression macro supporting complex non-boolean computations (10:46):
let unplannedItemsExpression = #Expression<[BucketListItem], Int> { items in
items.filter {
!$0.isInPlan
}.count
}
let today = Date.now
let tripsWithUnplannedItems = #Predicate<Trip>{ trip
(trip.startDate ..< trip.endDate).contains(today) &&
unplannedItemsExpression.evaluate(trip.bucketList) > 0
}
#Expression<[BucketListItem], Int>defines an expression with array input and integer output- The expression counts elements matching conditions in the array
evaluate(trip.bucketList)calls the expression within the predicate- This lets predicates express complex logic like “at least one element in the array matches a condition”
Index-Accelerated Queries
The #Index macro creates indexes for frequently used fields (12:41):
@Model
class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
#Index<Trip>([\.name], [\.startDate], [\.endDate], [\.name, \.startDate, \.endDate])
var name: String
var destination: String
var startDate: Date
var endDate: Date
var bucketList: [BucketListItem] = [BucketListItem]()
var livingAccommodation: LivingAccommodation?
}
#Index<Trip>creates indexes for specified KeyPaths[\.name],[\.startDate],[\.endDate]create single-field indexes[\.name, \.startDate, \.endDate]creates a compound index on three fields- Indexes accelerate sorting and filtering on these fields
- Significant effect on large datasets
Core Takeaways
-
Add uniqueness constraints for business identity: If you have a natural primary key (user ID, order number), declare it with
#Unique. For composite keys (user + time), declare those too. Sync and bulk import won’t create duplicate records, and you won’t need deduplication code. -
Add indexes for high-frequency query fields: Review
@QueryandFetchDescriptorin your app for fields frequently used in sorting or filtering. Mark these with#Index. Difference is negligible at small data sizes, but indexes can bring multi-fold performance gains as data grows. -
Unify preview data with PreviewModifier: Don’t manually create model containers and sample data in every view’s
#Preview. Define aPreviewModifierso all SwiftData previews reuse the same data source. Previews stay consistent, and sample data changes only need one edit.
Related Sessions
- Track model changes with SwiftData history — Learn the SwiftData History API to track model insert, update, and delete
- Create a custom data store with SwiftData — Deep dive into building custom data stores for SwiftData
- Meet SwiftData — SwiftData basics: modeling and persisting app data
- Build complex queries with SwiftData — Deep dive into SwiftData queries, predicates, and relationships
Comments
GitHub Issues · utterances