Framework comparisons for iOS persistence usually line up three model declarations side by side and let the syntax decide. The syntax is the part that matters least. What decides is the set of operations the data has to support, and whether the product will ask for device sync later. This article is the second half of a pair. The first, iOS Data Storage: Classify Your Data Before You Choose a Database, works through a travel-planning app called TripBoard and sorts everything it stores into six categories: preferences, secrets, domain entities, user files, disposable cache, and synchronization metadata. Five of those six were placed without a database at all. Preferences went to UserDefaults, secrets to Keychain, tickets and photos to the file system, thumbnails to a cache the system may delete, and sync state to a handful of fields. One category was left over: the domain entities. Trips, places, and bookings, the data the user typed and expects to find again. That is what needs a database, and choosing one is what this article is about. The code and API behavior described here target iOS 27, which is in beta as of July 2026. The GRDB examples use version 7. Third-party object databases such as Realm are out of scope. The argument here is about the shape of your queries, and it does not change if you add another engine to the list. Start with the Operations, Not the Framework Before choosing SwiftData, Core Data, or SQLite, write down the operations the application must support. For TripBoard, these are: Fetch all upcoming trips. Fetch places belonging to a specific trip. Search places by name. Calculate expenses grouped by currency. Update hundreds of records after synchronization. Mark multiple records as synchronized. Delete a trip and all of its related data. Import a large response from the server. These matter more than the syntax used to declare a model. Every section below is measured against them. Four more questions decide the rest. The first article sorted the data; these turn the sorting into a choice of engine. What are the three most common queries, and what is the most expensive one? Roughly how many rows will the largest table hold after three years? Must the app be fully usable offline, and must two devices agree afterward? How will the schema change after the first release, and how will you test an upgrade from a version you no longer run? Question two deserves an estimate rather than a shrug, but be careful what you conclude from it. Row count on its own is a poor predictor. A few thousand large records, fetched and sorted repeatedly, can behave worse than a much larger indexed table whose queries are selective and whose results are bounded. Estimate the scale, then prototype the actual query shapes on the oldest hardware you support. When the Queries Are Plain and the App Uses SwiftUI: SwiftData SwiftData can be a good choice for a new SwiftUI application that targets modern versions of iOS. It is especially attractive when: the model has a modest relationship graph, and the app does not lean on aggregate, full-text, or bulk queries; the application uses SwiftUI; the team wants a native Swift persistence API; CloudKit synchronization is part of the product plan. Measured against the eight operations, SwiftData takes the fetching and the relationships in stride. Operations one and two are @Query in a view: the fetch, the sorting, and the redraw are handled for you, and that convenience is most of why a SwiftUI project reaches for SwiftData at all. Operation seven is one deleteRule on a relationship. Bulk work is where it gets awkward. There is no equivalent of a batch update request for your models, so operation six, marking a thousand records as synchronized, means fetching a thousand objects and mutating them one at a time. Operation four has no aggregate query behind it either: you fetch the expenses and group them in Swift. Neither is disqualifying at the scale of one person's trips. Both are worth knowing before the data grows. One thing SwiftData makes genuinely easy is testing. A ModelConfiguration with isStoredInMemoryOnly set to true gives every test its own throwaway store, which removes the usual excuse for not testing the persistence layer at all. One item on the list above comes with a condition, and it is the one the next section is about: if CloudKit synchronization is part of the plan, the model has to be designed for it from the start. A basic model may look like this: import Foundation import SwiftData @Model final class Trip { @Attribute(.unique) var id: UUID var title: String var startDate: Date var endDate: Date var createdAt: Date @Relationship(deleteRule: .cascade, inverse: \Place.trip) var places: [Place] init(id: UUID = UUID(), title: String, startDate: Date, endDate: Date) { self.id = id self.title = title self.startDate = startDate self.endDate = endDate self.createdAt = Date() self.places = [] } } @Model final class Place { @Attribute(.unique) var id: UUID var name: String var latitude: Double var longitude: Double var isVisited: Bool var trip: Trip? init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double) { self.id = id self.name = name self.latitude = latitude self.longitude = longitude self.isVisited = false } } The minimal setup is appealing, but concise syntax does not remove architectural risks. The Model Above Will Not Sync with CloudKit This is worth pointing out early because SwiftData and CloudKit are often considered together. The listing above uses @Attribute(.unique) on both models. It is a perfectly valid local schema, right up to the moment CloudKit synchronization is enabled. Apple lists unique constraints and nonoptional relationships among the small number of SwiftData features that CloudKit does not support natively. Devices merge their changes asynchronously, so there is no moment at which the service could confirm that a value is unique everywhere. The documentation states the incompatibility. It does not state the symptom, and the symptom is the part you meet first: the container does not warn and carry on, it fails to load. That comes from watching it happen, not from a page you can cite. Apple documents four rules that a synchronized model must satisfy: no unique constraints; every relationship is optional; every relationship has an inverse, in case records arrive out of order; no .deny delete rule. Two additional constraints are easy to encounter in practice. If they are violated, the store may refuse to open on first launch and report error 134060 in the console: every attribute is either optional or has a default value; no ordered relationships. One further rule matters before you reach for the obvious workaround. Entities in one configuration cannot have relationships with entities in another. Therefore, splitting the store into a synchronized half and a local half does not let you preserve a unique constraint on the local side of a relationship. Written to satisfy those rules, Trip looks like this instead: @Model final class Trip { var id: UUID = UUID() var title: String = "" var startDate: Date = Date() var endDate: Date = Date() var createdAt: Date = Date() @Relationship(deleteRule: .cascade, inverse: \Place.trip) var places: [Place]? init(id: UUID = UUID(), title: String, startDate: Date, endDate: Date) { self.id = id self.title = title self.startDate = startDate self.endDate = endDate self.createdAt = Date() self.places = [] } } Place needs the same treatment, and leaving @Attribute(.unique) on even one model in the graph still prevents the container from loading. Note the cost of this design. Every property now needs a default, which means the type system no longer helps distinguish "not set yet" from "legitimately empty." Relationships become optional, so every call site must unwrap them. Uniqueness enforcement moves out of the database and into the import logic, which must look up an existing record by id before inserting a new one. That is a real design cost, and it is worth paying only if device synchronization is genuinely part of the product. The important point is that this decision is best made before the first release. Adding CloudKit synchronization to a model that has already shipped with unique constraints and non-optional attributes requires a migration across the installed user base. This is the argument of the whole article in miniature: the storage question is a product question first and an API question second. One more feature is worth understanding before you design around a limitation that no longer exists. SwiftData in iOS 27 adds @Attribute(.codable), which stores the encoded representation of a Codable type you do not own. It is useful for values supplied by a framework that you cannot annotate with @Model. The tradeoff is that the encoded value is opaque to SwiftData: you cannot query or sort by its contents, and schema changes inside the value do not trigger an aware migration. The type's Codable implementation must therefore remain forward- and backward-compatible. In this case, the conformance effectively becomes the migration strategy. Use .codable for a value that is merely carried alongside the model, not for data that you may later need to filter or sort. When the Stack Already Exists: Core Data Core Data remains a valid choice, especially for mature applications. It is not simply a SQLite wrapper. It is an object-graph management and persistence framework with its own identity management, change tracking, faulting, validation, migration, and concurrency systems. Core Data may be the better option when: the project already has a stable Core Data layer; older iOS versions must be supported; the application relies on NSFetchedResultsController; the project has a long migration history; the team understands managed object contexts and merge policies; an existing Core Data and CloudKit setup is working reliably. Replacing a mature Core Data implementation with SwiftData only to use newer syntax is rarely a strong business decision. Such a migration introduces risk without necessarily improving the user experience. Against the eight operations, Core Data answers the two that SwiftData struggles with. Operation six is NSBatchUpdateRequest, which Apple describes as updating a store "without loading any data into memory", so marking a thousand rows as synchronized never allocates a thousand objects. Operation four can be expressed as an aggregate fetch, with the grouping done by the store rather than in Swift. Operations one and two are where NSFetchedResultsController still earns its place. It is not just a fetch: it holds a sorted, sectioned result set, watches the context, and reports insertions, deletions, and moves as index paths. On a long list of places grouped by city, that is the difference between animating one row and reloading the table. Core Data also has a strict concurrency model. Managed objects belong to their contexts, and Apple's wording is blunt: do not pass managed object instances between queues, because doing so can corrupt data and terminate the app. Pass an NSManagedObjectID instead. Do background work in a background context, and pass identifiers or immutable values across architectural boundaries rather than the objects themselves. When the Queries Are the Hard Part: SQLite and GRDB For applications with complex queries, large datasets, full-text search, or extensive batch operations, direct access to SQLite can be a better fit. Operations three, four, and five are the ones that argue for it. Full-text search over place names is an FTS5 virtual table rather than a contains filter that walks every row. Expenses grouped by currency is a GROUP BY, evaluated by SQLite over rows it has already indexed, and it stays four lines long as conditions accumulate. Updating hundreds of records after a sync is one UPDATE ... WHERE inside one transaction. GRDB provides a Swift-friendly API on top of SQLite while preserving explicit control over: tables; indexes; foreign keys; transactions; migrations; SQL queries; observation of database changes. A database record can remain a regular Swift structure. In this example, identifiers are stored as text rather than as UUID values, which keeps the rows readable in any SQL console: import GRDB struct PlaceRecord: Codable, FetchableRecord, PersistableRecord { static let databaseTableName = "place" var id: String var tripID: String var name: String var latitude: Double var longitude: Double var isVisited: Bool } The corresponding query remains explicit: func fetchUnvisitedPlaces( tripID: String, from reader: any DatabaseReader ) async throws -> [PlaceRecord] { try await reader.read { db in try PlaceRecord .filter(Column("tripID") == tripID) .filter(Column("isVisited") == false) .order(Column("name")) .fetchAll(db) } } One behavioral detail is worth noting because it changed in GRDB 7. Asynchronous database accesses now respect task cancellation, whereas in version 6 a try await read { ... } operation would run to completion even after the surrounding Task was canceled. For a read that supports a screen the user can navigate away from, this determines whether the query is canceled or continues running after its result is no longer needed. Explicit control is also explicit responsibility. Nothing designs the schema, the indexes, the constraints, the migration scripts, or the transaction boundaries for you, and nothing converts a row into a domain model on your behalf. For teams comfortable with SQL, that is the point rather than the price. Do Not Pass Persistence Models Through the Entire App Whichever persistence framework you choose, it is useful to prevent storage-specific models from spreading through every layer of the application. A tightly coupled design might look like this: final class TripDetailsViewModel { private let trip: Trip init(trip: Trip) { self.trip = trip } } If Trip is a SwiftData model, the view model now depends directly on SwiftData's behavior. The same model soon turns up in networking code, background tasks, notification handlers, and synchronization services, which makes later changes harder and raises the risk of concurrency problems. A more isolated design uses domain values: struct TripDetails: Sendable, Equatable { let id: UUID let title: String let startDate: Date let endDate: Date let places: [PlaceDetails] } struct PlaceDetails: Sendable, Equatable { let id: UUID let name: String let latitude: Double let longitude: Double let isVisited: Bool } The application can then depend on a repository interface: protocol TripsRepository: Sendable { func upcomingTrips() async throws -> [TripDetails] func trip(id: UUID) async throws -> TripDetails? func save(_ trip: TripDetails) async throws func deleteTrip(id: UUID) async throws } The implementation may use SwiftData, Core Data, or GRDB internally, and the rest of the application does not need to know. The usual objection to this boundary is that it sacrifices live updates: @Query redraws a SwiftUI view when the store changes, whereas a repository returning plain values does not. That objection has been reasonable. In iOS 27, SwiftData adds ResultsObserver, which brings @Query-style fetching and observation to code outside SwiftUI views. A repository implementation is one natural place to use it, so the abstraction boundary and live updates are no longer mutually exclusive. When supporting older systems, the existing approach still works: expose an AsyncStream of domain values from the repository and let the implementation decide what triggers an update. This does not mean every line of persistence code needs an abstraction. The boundary earns its cost where it isolates something likely to change or something with a complicated lifecycle, which is what a database, a Keychain, and a file system all are. Concurrency: Return Values, Not Live Database Objects Many persistence issues do not appear when saving a single record. They appear when the UI, a background import, and a synchronization pass run at the same time: a user reads a trip on screen while a background task rewrites the same booking rows underneath it. A safer persistence boundary follows several rules: Persistence operations run inside their own context, queue, or actor. Related changes are saved as a transaction. The persistence layer returns immutable values or identifiers. UI code does not receive an object owned by a background context. Database-specific threading rules remain inside the persistence layer. Rules three and four are the same boundary seen from two sides. That boundary is where most of the trouble lives. I discuss the general shape of it in Mastering Swift Structured Concurrency, and the distance between playground examples and production apps in What Swift's Playground Won't Tell You About Concurrency. Persistence makes that distance expensive, because an object shared across an isolation boundary by accident is also an object the UI reads again and again. With SwiftData, a background importer can be isolated using a model actor: import Foundation import SwiftData struct RemoteTrip: Sendable { let id: UUID let title: String let startDate: Date let endDate: Date } @ModelActor actor TripImporter { func importTrips(_ items: [RemoteTrip]) throws { let incomingIDs = items.map(\.id) let descriptor = FetchDescriptor( predicate: #Predicate { trip in incomingIDs.contains(trip.id) } ) let existing = try modelContext.fetch(descriptor) let byID = Dictionary( existing.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) for item in items { if let storedTrip = byID[item.id] { storedTrip.title = item.title storedTrip.startDate = item.startDate storedTrip.endDate = item.endDate } else { modelContext.insert( Trip( id: item.id, title: item.title, startDate: item.startDate, endDate: item.endDate ) ) } } try modelContext.save() } } Three details in that listing are easy to get wrong. One fetch, not one per record. The obvious version builds a FetchDescriptor inside the for body and fetches once per incoming item. Hoisting the identifier into a local constant makes that version compile, and it will pass review, and it still turns one import into hundreds of round trips. Fetch the existing records once, index them by identifier in memory, then process the batch. #Predicate wants a local constant. The macro builds an expression tree that has to become a store query, and it accepts a captured value where it will not accept property access on a captured object. Moving the identifiers into incomingIDs is not a style preference: #Predicate will not evaluate items.map(\.id) for you, and reaching into a property of a captured object inside the predicate body does not run slowly, it fails to compile. The dictionary tolerates duplicates. uniquingKeysWith may look like defensive noise until you remember the CloudKit rules from earlier in this article: a synchronized store cannot enforce unique constraints, so two records with the same id represent a state that the schema can no longer forbid. Building the index with uniqueKeysWithValues instead would turn that state into a crash during a background import. Tolerating a duplicate is not the same as reconciling it: this loop keeps the first record and leaves the second one stale. Deciding which record should win belongs to the sync engine, not the importer. The listing is here for the boundary, not for any line of it. Background persistence work needs a clear isolation boundary and @ModelActor provides one: the macro generates init(modelContainer:) and the actor's executor, the ModelActor protocol provides modelContext, and the work performed through the actor is serialized. Code inside uses that context, and code outside never receives a live model object. One caveat is easy to miss and expensive to debug: where you create the actor decides where its work runs. A ModelContext created on the main queue configures itself as a main-queue context, so an importer instantiated from main-actor-isolated UI code can do its work on the main thread. No warning, no compile error, just a frame drop during import. That last part is reported in developer forums, not stated in Apple's documentation, so treat it as an implementation detail rather than a contract: construct the importer outside main-actor-isolated UI code, and confirm with Instruments where it actually runs. A detached task can help during isolated setup, but it is not the default answer: it drops the inherited actor context, priority, task-local values, and structured cancellation along with the isolation you were trying to escape. Plan Migrations Before the First Release The first database schema is almost never the final one. Suppose the first version of a booking stores a single price. A year later the product needs the original amount, the original currency, a converted amount, the rate used at the time of payment, and refund and tax information. Once the application has real users, that field cannot simply be replaced: the existing data has to be transformed. With GRDB, migrations can be declared explicitly: var migrator = DatabaseMigrator() migrator.registerMigration("createBooking") { db in try db.create(table: "booking") { table in table.column("id", .text) .primaryKey() table.column("title", .text) .notNull() table.column("price", .double) .notNull() } } migrator.registerMigration("addCurrency") { db in try db.alter(table: "booking") { table in table.add(column: "currencyCode", .text) .notNull() .defaults(to: "EUR") } } try migrator.migrate(dbQueue) Regardless of the framework, a migration strategy should follow a few principles: never modify a migration that has already shipped; test upgrades from more than one previous version; keep sample databases created by older app versions; validate relationships and transformed values; measure the duration of expensive migrations; log the migration stage when an error occurs. If the store is synchronized through CloudKit, the constraints become considerably tighter, and they are worth stating plainly because they are not obvious from the local API. Once a schema has been deployed to production it is effectively add-only. You can add fields and stop using old ones. You cannot remove a field or change its type, and you cannot change the identity of an existing one. Renaming deserves to be separated out, because two different operations get the same name. Renaming the Swift property while keeping the stored name is supported and cheap: @Attribute(originalName:) tells SwiftData that the property you now call title is stored under its old name, and no data moves. Changing the stored name is the other operation. To CloudKit that is a new field, and the old one stays in the production schema with its data inside, unread by anything. That is the version that turns a refactor into a data-loss bug, and it needs a deliberate transition rather than a rename. Local migrations let you fix a bad naming decision two releases later. Synced schemas do not. That asymmetry is another reason the CloudKit question belongs at the start of the project rather than in the middle. A useful integration test opens a database created by an old version of the app, fills it with realistic data, runs every current migration, and then checks the new schema, the record counts, and the relationships using the current application code. A test that creates a fresh, empty database instead does not represent a real user upgrading after several years. The Eight Operations, Answered The list at the top of this article was the argument, so it deserves an answer rather than a summary. Operation SwiftData Core Data GRDB 1, 2. Fetch trips, and the places of a trip @Query in the view NSFetchedResultsController, sectioned and animated a query plus observation 3. Search places by name a predicate that walks the rows a predicate that walks the rows an FTS5 virtual table 4. Expenses grouped by currency grouped in Swift after the fetch an aggregate fetch GROUP BY inside the store 5, 6. Update or mark hundreds of rows one object at a time NSBatchUpdateRequest one UPDATE ... WHERE 7. Delete a trip and everything under it a cascade deleteRule a cascade delete rule a cascading foreign key 8. Import a large response @ModelActor on its own context a background context one transaction Read that as a map of where the work happens, not as a scoreboard. Every row is achievable in every column. What differs is whether the store does the work or your code does, and how much of the result has to travel through memory on the way. Most applications are one column plus an exception, and the exception is the thing worth designing for. Conclusion Everything above assumes the domain data needs a persistence framework at all. Sometimes it does not: a small app may want nothing more than UserDefaults for preferences, Keychain for one credential, and a JSON document in Application Support. Adding a framework there buys code, tests, and migrations without buying a benefit. When one is needed, none of the three is the right answer on its own. SwiftData can remove a great deal of infrastructure code from a modern SwiftUI application, provided the model was designed for CloudKit on the first day rather than the day sync was requested. Core Data can preserve years of stable architecture, and it still answers the batch and sectioning problems that SwiftData leaves to you. GRDB gives direct and predictable control over SQLite, which is what complex queries, full-text search, and large batch updates eventually demand. The choice follows from that table, from the honest answer to whether two devices must agree, and from how the schema will change after release. It does not follow from which model declaration reads better. And it only applies to one of the six categories of data an app stores. The other five were settled before any of this came up, which is the subject of the first article in this pair.
SwiftData, Core Data, or GRDB: Choose by the Queries You Actually Run
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.