Issue #1040
SwiftData is Apple’s modern persistence framework, introduced in iOS 17. It builds on top of Core Data but exposes a Swift-native API using macros and property wrappers. For most apps targeting iOS 17 or later, it replaces Core Data entirely without needing to touch an xcdatamodeld file or write fetch requests by hand.
Defining a model
The entry point for any SwiftData app is the @Model macro. Apply it to a class and SwiftData automatically makes it persistable and observable.
@Model
class Book {
var title: String
var author: String
var pageCount: Int
var dateAdded: Date
init(title: String, author: String, pageCount: Int, dateAdded: Date = .now) {
self.title = title
self.author = author
self.pageCount = pageCount
self.dateAdded = dateAdded
}
}
@Model synthesizes conformances to PersistentModel and Observable, so SwiftUI views automatically re-render when model properties change. All stored properties are persisted by default. Avoid the property name description, which is explicitly disallowed, and do not add property observers because SwiftData silently ignores them.
If you need a property that is computed on the fly and should not be stored, mark it with @Transient. It must have a default value because SwiftData resets it to that value each time the object is fetched.
@Model
class Book {
var title: String
var pageCount: Int
@Transient var isBeingEdited: Bool = false
}
For unique constraints, use #Unique inside the class body. You can only have one #Unique per model, but pass multiple key path arrays if you need more than one constraint.
@Model
class Book {
#Unique<Book>([\.isbn], [\.title, \.author])
var isbn: String
var title: String
var author: String
}
Enums work as stored properties as long as they conform to Codable. This includes enums with associated values, despite some older documentation suggesting otherwise.
enum Genre: Codable {
case fiction
case nonFiction
case technical(field: String)
}
@Model
class Book {
var title: String
var genre: Genre
}
Setting up the container
ModelContainer is the database. You set it up once at the app level and inject it into the SwiftUI environment.
@main
struct LibraryApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: Book.self)
}
}
For multiple model types, pass them as an array.
.modelContainer(for: [Book.self, Author.self])
When you need more control, such as storing data in memory only for tests or previews, create the container manually using ModelConfiguration.
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Book.self, configurations: config)
Inserting and deleting data
Inside any SwiftUI view, access ModelContext from the environment. This is the context you use for all write operations.
struct AddBookView: View {
@Environment(\.modelContext) private var modelContext
func addBook() {
let book = Book(title: "Dune", author: "Frank Herbert", pageCount: 412)
modelContext.insert(book)
try? modelContext.save()
}
}
SwiftData has an autosave mechanism, but it does not run predictably. Calling save() explicitly whenever correctness matters is the safer approach. There is no need to check modelContext.hasChanges first; just call save() directly.
Deleting a model instance is equally direct.
modelContext.delete(book)
try? modelContext.save()
Note that a model’s persistentModelID is temporary until the first save. The identifier starts with a lowercase “t” while it is unsaved. If you need to pass an ID across actors or store it, save the object first.
Querying with @Query
@Query is the primary way to fetch data inside a SwiftUI view. Declare it as a property and SwiftData keeps the results in sync with the store automatically.
struct BookListView: View {
@Query var books: [Book]
var body: some View {
List(books) { book in
Text(book.title)
}
}
}
Add sorting by passing a key path.
@Query(sort: \Book.title) var books: [Book]
For descending order, specify .reverse.
@Query(sort: \Book.dateAdded, order: .reverse) var books: [Book]
To filter results, use the #Predicate macro. It provides compile-time type checking and generates the correct SQL for the underlying store.
@Query(filter: #Predicate<Book> { $0.pageCount > 300 },
sort: \Book.title)
var longBooks: [Book]
@Query only works inside SwiftUI views. For queries in other contexts, use FetchDescriptor directly.
Fetching programmatically
Outside of views, fetch data through ModelContext using a FetchDescriptor.
var descriptor = FetchDescriptor<Book>(
predicate: #Predicate { $0.pageCount > 300 },
sortBy: [SortDescriptor(\Book.title)]
)
descriptor.fetchLimit = 20
let results = try modelContext.fetch(descriptor)
fetchLimit caps the number of results. Combined with fetchOffset, it supports basic pagination. For frequently used queries, declare the descriptor as a static property so you are not recreating it on every call.
extension Book {
static var longBookDescriptor: FetchDescriptor<Book> {
var descriptor = FetchDescriptor<Book>(
predicate: #Predicate { $0.pageCount > 300 },
sortBy: [SortDescriptor(\Book.title)]
)
descriptor.fetchLimit = 20
return descriptor
}
}
If you only need a count, use modelContext.fetchCount(_:) instead of fetching the full objects. Keep in mind this count does not live-update; only @Query provides that behavior.
Defining relationships
Use @Relationship to connect two model types. Place the macro on one side of the relationship only. Using it on both sides creates a circular reference.
@Model
class Author {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Book.author) var books: [Book] = []
init(name: String) {
self.name = name
}
}
@Model
class Book {
var title: String
var author: Author?
init(title: String) {
self.title = title
}
}
Always set an explicit delete rule. The default is .nullify, which sets the related object’s reference to nil on deletion. This can leave orphaned objects or crash if the property is non-optional. Use .cascade when the child should be deleted with the parent, and .deny when deleting the parent should be blocked while children exist.
SwiftData sometimes infers the inverse relationship incorrectly. Specifying it explicitly with inverse: prevents subtle bugs where the relationship does not behave as expected.
Predicate pitfalls
SwiftData predicates support only a subset of Swift. Some methods are outright unsupported and produce a compile error: String.hasSuffix(), String.lowercased(), Sequence.map(), Sequence.reduce(), and Collection.first. Custom operators are also off limits.
For string matching, use localizedStandardContains() rather than lowercased().contains(). For prefix matching, use starts(with:) instead of hasPrefix().
@Query(filter: #Predicate<Book> {
$0.title.localizedStandardContains("swift")
}) var swiftBooks: [Book]
A trickier category is predicates that compile cleanly but crash at runtime. Checking whether an array is non-empty with == false is a common trap.
// Crashes at runtime
@Query(filter: #Predicate<Book> { $0.reviews.isEmpty == false }) var reviewedBooks: [Book]
// Correct
@Query(filter: #Predicate<Book> { !$0.reviews.isEmpty }) var reviewedBooks: [Book]
Computed properties, @Transient properties, custom Codable struct data, and regular expressions all compile without error but fail at runtime. Every value referenced in a predicate must be an actual stored property in the database.
SwiftData’s compile-time safety catches many mistakes, but the runtime category requires care. Test predicate queries early in development rather than discovering crashes in production.
Start the conversation