WWDC Quick Look 💓 By SwiftGGTeam
What's new in SwiftData

What's new in SwiftData

Watch original video

Highlight

SwiftData adds the #Unique macro for compound uniqueness constraints, the #Index macro 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, \.endDate must 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)
   }
}
  • JSONStoreConfiguration replaces the default ModelConfiguration, 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())
}
  • PreviewModifier defines shared preview context
  • isStoredInMemoryOnly: true ensures previews don’t write to disk
  • Trip.makeSampleTrips populates 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)
}
  • @Previewable lets you create @Query in 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 @Query and FetchDescriptor in 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 a PreviewModifier so all SwiftData previews reuse the same data source. Previews stay consistent, and sample data changes only need one edit.

Comments

GitHub Issues · utterances