Every app stores data. The right tool depends on the data’s size, structure, sensitivity, and whether it needs to sync across devices.
| Option | Best for | Size limit |
|---|---|---|
| UserDefaults | Preferences, settings, flags | Practical: small (<100KB) |
| Keychain | Secrets, tokens, passwords | ~per-item |
| File System + Codable | JSON/plist documents, user-generated files | Disk space |
| SwiftData | Complex object graphs, SwiftUI apps (iOS 17+) | Disk space |
| CoreData | Complex migrations, pre-iOS 17 support | Disk space |
| GRDB / SQLite | Relational data, queries, migrations | Disk space |
| Realm | Legacy projects only | Disk space |
| NSUbiquitousKeyValueStore | Small cross-device key-value sync (iCloud) | 1MB total |
Store small key-value pairs for app settings. Synchronous, thread-safe for reads, persists to a plist in the app’s Library.
let defaults = UserDefaults.standard
defaults.set(22, forKey: "userAge")
defaults.set(true, forKey: "darkModeEnabled")
defaults.set(["Apple", "Banana"], forKey: "favoriteFruits")Read back with typed accessors:
let age = defaults.integer(forKey: "userAge")
let dark = defaults.bool(forKey: "darkModeEnabled")
let fruits = defaults.array(forKey: "favoriteFruits")When to use: App preferences, onboarding flags, cached UI state. Not for sensitive data or large blobs.
Store small sensitive values — tokens, passwords, keys. Data is encrypted at rest by the Secure Enclave.
import Security
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "auth_token",
kSecValueData as String: Data("secret".utf8),
]
SecItemAdd(query as CFDictionary, nil)Read back:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "auth_token",
kSecReturnData as String: true,
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)For biometric-protected items, use SecAccessControlCreateWithFlags with .biometryCurrentSet and pass an LAContext. See the biometric authentication guide for details.
Keychain data is scoped to the provisioning profile. A different team or bundle ID cannot read it. Re-signing with a different profile orphans existing items.
When to use: API tokens, refresh tokens, passwords, encryption keys.
The most straightforward way to persist structured data is reading and writing Codable models to the app’s Documents directory.
struct User: Codable {
let id: String
let name: String
}
func save(_ users: [User]) throws {
let url = URL.documentsDirectory.appending(path: "users.json")
let data = try JSONEncoder().encode(users)
try data.write(to: url, options: .atomic)
}
func load() throws -> [User] {
let url = URL.documentsDirectory.appending(path: "users.json")
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([User].self, from: data)
}App container directories:
| Directory | Contents | Backed up |
|---|---|---|
Documents/ |
User-generated content | Yes (iCloud) |
Library/ |
App data not user-facing | Yes, but not Caches/ |
tmp/ |
Temporary files | No |
Library/Caches/ |
Cacheable data | No |
Use .atomic write option to prevent data corruption from partial writes.
When to use: Simple document-based apps, exporting/importing data, JSON/plist config files, caching API responses.
SwiftData (iOS 17+) is Apple’s modern persistence framework, built on top of Core Data but with a Swift-native API. It uses the @Model macro to define the schema and provides @Query for SwiftUI integration.
import SwiftData
@Model
final class Recipe {
@Attribute(.unique) var id: UUID
var name: String
var servings: Int
@Relationship(inverse: \Ingredient.recipe) var ingredients: [Ingredient]
init(name: String, servings: Int) {
self.id = UUID()
self.name = name
self.servings = servings
self.ingredients = []
}
}
@Model
final class Ingredient {
var name: String
var recipe: Recipe?
init(name: String) {
self.name = name
}
}let config = ModelConfiguration(isStoredInMemoryOnly: false)
let container = try ModelContainer(for: Recipe.self, configurations: config)For iCloud sync, add cloudKitDatabase: .automatic to the configuration and enable the CloudKit entitlement.
let context = container.mainContext
// Create
let recipe = Recipe(name: "Soup", servings: 4)
context.insert(recipe)
// Read
let descriptor = FetchDescriptor<Recipe>(
predicate: #Predicate { $0.servings > 2 },
sortBy: [SortDescriptor(\.name)]
)
let results = try context.fetch(descriptor)
// Update
recipe.servings = 6
// Delete
context.delete(recipe)struct RecipeListView: View {
@Query(filter: #Predicate<Recipe> { $0.servings > 2 })
var recipes: [Recipe]
var body: some View {
List(recipes, id: \.id) { recipe in
Text(recipe.name)
}
}
}| Feature | Description |
|---|---|
#Index macro |
Declare database indexes on model properties |
#Unique macro |
Enforce uniqueness constraints across model instances |
CustomStore |
Replace the default SQLite backend with a custom implementation |
| Collection predicates | contains(where:), allSatisfy, and other collection operations in #Predicate |
@Model
final class User {
#Unique<User>([\.email])
#Index<User>([\.name], [\.email])
var name: String
var email: String
}@ModelActor
actor DataHandler {
func importRecipes() throws {
// self.modelContext is already on the correct executor
}
}SwiftData handles lightweight migrations automatically. For complex transformations, use SchemaMigrationPlan:
enum RecipeMigrationPlan: SchemaMigrationPlan {
static var stages: [MigrationStage] {
[MigrationStage.custom(fromVersion: SchemaV1.self, toVersion: SchemaV2.self) { context in
// manual transform
}]
}
}When to use: All new projects targeting iOS 17+. SwiftUI apps benefit most from the seamless @Query integration. For iOS 18+, the feature gap with CoreData is minimal.
CoreData is the mature predecessor to SwiftData. It remains the right choice when you need backward compatibility (iOS 16 or earlier) or have an existing codebase with complex managed object models.
import CoreData
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { _, error in
if let error { print(error) }
}
let context = container.viewContext
let request = NSFetchRequest<NSManagedObject>(entityName: "Recipe")
request.predicate = NSPredicate(format: "servings > %d", 2)
let results = try context.fetch(request)CoreData supports heavyweight migrations, complicated relationship graphs, and features like NSFetchedResultsController that SwiftData has not fully replicated. However, the API is significantly more verbose than SwiftData.
When to use: Existing CoreData codebases, iOS 16 and earlier support, complex migrations not supported by SwiftData’s SchemaMigrationPlan.
GRDB is a pure-Swift SQLite toolkit. It provides a type-safe query interface, record-based model layer, and migrations. It is the most popular SQLite wrapper in the Swift community.
import GRDB
let dbQueue = try DatabaseQueue(path: "/path/to/database.sqlite")
try dbQueue.write { db in
try db.create(table: "user") { t in
t.autoIncrementedPrimaryKey("id")
t.column("name", .text)
t.column("email", .text).unique()
}
try db.execute(sql: "INSERT INTO user (name, email) VALUES (?, ?)",
arguments: ["Alice", "alice@example.com"])
}
let users = try dbQueue.read { db in
try Row.fetchAll(db, sql: "SELECT * FROM user")
}GRDB supports migrations via DatabaseMigrator, Combine publishers via ValueObservation, and integrates with SwiftUI via @Query-like observation.
When to use: Complex relational queries, full-text search, SQL-based reporting, or when you want direct SQL access with a Swift-friendly API.
SQLite is available on iOS without any dependencies. Use SQLite.swift for a typed Swift layer:
import SQLite
let db = try Connection("path/to/db.sqlite3")
let users = Table("users")
let id = Expression<Int64>("id")
let name = Expression<String?>("name")
try db.run(users.create { t in
t.column(id, primaryKey: true)
t.column(name)
})
let insert = users.insert(name <- "Alice")
try db.run(insert)When to use: When you need SQLite directly without a framework dependency. GRDB is generally recommended over SQLite.swift for new projects.
Realm was a popular mobile database, but the company (MongoDB) shifted focus to Atlas Device Sync. The Realm Swift SDK is in maintenance mode.
When to use: Maintaining existing Realm-based projects. Do not adopt Realm for new projects.
iCloud-synced key-value storage for small pieces of data across a user’s devices.
let store = NSUbiquitousKeyValueStore.default
store.set("value", forKey: "key")
store.synchronize()Limits: 1MB total, 1MB per key, 1024 keys max. Requires iCloud entitlement and App Store distribution. Always pair with a local UserDefaults fallback.
When to use: Cross-device sync for preferences (theme, bookmarks), not a replacement for a database.
| SwiftData | CoreData | GRDB | UserDefaults | File + Codable | |
|---|---|---|---|---|---|
| iOS minimum | 17.0 | 3.0 | 13.0 | 2.0 | 16.0 |
| Schema | @Model macro | .xcdatamodeld editor | Swift types + migrations | N/A | Codable |
| Queries | #Predicate, FetchDescriptor | NSPredicate, NSFetchRequest | SQL, GRDB query builder | Key lookup | Load entire file |
| SwiftUI | @Query, @ModelActor | @FetchRequest | ValueObservation, @Query | @AppStorage | Manual |
| Migrations | Automatic + SchemaMigrationPlan | Lightweight + Heavyweight | DatabaseMigrator | N/A | Manual versioning |
| Concurrency | @ModelActor | performBackgroundTask | DatabasePool, read/write | Synchronous | DispatchQueue |
| iCloud | Built-in (automatic) | NSPersistentCloudKitContainer | Manual (CKDatabase) | NSUbiquitousKeyValueStore | Manual |
| Diff | Best for new SwiftUI apps | Best for legacy/complex | Best for relational/SQL | Best for settings | Best for documents |