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:

  • what data the app will store;
  • how long it should persist;
  • whether it is sensitive;
  • whether it can be downloaded again;
  • whether it must remain available offline;
  • whether it must be synchronized between devices.

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.

The Example App

Imagine an app called TripBoard.

It allows users to:

  • create trips;
  • add cities and places;
  • store booking information;
  • attach tickets and photos;
  • use the app without an internet connection;
  • synchronize trips between Apple devices.

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.

App Preferences: UserDefaults

UserDefaults is a good fit for small, non-sensitive application preferences.

Typical examples include:

  • the selected theme;
  • the preferred currency;
  • whether onboarding has been completed;
  • the last selected tab;
  • map display settings.

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.

Tokens and Credentials: Keychain

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.

Domain Entities: Start with Queries, Not Frameworks

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.

Store Large Attachments as Files

Suppose a user attaches three files to a booking:

  • a 2 MB airline ticket;
  • a 6 MB hotel photo;
  • a 4 MB map screenshot.

All of these files could technically be stored as binary database fields.

That does not mean they should be.

A common design is:

  • store metadata in the database;
  • store the actual file in the application container;
  • connect them using a stable identifier or relative path.

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:

  • normal database queries do not load large binary values;
  • files are read only when required;
  • previews can be cached independently;
  • backup behavior can be controlled explicitly;
  • temporary files can be cleaned without touching domain records.

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 pending deletion queue;
  • retry logic;
  • periodic orphan file cleanup;
  • consistency checks during launch or maintenance tasks.

Decide Where the Container Lives Before You Ship

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 Is Not User Data

Cache data can be deleted without permanently losing information.

This definition is simple, but it prevents many design mistakes.

For example:

  • an original PDF ticket is user data;
  • a thumbnail generated from that PDF is cached data;
  • a user-written note is permanent data;
  • a downloaded list of airports may be cached data;
  • an unsynchronized local edit is not cache.

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.

When the Store Will Not Open

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.

Synchronization Is a Separate System

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:

  • What is the stable identifier of a record?
  • How do we know which version is newer?
  • What happens when the same record is changed on two devices?
  • How are offline changes stored?
  • How are failed uploads retried?
  • How are deletions represented before they reach the server?
  • How are conflicts resolved?

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.

What iOS 27 Changes Here

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:

  • the user is not signed into iCloud;
  • iCloud Drive is disabled;
  • the same record is edited on two devices;
  • one device stays offline for several weeks;
  • the application is deleted and reinstalled;
  • the schema changes between app versions;
  • a partial sync operation fails.

It is a separate subsystem with its own failure modes, and it does not replace migrations, conflict handling, or backups.

The Resulting TripBoard Architecture

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:

  • preferences remain preferences;
  • secrets are stored securely;
  • domain entities can be queried and migrated;
  • large files do not make normal database queries heavier;
  • caches can be deleted safely;
  • synchronization logic does not leak into the UI.

Conclusion

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.