A Complete Guide to iOS Data Persistence

Updated Mar 18, 2024#ios#swift#persistence

One of the things a developer has to consider when creating an application is how to store this data in a way that leads to the best experience for the user.

In this article, we are going to look at persistence options for iOS, each of these has its own pros and cons. There’s no silver bullet for data persistence. As the developer, it’s up to you to determine which option surpasses the others based on your app’s requirements.

Bundles

Apple uses bundles to represent apps, frameworks, plug-ins, and many other specific types of content. Bundles organize their contained resources into well-defined subdirectories, you can access a bundle’s resources without knowing the structure of the bundle.

That main bundle can be accessed using Bundle.main, which lets us retrieve any resource file that was included within our main app target, such as a bundled JSON file.

if let url = Bundle.main.url(forResource: "example",withExtension: "json"), let data = try Data(contentsOf: url) {
  // decode and use data
}

When you build your Swift package, Xcode treats each target as a Swift module. If a target includes resources, Xcode creates a resource bundle and an internal static extension on Bundle to access it for each module. Use the extension to locate package resources.

Always use Bundle.module when you access resources. A package shouldn’t make assumptions about the exact location of a resource. If you want to make a package resource available to apps that depend on your Swift package, declare a public constant for it.

let settingsURL = Bundle.module.url(forResource: "settings", withExtension: "plist")

Learn more -> developer.apple.com

UserDefaults

UserDefaults are meant to be used to store small pieces of data which persist across app launches. It is very common to use UserDefaults to store app settings or user preferences. UserDefaults lets you store key-value pairs, where a key is always a String and value can be one of the following data types: Data, String, Number, Date, Array or Dictionary.

UserDefaults saves its data in a local plists file on disk. UserDefaults are persisted for backups and restores. Currently there is no size limit for data on platforms other than tvOS (which is 1MB).

let defaults = UserDefaults.standard

defaults.set(22, forKey: "userAge")
defaults.set(true, forKey: "darkModeEnabled")
defaults.set(["Apple", "Banana", "Mango"], forKey: "favoriteFruits")
defaults.set(["WiFi": true, "Bluetooth": false], forKey: "toggleStates")

let userAge = defaults.integer(forKey: "userAge")
let darkModeEnabled = defaults.bool(forKey: "darkModeEnabled")
let favoriteFruits = defaults.array(forKey: "favoriteFruits")
let toggleStates = defaults.dictionary(forKey: "toggleStates")

NSUbiquitousKeyValueStore

An iCloud-based container of key-value pairs you use to share data among instances of your app running on a user’s connected devices.

Changes your app writes to the key-value store object are initially held in memory, then written to disk by the system at appropriate times. If you write to the key-value store object when the user is not signed into an iCloud account, the data is stored locally until the next synchronization opportunity. When the user signs into an iCloud account, the system automatically reconciles your local, on-disk keys and values with those on the iCloud server.

The total amount of space available in your app’s key-value store, for a given user, is 1 MB. There is a per-key value size limit of 1 MB, and a maximum of 1024 keys. If you attempt to write data that exceeds these quotas, the write attempt fails and no change is made to your iCloud key-value storage.

To use this class, you must distribute your app through the App Store or Mac App Store, and you must request the com.apple.developer.ubiquity-kvstore-identifier entitlement in your Xcode project.

The key-value store is not a replacement for NSUserDefaults or other local techniques for saving the same data. The purpose of the key-value store is to share data between apps, but if iCloud is not enabled or is not available on a given device, you still will want to keep a local copy of the data.

It is important to keep your NSUserDefaults and NSUbiquitousKeyValueStore values in sync. It helps to only update them from a method that updates them both.

Keychain

Apple’s Keychain Services is a mechanism for storing small, sensitive data such as passwords, encryption keys or user tokens in a secure and protected manner. You should not store this kind of data in UserDefaults, even if iOS has made it harder to access that data for normal users in the latest versions.

let keychainItemQuery = [
  kSecValueData: "mypassword".data(using: .utf8)!,
  kSecClass: kSecClassGenericPassword
] as CFDictionary

let status = SecItemAdd(keychainItemQuery, nil)
print("Operation finished with status: \(status)")

Please note that the keychain is tied to the developer provisioning profile used to sign the app and its bundle ID. If either of these change, the data becomes inaccessible.

The keychain APIs are low-level and very old, consider using this simple Swift wrapper for Keychain instead.

Learn more -> andyibanez.com

Keyed archives

NSKeyedArchiver and NSKeyedUnarchiver provide a convenient API to read / write objects directly to / from disk.

Assume you have class Book conforms to NSCoding:

class Book: NSObject, NSCoding { 
  ///  
}

Using with File System:

NSKeyedArchiver.archiveRootObject(books, toFile: "/path/to/archive")
let books = NSKeyedUnarchiver.unarchiveObjectWithFile("/path/to/archive") as? [Book]

Using with NSUserDefaults:

let data = NSKeyedArchiver.archivedDataWithRootObject(books)
NSUserDefaults.standardUserDefaults().setObject(data, forKey: "books")

if let data = NSUserDefaults.standardUserDefaults().objectForKey("books") as? NSData {
  let books = NSKeyedUnarchiver.unarchiveObjectWithData(data)
}

Learn more -> developer.apple.com, nshipster.com

CoreData

Use Core Data to save your application’s permanent data for offline use, to cache temporary data, and to add undo functionality to your app on a single device. To sync data across multiple devices in a single iCloud account, Core Data automatically mirrors your schema to a CloudKit container.

  • NSManagedObjectModel represents your app’s model file describing your app’s types, properties, and relationships.
  • NSManagedObjectContext tracks changes to instances of your app’s types.
  • NSPersistentStoreCoordinator saves and fetches instances of your app’s types from stores.
  • NSPersistentContainer sets up the model, context, and store coordinator all at once.

Learn more -> developer.apple.com, avanderlee.com

SQLite

SQLite is available by default on iOS. In fact, if you’ve used Core Data before, you’ve already used SQLite. Core Data is just a layer on top of SQLite that provides a more convenient API.

SQLite has some advantages:

  • Shipped with iOS, it adds no overhead to your app’s bundle.
  • SQLite released version 1.0 in August 2000, so it’s tried and tested.
  • It’s well-maintained with frequent releases.
  • It uses a query language that’s familiar to database developers.
  • Cross-platform.
  • Open-source.

You can interact with SQLite using C APIs or SQLite.swift, which is a type-safe Swift-language layer over SQLite3, and provides compile-time confidence in SQL statement syntax and intent.

import SQLite

// Wrap everything in a do...catch to handle errors
do {
  let db = try Connection("path/to/db.sqlite3")

  let users = Table("users")
  let id = Expression<Int64>("id")
  let name = Expression<String?>("name")
  let email = Expression<String>("email")

  try db.run(users.create { t in
    t.column(id, primaryKey: true)
    t.column(name)
    t.column(email, unique: true)
  })
  // CREATE TABLE "users" (
  //     "id" INTEGER PRIMARY KEY NOT NULL,
  //     "name" TEXT,
  //     "email" TEXT NOT NULL UNIQUE
  // )

  let insert = users.insert(name <- "Alice", email <- "alice@mac.com")
  let rowid = try db.run(insert)
  // INSERT INTO "users" ("name", "email") VALUES ('Alice', 'alice@mac.com')

  for user in try db.prepare(users) {
      print("id: \(user[id]), name: \(user[name]), email: \(user[email])")
      // id: 1, name: Optional("Alice"), email: alice@mac.com
  }
  // SELECT * FROM "users"

  let alice = users.filter(id == rowid)

  try db.run(alice.update(email <- email.replace("mac.com", with: "me.com")))
  // UPDATE "users" SET "email" = replace("email", 'mac.com', 'me.com')
  // WHERE ("id" = 1)

  try db.run(alice.delete())
  // DELETE FROM "users" WHERE ("id" = 1)

  try db.scalar(users.count) // 0
  // SELECT count(*) FROM "users"
} catch {
  print (error)
}

Learn more -> kodeco.com, GRDB

Realm

Realm Database is an offline-first mobile object database in which you can directly access and store live objects without an ORM. It runs directly inside phones, tablets or wearables.

// Define your models like regular Swift classes
class Dog: Object {
  @Persisted var name: String
  @Persisted var age: Int
}
class Person: Object {
  @Persisted(primaryKey: true) var _id: String
  @Persisted var name: String
  @Persisted var age: Int
  // Create relationships by pointing an Object field to another Class
  @Persisted var dogs: List<Dog>
}
// Use them like regular Swift objects
let dog = Dog()
dog.name = "Rex"
dog.age = 1
print("name of dog: \(dog.name)")

// Get the default Realm
let realm = try! Realm()
// Persist your data easily with a write transaction
try! realm.write {
  realm.add(dog)
}

Learn more -> realm.io, github.com, kodeco.com

File System

Of course you can also directly save all types of files to the file system. However, you can just access the file system within the app container due to security reasons. Basically there are three folders within the app container:

  • Documents/: This is the perfect place to save user generated content. It’s also backed up to the iCloud automatically.

  • Library/: In this folder you can put files that are not generated by the user and that should persist between launches of the app. However, some files could be deleted when the device doesn’t has enough free space. There are some subfolders within the library folder for different purposes. For more details take a look at the iOS file systems basics document.

  • tmp/: In this folder you can save files that the app just needs temporarily. They can be deleted by the operation system when the app is not running.

An iOS app may create additional directories in the Documents, Library, and tmp directories. You might do this to better organize the files in those locations.

Learn more -> swiftbysundell.com, archived documentation

SwiftData

SwiftData is a framework that enables you to add persistence and iCloud sync to your app using Swift code. It combines the proven persistence technology of Core Data with Swift’s modern concurrency features, allowing you to describe your entire model layer (or object graph) with minimal code and no external dependencies.

You can write your model code declaratively, making it persistable. Use macros like Model(), Attribute(), and Relationship() to define your model classes and their properties.

@Model
class RemoteImage {
  @Attribute(.unique) var sourceURL: URL
  @Relationship(inverse: \Category.images) var category: Category?
  var data: Data

  init(sourceURL: URL, data: Data = Data()) {
    self.sourceURL = sourceURL
    self.data = data
  }
}


@Model
class Category {
  @Attribute(.unique) var name: String
  @Relationship var images = [RemoteImage]()

  init(name: String) {
    self.name = name
  }
}

SwiftData doesn’t intrude on your existing codebase. Attach the Model() macro to any model class to make it persistable. For automatic iCloud sync, SwiftData requires the CloudKit entitlement and an iCloud container. You can use it for locally created content or lightweight caching.