A Complete Guide to iOS Data Persistence

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

UserDefaults

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.

Keychain

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.

File System + Codable

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

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.

Defining a model

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
  }
}

Setting up the container

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.

CRUD

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)

SwiftUI integration

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)
    }
  }
}

SwiftData 2.0 (iOS 18+)

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
}

Concurrency with @ModelActor

@ModelActor
actor DataHandler {
  func importRecipes() throws {
    // self.modelContext is already on the correct executor
  }
}

Migrations

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

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

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 (C API / SQLite.swift)

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

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.

NSUbiquitousKeyValueStore

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.

Comparison

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