When a new iOS project needs local data storage, the discussion often starts with questions like these:
Is SwiftData production-ready?
Should we use Core Data instead?
Would GRDB give us more control?
These are reasonable questions, but they should not be the team's starting point.
Before choosing a framework, we need to understand:
A real application rarely has a single type of data. UI preferences, authentication tokens, domain models, user-generated files, and temporary network responses all have different lifecycles.
Putting everything into one database because it is technically possible has a cost, and the cost never arrives on the day you make the decision.
It arrives when the product asks for sync and the schema will not allow it. It arrives when a 6 MB photo sits in a table that every screen queries.
It arrives when a token you kept in the wrong place survives a device restore onto someone else's hardware.
This article designs the storage architecture of a travel-planning app, category by category, and ends with the resulting layout. Choosing the database for the one category that needs a database is a separate job, and it has its own article.
The code and API behavior described here target iOS 27, which is in beta as of July 2026.
When an API is too new to rely on while supporting older systems, I say so where it appears rather than hiding it in a footnote.
Imagine an app called TripBoard.
It allows users to:
At first, this may sound like a simple database of trips. Once we break the requirements down, though, several fundamentally different data categories appear.
|
Data |
Examples |
Main requirements |
|---|---|---|
|
Preferences |
Currency, theme, map style |
Small values, fast access |
|
Secrets |
Access and refresh tokens |
Secure storage |
|
Domain entities |
Trips, places, bookings |
Queries, relationships, migrations |
|
User files |
PDF tickets, photos |
Large objects, file access |
|
Temporary cache |
Thumbnails, API responses |
Safe to delete |
|
Sync metadata |
Remote version, upload state |
Reliability and transactional updates |
Six categories, six sets of requirements, and no single technology that serves all of them well. What TripBoard needs is a storage system, not a database.
UserDefaults is a good fit for small, non-sensitive application preferences.
Typical examples include:
enum AppPreferences {
private static var defaults: UserDefaults { .standard }
private enum Key {
static let preferredCurrency = "preferredCurrency"
static let showsVisitedPlaces = "showsVisitedPlaces"
}
static var preferredCurrency: String {
get {
defaults.string(forKey: Key.preferredCurrency) ?? "EUR"
}
set {
defaults.set(newValue, forKey: Key.preferredCurrency)
}
}
static var showsVisitedPlaces: Bool {
get {
defaults.object(forKey: Key.showsVisitedPlaces) as? Bool ?? true
}
set {
defaults.set(newValue, forKey: Key.showsVisitedPlaces)
}
}
}
The question is not whether a value can be encoded and stored in UserDefaults. It is whether that value is actually a preference.
A list of the five most recent searches might still be considered UI state. A user's complete search history is a different thing. It may require sorting, pagination, deletion, filtering, and synchronization.
At that point, it becomes part of the application's data model.
UserDefaults should not be treated as a lightweight database simply because it is convenient.
There is a second reason to keep the boundary tight. UserDefaults stores its contents on disk in an unencrypted format, and the system includes that database in device backups.
That is entirely appropriate for a currency code or a theme, but not for secrets or other sensitive data. That distinction leads directly to the next section.
An authentication token should not be stored next to the dark-mode preference.
Passwords, tokens, cryptographic keys, and other small secrets belong in Keychain.
The application's business layer should not need to know about Security framework functions such as SecItemAdd or SecItemCopyMatching. Instead, it can depend on a small abstraction:
protocol CredentialsStore: Sendable {
func saveAccessToken(_ token: String) throws
func accessToken() throws -> String?
func deleteCredentials() throws
}
A concrete implementation may use a Keychain client internally:
final class KeychainCredentialsStore: CredentialsStore {
private let keychain: KeychainClient
init(keychain: KeychainClient) {
self.keychain = keychain
}
func saveAccessToken(_ token: String) throws {
try keychain.set(Data(token.utf8), account: "tripboard.access-token")
}
func accessToken() throws -> String? {
guard let data = try keychain.get(
account: "tripboard.access-token"
) else {
return nil
}
return String(data: data, encoding: .utf8)
}
func deleteCredentials() throws {
try keychain.delete(account: "tripboard.access-token")
}
}
KeychainClient here is assumed to be a thin wrapper over Keychain Services that turns SecItemAdd, SecItemCopyMatching, and SecItemDelete into throwing Swift functions. Whether you write those roughly forty lines yourself or use a dependency is not the important decision here.
The abstraction itself is the dependency inversion argument applied to storage: the business layer depends on what it needs, not on the Security framework.
The interesting decision is the one that wrapper must not hide: accessibility.
Every Keychain item carries a kSecAttrAccessible value that determines when the item can be read, and the default may be more permissive than a team expects.
A session token needed only while the user is actively using the app belongs in kSecAttrAccessibleWhenUnlockedThisDeviceOnly. A credential that a background refresh task must read belongs in kSecAttrAccessibleAfterFirstUnlock.
The ThisDeviceOnly suffix affects migration to another device. Items using one of these accessibility classes are unavailable after a backup is restored to different hardware, whereas items without the suffix can migrate through an encrypted backup.
This is a security and product decision that should be made deliberately rather than inherited from a default.
Getting this wrong produces neither a crash nor a warning. Instead, the app may quietly store a long-lived token in a form that survives a device restore. Choose the accessibility class deliberately, and document the reason.
Beyond that, the abstraction provides two benefits.
First, it keeps platform-specific security code outside the rest of the application.
Second, it makes authentication logic easier to test. A unit test can use an in-memory implementation of CredentialsStore instead of writing to the real Keychain.
Trips, places, and bookings require a more capable persistence solution.
This is the one category that needs a real database, and it is also the only one where the framework question is worth asking at all.
It is worth asking last. Which of SwiftData, Core Data, or SQLite fits depends on the operations this data has to support, and the rest of the app has to be classified first, because two of the remaining categories do not belong in a database in the first place.
A companion article works through that choice in detail. This one finishes the classification.
Suppose a user attaches three files to a booking:
All of these files could technically be stored as binary database fields.
That does not mean they should be.
A common design is:
struct AttachmentMetadata: Sendable {
let id: UUID
let bookingID: UUID
let filename: String
let relativePath: String
let contentType: String
let createdAt: Date
}
A simple file store may look like this:
actor AttachmentFileStore {
private let fileManager: FileManager
private let directoryURL: URL
init(fileManager: FileManager = .default) throws {
self.fileManager = fileManager
let applicationSupport =
try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
directoryURL = applicationSupport
.appendingPathComponent("TripBoardAttachments", isDirectory: true)
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true
)
}
func save(data: Data, id: UUID, fileExtension: String) throws -> String {
let filename =
"\(id.uuidString).\(fileExtension)"
let fileURL = directoryURL
.appendingPathComponent(filename)
try data.write(to: fileURL, options: .atomic)
return filename
}
func data(relativePath: String) throws -> Data {
let fileURL = directoryURL
.appendingPathComponent(relativePath)
return try Data(contentsOf: fileURL, options: .mappedIfSafe)
}
func delete(relativePath: String) throws {
let fileURL = directoryURL
.appendingPathComponent(relativePath)
guard fileManager.fileExists(atPath: fileURL.path) else {
return
}
try fileManager.removeItem(at: fileURL)
}
}
Two details in that listing are deliberate.
.mappedIfSafe lets Foundation use a memory-mapped representation when it considers mapping appropriate. Treat it as an optimization hint rather than a guarantee: it can reduce copying and peak physical memory for larger files, and it can also decline.
One thing it does not do is make your own API lazy. The mapping is set up during the Data(contentsOf:) call itself. What becomes lazy is the paging: the system pulls pages in as they are touched, rather than materializing six megabytes up front.
.atomic writes to a temporary file and renames it into place, which keeps a partially written ticket from ever becoming visible under its final name.
What it does not do is make the file operation transactional with the database. The app can still write the file and die before inserting the row, or commit the row and die before the rename. Missing and orphaned attachments remain a separate problem, handled below.
This design has several advantages:
The fourth point deserves special attention because Application Support is included in backups by default. Attachments created by the user belong there and should be backed up.
Data that the app can fetch again generally should not be included, and backup exclusion is set per file rather than globally:
func excludeFromBackup(fileURL: URL) throws {
var url = fileURL
var values = URLResourceValues()
values.isExcludedFromBackup = true
try url.setResourceValues(values)
}
Apple's guidance here is practical rather than stylistic: purgeable data belongs in Caches or tmp, which backups already skip, and files the app can download again should be excluded.
Two caveats come from the same page. The flag is a request to the system rather than a guarantee.
And files the user imported, arbitrary PDFs and ebooks and comics, should not be excluded at all, because a restored device may leave them impossible to recreate.
There is a second decision hiding in the same place, and it is the Keychain question asked about files.
A PDF ticket carries a name, a flight number, and a seat.
The Keychain section treated accessibility as something to choose deliberately. Files have the same control: FileProtectionType decides whether a file can be read while the device is locked, and Data.WritingOptions carries it directly, so the write from the previous listing can ask for it inline.
try data.write(to: fileURL, options: [.atomic, .completeFileProtection])
.completeFileProtection means the file cannot be read or written while the device is locked. That is the right default for a boarding pass.
It is the wrong default for anything a background task has to touch. There .completeFileProtectionUntilFirstUserAuthentication is the workable middle: encrypted on disk, unreadable until the device has been unlocked once after boot.
The same choice applies to the store itself, and it is the more consequential one: a store that cannot be opened while the device is locked fails a background refresh rather than merely returning nothing.
It is also the one to configure through the framework rather than by hand. A SQLite store in WAL mode is not one file but three, the main store plus its -wal and -shm companions, so setting an attribute on the .sqlite file alone proves nothing. Core Data exposes NSPersistentStoreFileProtectionKey as a store option for exactly this reason. Set it where the framework provides it, then verify the sidecar files.
Pick it deliberately. The default is not a decision anyone made about your data.
The tradeoff is consistency.
When an attachment is deleted, both the database record and the physical file must eventually be removed.
Because database transactions generally cannot include file-system operations, the app may need:
A travel app will eventually want a widget showing the next trip, or a share extension that saves a ticket straight from Mail. Both run in separate processes, and neither can read the app's private container.
The fix is an App Group and containerURL(forSecurityApplicationGroupIdentifier:), which returns a directory both processes can open. It is a small change on day one.
After release it is a file migration. It has to move the store and every attachment on first launch, survive being interrupted halfway, and leave the old location clean. The decision is cheap while nothing has shipped, and it is only ever made once.
Cache data can be deleted without permanently losing information.
This definition is simple, but it prevents many design mistakes.
For example:
In TripBoard, generated thumbnails can be stored in the Caches directory:
import os
actor ThumbnailCache {
private let directoryURL: URL
private let logger = Logger(
subsystem: "com.tripboard.storage",
category: "ThumbnailCache"
)
init(fileManager: FileManager = .default) throws {
let cachesURL =
try fileManager.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
directoryURL = cachesURL
.appendingPathComponent("AttachmentThumbnails", isDirectory: true)
try fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true
)
}
func thumbnail(for id: UUID) -> Data? {
do {
return try Data(
contentsOf: url(for: id),
options: .mappedIfSafe
)
} catch CocoaError.fileReadNoSuchFile {
return nil
} catch {
logger.error("Thumbnail read failed: \(error)")
return nil
}
}
func store(_ data: Data, for id: UUID) throws {
try data.write(to: url(for: id), options: .atomic)
}
private func url(for id: UUID) -> URL {
directoryURL.appendingPathComponent("\(id.uuidString).jpg")
}
}
Note the return type, and note which failure gets the silent treatment.
A missing file is not an error condition in this design. It is the normal state after the system reclaims disk space, so fileReadNoSuchFile returns nil and nobody is told.
Everything else, a permission failure, a damaged file, an I/O error, a file whose protection class will not let you open it, also returns nil, because the caller genuinely cannot act on any of them. But it gets logged, because you can.
A bare try? would have collapsed all of that into one case, in an article whose argument is that different failures are different.
If the function starts throwing instead, every call site must handle a failure that is not really a failure, and the cache's disposable nature becomes harder to preserve.
The cache is an actor for the same reason the importer later is: two screens can ask for the same thumbnail at the same time, and the isolation has to be somewhere.
Business logic should never assume that cached data will always exist.
If the cache disappears, the application must be able to regenerate it from the original file or download it again.
The system can delete the contents of the Caches directory when the device is very low on disk space, although never while the app is running, and it does not tell the app afterward.
Within a session, an entry will not disappear underneath you. Across launches, you cannot assume it survived.
Every listing so far assumes the store opens. Sometimes it does not: a migration fails at launch, the file is corrupt, the disk is full, or a background write was cut off by a reboot.
This needs a decided policy rather than whatever the container's initializer happens to do, and the classification already answers it.
Cache can be deleted without asking anyone. Rebuilding a thumbnail directory costs a network request.
Domain entities cannot be deleted on the app's own initiative. The user typed them, and nothing else has a copy unless sync has already run at least once.
Secrets sit in Keychain and are unaffected either way, which is one more argument for keeping them there.
So the policy usually splits: delete and rebuild the caches, and for the user's own data, fail loudly rather than quietly.
Show the error and preserve the original store. A crash at launch tells the user nothing and tells you nothing either.
If you want diagnostics out of it, make that an explicit opt-in and export something redacted. The raw store holds this person's trips, bookings, and addresses, and "send us your database" is not a support flow you want to have built.
The one thing not to do is catch the error, create an empty store, and carry on. The app opens, the trips are gone, and the sync engine that comes along later reads that empty store as a set of deletions to push.
Adding CloudKit or a custom backend does not magically turn a local database into a reliable synchronization engine.
The application still needs answers to several questions:
A locally stored place may include synchronization metadata:
enum SyncState: String, Codable, Sendable {
case synchronized
case pendingUpload
case pendingDeletion
case failed
}
struct StoredPlace: Sendable {
let id: UUID
var name: String
var updatedAt: Date
var remoteVersion: Int?
var syncState: SyncState
}
A local change is first persisted on the device:
func rename(_ place: inout StoredPlace, to newName: String, now: Date) {
place.name = newName
place.updatedAt = now
place.syncState = .pendingUpload
}
After the server accepts the update:
func acknowledgeUpload(_ place: inout StoredPlace, version: Int) {
place.remoteVersion = version
place.syncState = .synchronized
}
StoredPlace is a value type on purpose. Mutating it changes a copy, and that copy must be written back to the store explicitly.
This requires more code than mutating a managed object in place, but it provides exactly the distinction a sync engine needs: the difference between "changed in memory" and "committed."
Passing the current time in rather than calling Date() inside the function applies the same principle to testing. A sync engine is largely ordering logic, and that ordering is difficult to test against an implicit clock.
Metadata like this helps keep the application usable without a network connection and gives the sync engine enough information to retry failed operations later.
Storing SyncState on a SwiftData model and filtering by it has historically been awkward because SwiftData predicates could not evaluate the enum directly. The usual workaround is a shadow String property whose only purpose is to make the query expressible, and it remains the portable solution.
iOS 27 addresses the other half of the problem: composing the query itself. Foundation adds Predicate(all:) and Predicate(any:), which create a compound predicate from a collection of smaller predicates.
This is useful when a sync engine builds a filter from the states it currently cares about instead of defining a separate predicate for every combination.
SwiftData also adds HistoryObserver, built on persistent history, which reports when new history transactions are available. It can be filtered by model type and by transaction author.
The author filter is an allowlist, not an exclusion list: you name the authors you want to hear about. In a sync engine that is how you watch the writes your own app made locally, so you know what still has to be uploaded.
Every API described in this subsection requires iOS 27 and is still in beta as of July 2026. Treat these APIs as a reason to revisit the design rather than as something to depend on immediately, particularly if the app must support older systems.
Even when a framework provides automatic CloudKit integration, the application should still test scenarios such as:
It is a separate subsystem with its own failure modes, and it does not replace migrations, conflict handling, or backups.
After classifying the data, the TripBoard storage architecture may look like this:
UserDefaults
├── selected currency
├── application theme
└── map display preferences
Keychain
├── access token
├── refresh token
└── local cryptographic key
SwiftData / Core Data / GRDB
├── trips
├── places
├── bookings
├── attachment metadata
└── synchronization state
Application Support
├── PDF tickets
├── user photos
└── exported documents
Caches
├── thumbnails
├── temporary map images
└── recoverable API responses
This is not overengineering.
Each technology has one responsibility you can state in a sentence:
Choosing storage for an iOS application is not a competition between SwiftData, Core Data, and SQLite.
The first step is to classify the data, and only then to pick a mechanism for each category.
Five of the six categories in TripBoard were placed without naming a persistence framework at all. Preferences went to UserDefaults, secrets to Keychain, attachments to the file system, thumbnails to a cache the system is allowed to delete, and sync state to a small set of fields that exist only to make retries possible.
The sixth category, the domain entities, is the one that needs a database. Which one it needs depends on the queries it has to answer, and that is the subject of the companion article.
A good storage architecture is not defined by how little code is required to save one object.
It is defined by how predictably the application behaves during poor network conditions, background updates, schema changes, failed synchronization, and several years of real-world use.