Core Data, CloudKit and iCloud

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Persisting Models with SwiftData gave FieldNotes its store: SwiftData records behind a NotePersisting protocol, kept in the App Group and mirrored to the user’s iCloud private database. This page covers everything around that store. It covers Core Data, which FieldNotes 1.x used and many existing apps still use, and CloudKit itself for what SwiftData’s automatic sync doesn’t do: sharing with other people and publishing to everyone. It also covers the small iCloud stores for preferences and documents, and the move from Core Data to SwiftData. It targets the section’s baseline: iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, with a deployment minimum of 26 for every FieldNotes target. Nothing on this page needs an API newer than 26; the Core Data and CloudKit APIs used here have been available for several releases, and the platform table at the end lists the exceptions.

Most examples on this page need the iCloud capability, a paid Apple Developer Program membership, and a device or simulator signed in to an iCloud account; each section says what its example needs. The Swift features used, async/await, actors and Sendable, are covered in Async/Await and Tasks and Actors, Isolation and Sendable.

Choosing a Persistence and Sync Technology

Apple offers four layers, and they build on each other. SwiftData’s sync is implemented with Core Data’s NSPersistentCloudKitContainer, which is built on CloudKit, which stores records in the user’s iCloud account:

  • SwiftData — Swift-native models, @Query, automatic sync of the private database. The default for new code, and what FieldNotes 2 uses.

  • Core Data — the older object graph framework: managed objects, contexts and fetch requests. Its NSPersistentCloudKitContainer also mirrors the shared and public databases and supports sharing objects with other iCloud users, which SwiftData’s configuration doesn’t offer.

  • CloudKit directly — records, zones, queries and subscriptions with no local database, or with your own local store synced by CKSyncEngine. For full control, or for data that isn’t a local object graph.

  • Key-value and document storage — NSUbiquitousKeyValueStore for a few small preferences, and the iCloud Drive ubiquity container for user-visible files.

Apple’s services end at Apple’s platforms. When the same data must also sync with Android, the web or a backend you run, a third-party sync engine or your own server is the answer. The section links to one such option, Couchbase Lite, on SDKs, connectors & Couchbase Mobile, and Networking: URLSession, Network Framework and ATS covers talking to your own server.

Figure: which technology to use

flowchart TD Start(["Data to store and sync"]) --> Small{"Only a few small
preferences?"} Small -->|yes| KVS["NSUbiquitousKeyValueStore
(plus UserDefaults locally)"] Small -->|no| Platforms{"Must sync with Android,
the web or your own backend?"} Platforms -->|yes| ThirdParty["Third-party sync such as Couchbase Lite,
or your own server API"] Platforms -->|no| Existing{"Existing Core Data app?"} Existing -->|yes| KeepCD["Keep Core Data +
NSPersistentCloudKitContainer;
adopt SwiftData screen by screen"] Existing -->|no| Sharing{"Share records with other
iCloud users, or publish
to everyone?"} Sharing -->|"yes, as an object graph"| CoreDataShare["Core Data with private,
shared and public stores"] Sharing -->|"yes, as a few records"| CloudKitDirect["CloudKit directly:
CKDatabase, CKShare"] Sharing -->|no| Control{"Need your own local store
or custom conflict handling?"} Control -->|yes| SyncEngine["Your store +
CKSyncEngine"] Control -->|no| SwiftData["SwiftData with
private database sync"]

FieldNotes ends up in three boxes: SwiftData for notes, CloudKit directly for publishing field guides to the public database, and NSUbiquitousKeyValueStore for its sort-order preference. Its 1.x releases were in the "Core Data with private, shared and public stores" box, which is where most of this page starts.

The Core Data Stack (FieldNotes 1.x)

FieldNotes 1.x was a UIKit app hosting SwiftUI screens (its app and scene delegates are on App Structure and Life Cycle), and it stored notes with Core Data. Understanding that stack matters for two reasons: many existing apps still use it, and FieldNotes 2 has to read it once to migrate the user’s notes.

A Core Data stack has four parts:

  • the managed object model, drawn in Xcode’s model editor as FieldNotes.xcdatamodeld, with the entities CDNote and CDNotebook;

  • the persistent store coordinator, which connects the model to one or more store files;

  • managed object contexts, the scratch pads where objects are fetched, changed and saved;

  • NSPersistentContainer, which creates and holds the other three.

Xcode generates a class for each entity (Codegen: Class Definition); written out, the note entity looks like this:

// FieldNotes 1.x/Model/CDNote.swift (what Xcode's code generation produces)
import CoreData

@objc(CDNote)
public class CDNote: NSManagedObject {
    @NSManaged public var id: UUID?
    @NSManaged public var title: String?
    @NSManaged public var body: String?
    @NSManaged public var createdAt: Date?
    @NSManaged public var isPinned: Bool
    @NSManaged public var tagList: String?          // comma-separated in 1.x
    @NSManaged public var notebook: CDNotebook?
}

extension CDNote {
    @nonobjc public class func fetchRequest() -> NSFetchRequest<CDNote> {
        NSFetchRequest<CDNote>(entityName: "CDNote")
    }
}

The CD prefix keeps the Core Data classes apart from the SwiftData records of FieldNotes 2, which is what Apple’s coexistence sample recommends: the class names differ even when entity names would match. Managed object properties are optional in Swift because Core Data allows unsaved objects with missing values; the model editor’s Optional checkbox decides what may be saved.

Creating the container

FieldNotes 1.0 stored notes only on the device, with a plain NSPersistentContainer; iCloud sync arrived in 1.2 and is shown in the next section. NSPersistentContainer(name:) finds the compiled model with the same name in the bundle. Loading the stores is asynchronous in principle, so the completion handler is where failures show up. The viewContext belongs to the main thread; newBackgroundContext() returns a private-queue context for work off the main thread.

// FieldNotes 1.x/Persistence/LegacyNotesStack.swift
@preconcurrency import CoreData

/// Core Data serializes access through its contexts, so the stack itself is shared freely.
final class LegacyNotesStack: @unchecked Sendable {
    static let shared = LegacyNotesStack()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "FieldNotes")
        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(filePath: "/dev/null")
        }
        container.loadPersistentStores { description, error in
            if let error {
                fatalError("Unresolved Core Data error \(error) for \(description)")
            }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }

    /// Saves a note on a private queue; the view context picks the change up automatically.
    func addNote(title: String, body: String) async throws {
        let context = container.newBackgroundContext()
        try await context.perform {
            let note = CDNote(context: context)
            note.id = UUID()
            note.title = title
            note.body = body
            note.createdAt = .now
            try context.save()
        }
    }
}

The same rule as SwiftData’s applies: a managed object belongs to its context and is used only inside that context’s perform block or, for the view context, on the main thread. Between contexts, pass the object’s objectID (an NSManagedObjectID) and look it up with context.object(with:). automaticallyMergesChangesFromParent makes the view context absorb saves from background contexts, and the merge policy decides which side wins when both changed the same object.

Fetching in SwiftUI with @FetchRequest

@FetchRequest is Core Data’s counterpart of @Query. It reads the managed object context from the environment, fetches with a sort and an optional NSPredicate, and redraws the view when the results change:

// FieldNotes 1.x/Views/LegacyNoteList.swift
import SwiftUI
import CoreData

struct LegacyNoteList: View {
    @FetchRequest(sortDescriptors: [SortDescriptor(\CDNote.createdAt, order: .reverse)],
                  predicate: NSPredicate(format: "isPinned == YES OR notebook != nil"))
    private var notes: FetchedResults<CDNote>

    var body: some View {
        List(notes, id: \.objectID) { note in
            VStack(alignment: .leading) {
                Text(note.title ?? "Untitled")
                if let createdAt = note.createdAt {
                    Text(createdAt, style: .date).font(.caption)
                }
            }
        }
    }
}

// FieldNotes 1.x hosted it with the view context in the environment:
// UIHostingController(rootView: LegacyNoteList()
//     .environment(\.managedObjectContext, LegacyNotesStack.shared.container.viewContext))

List(notes, id: \.objectID) explicitly keys rows by each managed object’s objectID, since NSManagedObject does not itself conform to Identifiable; this keeps a row’s identity stable while a note is edited.

Syncing Core Data with CloudKit

NSPersistentCloudKitContainer is a subclass of NSPersistentContainer that mirrors selected stores to CloudKit. Each store description names a CloudKit container and a database scope:

  • private — the user’s own data, visible only to them, on all their devices. Counts against the user’s iCloud storage.

  • shared — data other users shared with this user. It appears in the participant’s shared database; the owner still stores it in their private one.

  • public — data every user of the app can read, stored against the app’s CloudKit quota. Writing needs an iCloud account; reading doesn’t.

From 1.2 on, FieldNotes used all three: a private store for the user’s notes, a shared store for notebooks other people shared with them, and a public store for community field guides. The private and shared stores use the same entities; the public store uses its own configuration (Public) in the model, because objects in different configurations can’t have relationships to each other.

// FieldNotes 1.x/Persistence/CloudNotesStack.swift
import CoreData
import CloudKit

final class CloudNotesStack: @unchecked Sendable {
    static let shared = CloudNotesStack()
    static let containerIdentifier = "iCloud.com.example.fieldnotes"

    let container: NSPersistentCloudKitContainer
    private(set) var privateStore: NSPersistentStore!
    private(set) var sharedStore: NSPersistentStore!

    init() {
        container = NSPersistentCloudKitContainer(name: "FieldNotes")
        let directory = NSPersistentContainer.defaultDirectoryURL()
        container.persistentStoreDescriptions = [
            Self.description(url: directory.appending(path: "Private.sqlite"), scope: .private),
            Self.description(url: directory.appending(path: "Shared.sqlite"), scope: .shared),
            Self.description(url: directory.appending(path: "Public.sqlite"), scope: .public,
                             configuration: "Public"),
        ]
        container.loadPersistentStores { description, error in
            if let error { fatalError("Could not load \(description): \(error)") }
        }
        let coordinator = container.persistentStoreCoordinator
        privateStore = coordinator.persistentStore(for: directory.appending(path: "Private.sqlite"))
        sharedStore = coordinator.persistentStore(for: directory.appending(path: "Shared.sqlite"))
        container.viewContext.automaticallyMergesChangesFromParent = true
    }

    private static func description(url: URL, scope: CKDatabase.Scope,
                                    configuration: String? = nil) -> NSPersistentStoreDescription {
        let description = NSPersistentStoreDescription(url: url)
        description.configuration = configuration
        let options = NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier)
        options.databaseScope = scope
        description.cloudKitContainerOptions = options
        // History drives the export; remote-change notifications tell the app about imports.
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        return description
    }
}

Needs the iCloud capability with CloudKit and the container iCloud.com.example.fieldnotes, the Remote notifications background mode, and a paid developer account.

The model must follow CloudKit’s rules, the same ones listed on the SwiftData page: no unique constraints, every relationship optional and with an inverse, and no Deny delete rule. During development, initializeCloudKitSchema() creates the matching record types; the SwiftData page shows the call. Core Data names each record type after its entity with a CD_ prefix (CD_CDNote), which is worth knowing when you look at the data in CloudKit Console.

Figure: how a change travels between devices

sequenceDiagram participant A as iPhone (owner)
local store participant M as Mirroring on iPhone
NSPersistentCloudKitContainer participant P as CloudKit
private database participant S as CloudKit
shared database participant B as iPad (same user) participant C as Friend's iPhone
(participant) A->>M: save() writes a history transaction M->>P: export changed records (when the system allows) P-->>B: silent push (content-available) B->>P: fetch changes since last token B->>B: import into local store, post remote change Note over P,S: A shared notebook lives in its own zone in the owner's private database P-->>S: the zone is visible to participants S-->>C: silent push C->>S: fetch changes, import into the shared store C->>S: participant edits are exported back

Sync is opportunistic, not real time. The system decides when to export and import, based on network, power and how the app is used; there is no API to force a sync at a given moment. Silent pushes are what make imports happen promptly, which is why the Remote notifications background mode is required.

Sharing a notebook with CKShare

Sharing is zone-based: share(_:to:) moves the notebook and everything related to it into a new record zone and creates a CKShare for that zone. The owner then sends an invitation through the system share sheet, which ShareLink presents when the shared item’s transfer representation is a CKShareTransferRepresentation:

// FieldNotes 1.x/Sharing/NotebookSharing.swift
#if os(iOS) || os(macOS)
import SwiftUI
import CoreData
import CloudKit

extension CloudNotesStack {
    /// Creates a share for the notebook and its notes.
    @MainActor
    func share(notebookWithID objectID: NSManagedObjectID, title: String) async throws -> CKShare {
        let notebook = try container.viewContext.existingObject(with: objectID)
        let (_, share, _) = try await container.share([notebook], to: nil)
        share[CKShare.SystemFieldKey.title] = title
        return share
    }
}

struct ShareableNotebook: Transferable {
    let objectID: NSManagedObjectID
    let name: String

    static var transferRepresentation: some TransferRepresentation {
        CKShareTransferRepresentation { item in
            .prepareShare(container: CKContainer(identifier: CloudNotesStack.containerIdentifier)) {
                try await CloudNotesStack.shared.share(notebookWithID: item.objectID, title: item.name)
            }
        }
    }
}

struct ShareNotebookButton: View {
    let notebook: ShareableNotebook

    var body: some View {
        ShareLink(item: notebook, preview: SharePreview(notebook.name)) {
            Label("Share Notebook", systemImage: "person.crop.circle.badge.plus")
        }
    }
}
#endif

Needs the iCloud (CloudKit) capability, CKSharingSupported set to true in Info.plist (shown under Capabilities and Configuration), and two devices signed in to different iCloud accounts to test.

CKShareTransferRepresentation exists on iOS, iPadOS and macOS; the guard keeps the file out of the watchOS and visionOS builds. Because every share is its own record zone and a database has a limit on zones, Apple’s sample also offers "add to existing share" by passing an existing CKShare instead of nil. Objects in different shares can’t be related, because each share moves its whole object graph into its own zone.

When the participant taps the invitation, the system launches the app and passes the share’s metadata to the scene delegate. FieldNotes 1.x already had one, so accepting is one method; a SwiftUI-lifecycle app adds an app delegate through UIApplicationDelegateAdaptor that installs such a scene delegate:

// FieldNotes 1.x/SceneDelegate+Sharing.swift
#if os(iOS)
import UIKit
import CloudKit

extension SceneDelegate {
    func windowScene(_ windowScene: UIWindowScene,
                     userDidAcceptCloudKitShareWith metadata: CKShare.Metadata) {
        let stack = CloudNotesStack.shared
        stack.container.acceptShareInvitations(from: [metadata], into: stack.sharedStore) { _, error in
            if let error { print("Accepting the share failed: \(error)") }
        }
    }
}
#endif

After acceptance, the notebook appears in the participant’s shared store once the import finishes. Owners can stop sharing and participants can leave from the system’s share management interface; when a participant leaves, Core Data removes the shared objects from their shared store.

Reacting to imports with persistent history

With NSPersistentStoreRemoteChangeNotificationPostOptionKey set, the container posts a remote-change notification after every import. The app then reads the persistent history since the last token it processed, merges what matters and remembers the new token. Filtering by transaction author skips the app’s own saves:

// FieldNotes 1.x/Persistence/HistoryProcessor.swift
import CoreData

actor HistoryProcessor {
    private var lastToken: NSPersistentHistoryToken?
    private let container: NSPersistentContainer

    init(container: NSPersistentContainer) { self.container = container }

    func processNewTransactions() async throws {
        let context = container.newBackgroundContext()
        let token = lastToken
        let transactions: [NSPersistentHistoryTransaction] = try await context.perform {
            let request = NSPersistentHistoryChangeRequest.fetchHistory(after: token)
            if let fetch = NSPersistentHistoryTransaction.fetchRequest {
                fetch.predicate = NSPredicate(format: "author != %@", "app")
                request.fetchRequest = fetch
            }
            let result = try context.execute(request) as? NSPersistentHistoryResult
            return result?.result as? [NSPersistentHistoryTransaction] ?? []
        }
        lastToken = transactions.last?.token ?? lastToken
        // Merge into the view context, update badges, deduplicate new objects ...
    }
}

The app’s own contexts set transactionAuthor = "app" so the filter works. History grows forever unless it is purged; Apple’s advice is to purge only history that is well older than the last successful CloudKit export, because the mirroring itself reads the history to know what to upload.

Using CloudKit Directly

SwiftData’s configuration mirrors only the private database, so FieldNotes 2 talks to CloudKit directly for the one feature that needs the public database: field guides, read-only notebooks that a user publishes for every FieldNotes user to browse. The API has a small number of central types:

  • CKContainer — the app’s space on iCloud, identified by iCloud.com.example.fieldnotes, with three databases: privateCloudDatabase, sharedCloudDatabase and publicCloudDatabase.

  • CKDatabase — saves, fetches, queries and deletes records and zones, with async methods.

  • CKRecord — a dictionary-like record of a record type, with an ID, typed fields and system fields such as the creation date. Large files go in CKAsset fields.

  • CKQuery — a record type plus an NSPredicate and sort descriptors; fields used in a query must be marked Queryable (or Sortable) in the schema.

// Packages/FieldNotesKit/Sources/FieldNotesServices/CloudKit/FieldGuidePublisher.swift
import CloudKit
import FieldNotesModel

public actor FieldGuidePublisher {
    private let database = CKContainer(identifier: "iCloud.com.example.fieldnotes").publicCloudDatabase

    public init() {}

    /// Publishes a notebook's notes as a public field guide and returns its record name.
    public func publish(_ notebook: Notebook, notes: [Note], coverImage: URL?) async throws -> String {
        guard try await CKContainer(identifier: "iCloud.com.example.fieldnotes").accountStatus() == .available
        else { throw CKError(.notAuthenticated) }

        let record = CKRecord(recordType: "FieldGuide",
                              recordID: CKRecord.ID(recordName: notebook.id.uuidString))
        record["title"] = notebook.name
        record["noteTitles"] = notes.map(\.title)
        record["placeNames"] = notes.compactMap { $0.location?.placeName }
        if let coverImage {
            record["cover"] = CKAsset(fileURL: coverImage)
        }
        let (saveResults, _) = try await database.modifyRecords(saving: [record], deleting: [],
                                                                 savePolicy: .changedKeys)
        return try saveResults[record.recordID]?.get().recordID.recordName ?? record.recordID.recordName
    }

    /// The newest guides that mention a place.
    public func guides(mentioning placeName: String) async throws -> [String] {
        let query = CKQuery(recordType: "FieldGuide",
                            predicate: NSPredicate(format: "placeNames CONTAINS %@", placeName))
        query.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        let (results, _) = try await database.records(matching: query, desiredKeys: ["title"],
                                                      resultsLimit: 25)
        return results.compactMap { (try? $0.1.get())?["title"] as? String }
    }
}

Needs the iCloud capability with CloudKit, a paid developer account and, for publishing, a signed-in iCloud account; reading public records works without one.

A few design points are visible in the code. The record name is the notebook’s id, so publishing again updates the same guide instead of creating a new one. savePolicy: .changedKeys sends only the fields that changed and doesn’t fail if the server copy was modified meanwhile; the default, .ifServerRecordUnchanged, reports a conflict (CKError.serverRecordChanged) with the server’s copy so the app can merge. CloudKit errors often carry a retryAfterSeconds value; honour it rather than retrying immediately. Batch writes with modifyRecords, as here, instead of saving records one by one.

What CloudNoteSyncer does

CloudNoteSyncer, the NoteSyncing actor from Swift Concurrency in Apps, is not how notes reach iCloud: SwiftData’s mirroring does that without any code. CloudNoteSyncer exchanges notes with the FieldNotes web service, which lets users read their notes on the web and back them up outside iCloud. Its upload is written on Networking: URLSession, Network Framework and ATS, and the persistent history on the SwiftData page tells it which notes were deleted. Keeping the two channels apart matters: iCloud sync belongs to the system and runs when the system decides, while the web sync belongs to the app and runs when NoteStore.refresh() asks for it.

Subscriptions and silent pushes

When records change on the server, CloudKit can notify the app through a subscription. A CKQuerySubscription fires for records that match a predicate (public and private databases); a CKDatabaseSubscription fires for any change in a private or shared database; setting shouldSendContentAvailable makes the notification silent, so it wakes the app in the background without alerting the user. FieldNotes subscribes to new guides for a place the user follows:

// Packages/FieldNotesKit/Sources/FieldNotesServices/CloudKit/FieldGuidePublisher+Subscriptions.swift
import CloudKit

extension FieldGuidePublisher {
    public func follow(placeName: String) async throws {
        let subscription = CKQuerySubscription(
            recordType: "FieldGuide",
            predicate: NSPredicate(format: "placeNames CONTAINS %@", placeName),
            subscriptionID: "guides-\(placeName)",
            options: [.firesOnRecordCreation])
        let info = CKSubscription.NotificationInfo()
        info.shouldSendContentAvailable = true
        subscription.notificationInfo = info
        _ = try await CKContainer(identifier: "iCloud.com.example.fieldnotes")
            .publicCloudDatabase.save(subscription)
    }
}

Needs the Push Notifications capability (Xcode adds the aps-environment entitlement), the Remote notifications background mode and a physical device; the simulator doesn’t receive CloudKit pushes reliably. Handling the push itself is covered on Notifications, APNs and Live Activities.

CKSyncEngine for your own store

If FieldNotes ever needed a local store that SwiftData can’t provide (a custom file format, say, or conflict resolution per field), it wouldn’t have to write the whole sync protocol itself. CKSyncEngine handles scheduling, push notifications, batching, retries and account changes. The app keeps a delegate that answers two questions: "what should be sent next?" (nextRecordZoneChangeBatch(_:syncEngine:)) and "here is what changed" (handleEvent(_:syncEngine:)), and it persists the engine’s state serialization between launches:

// A sketch of how a custom store would drive CKSyncEngine; not part of FieldNotes 2.
import CloudKit

func makeGuideSyncEngine(delegate: any CKSyncEngineDelegate,
                         savedState: CKSyncEngine.State.Serialization?) -> CKSyncEngine {
    let configuration = CKSyncEngine.Configuration(
        database: CKContainer(identifier: "iCloud.com.example.fieldnotes").privateCloudDatabase,
        stateSerialization: savedState,
        delegate: delegate)
    return CKSyncEngine(configuration)
}

func noteChanged(_ recordID: CKRecord.ID, engine: CKSyncEngine) {
    // The engine schedules the upload; the delegate builds the CKRecord when asked.
    engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}

CloudKit Console and deploying the schema

Every CloudKit container has two environments. Development builds use development, where saving a record of a new type creates the type automatically. App Store and TestFlight builds use production, where the schema is fixed. CloudKit Console (https://icloud.developer.apple.com) is the web tool for both: browse and edit records, add indexes (a field must be Queryable before a query can filter on it), look at logs and telemetry, reset the development environment, and deploy schema changes from development to production.

Deployment is additive. Record types, fields and indexes are copied to production, and once there they can’t be removed or have their type changed. Plan fields carefully, and prefer adding a new field to reusing an old one for something else. The schema can also live in the repository as a text file and be managed with Xcode’s cktool:

# Save a CloudKit management token once (created in CloudKit Console).
xcrun cktool save-token --type management

# Export the development schema to a file kept in the repository.
xcrun cktool export-schema --team-id ABCDE12345 \
    --container-id iCloud.com.example.fieldnotes \
    --environment development --output-file CloudKit/FieldNotes.ckdb

# After editing the file, validate and import it into development.
xcrun cktool validate-schema --team-id ABCDE12345 \
    --container-id iCloud.com.example.fieldnotes \
    --environment development --file CloudKit/FieldNotes.ckdb
xcrun cktool import-schema --team-id ABCDE12345 \
    --container-id iCloud.com.example.fieldnotes \
    --environment development --file CloudKit/FieldNotes.ckdb

Deploying to production is still done in CloudKit Console, by a team member with the right privileges. Apple’s guide for designing the schema itself (record types, references, zones and indexes) is linked in the references.

Small Values: NSUbiquitousKeyValueStore

For a handful of preferences that should follow the user across devices, a database is too much. NSUbiquitousKeyValueStore is UserDefaults with iCloud sync: string keys, property-list values, at most 1024 keys and 1 MB in total per app. It has no conflict resolution beyond "last write wins", so it suits settings and small state, never user content.

FieldNotes keeps its note sort order in @AppStorage("noteSortOrder"), backed by the App Group defaults suite from Preferences, Files and Document-Based Apps. A small mirror copies it to iCloud and back, so choosing "Oldest First" on the iPhone also changes the Mac:

// FieldNotes/Settings/SortOrderCloudMirror.swift
import Foundation
import FieldNotesModel
import FieldNotesServices

@MainActor
final class SortOrderCloudMirror {
    private let cloud = NSUbiquitousKeyValueStore.default
    private var observation: (any NSObjectProtocol)?
    private static let key = "noteSortOrder"

    func start() {
        observation = NotificationCenter.default.addObserver(
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
            object: cloud, queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated { self?.pullFromCloud() }
        }
        cloud.synchronize()          // ask for the latest values; not a forced upload
        pullFromCloud()
    }

    /// Call when the user picks a new order.
    func push(_ order: NoteSortOrder) {
        cloud.set(order.rawValue, forKey: Self.key)
    }

    private func pullFromCloud() {
        guard let raw = cloud.string(forKey: Self.key), NoteSortOrder(rawValue: raw) != nil else { return }
        UserDefaults.fieldNotesShared.set(raw, forKey: Self.key)   // @AppStorage picks it up
    }
}

Needs the iCloud capability with Key-value storage enabled, which adds the com.apple.developer.ubiquity-kvstore-identifier entitlement. The notification’s userInfo says why values changed: a server change, the initial download, a quota violation, or the user switching iCloud accounts; the last one means the whole store should be re-read.

iCloud Drive Documents

The third iCloud store is the ubiquity container for documents: a folder that syncs through iCloud Drive and, if the app declares it public, appears in the Files app and the Finder under the app’s name. FieldNotes uses it as a destination for exported .fieldnotes notebooks, the document type defined on Preferences, Files and Document-Based Apps.

// Packages/FieldNotesKit/Sources/FieldNotesServices/Files/ICloudDocuments.swift
import Foundation

public enum ICloudDocuments {
    /// The Documents folder of FieldNotes's iCloud Drive container, or nil when iCloud Drive is off.
    /// Call off the main thread: the first call can set up the container and take a while.
    public static func documentsURL() -> URL? {
        guard let container = FileManager.default.url(
            forUbiquityContainerIdentifier: "iCloud.com.example.fieldnotes") else { return nil }
        let documents = container.appending(path: "Documents", directoryHint: .isDirectory)
        try? FileManager.default.createDirectory(at: documents, withIntermediateDirectories: true)
        return documents
    }
}

Needs the iCloud capability with iCloud Documents enabled for the container. Files in the container should be read and written with file coordination (NSFileCoordinator), because the iCloud daemon writes to them too; the Preferences page covers coordination.

Capabilities and Configuration

Every iCloud feature on this page and the SwiftData page is switched on in Signing & Capabilities, which writes the entitlements file and registers the identifiers with the developer account:

  • iCloud, with CloudKit checked and the container iCloud.com.example.fieldnotes selected, for SwiftData and Core Data sync and for direct CloudKit access; Key-value storage for NSUbiquitousKeyValueStore; iCloud Documents for the ubiquity container.

  • Background Modes with Remote notifications, so CloudKit’s silent pushes can wake the app.

  • Push Notifications, which adds aps-environment (com.apple.developer.aps-environment on macOS). Xcode adds it automatically with CloudKit; it is needed for subscriptions.

The resulting entitlements for the iOS app look like this:

<!-- FieldNotes/FieldNotes.entitlements (excerpt) -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
    <string>iCloud.com.example.fieldnotes</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
    <string>CloudKit</string>
    <string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
    <string>iCloud.com.example.fieldnotes</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
    <string>group.com.example.fieldnotes</string>
</array>

And Info.plist gains the background mode, the sharing flag, and the declaration that makes the documents folder visible in Files:

<!-- FieldNotes/Info.plist (excerpt) -->
<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>
<key>CKSharingSupported</key>
<true/>
<key>NSUbiquitousContainers</key>
<dict>
    <key>iCloud.com.example.fieldnotes</key>
    <dict>
        <key>NSUbiquitousContainerIsDocumentScopePublic</key>
        <true/>
        <key>NSUbiquitousContainerName</key>
        <string>FieldNotes</string>
        <key>NSUbiquitousContainerSupportedFolderLevels</key>
        <string>Any</string>
    </dict>
</dict>

All of these need a paid Apple Developer Program membership: iCloud containers are registered to a team. Membership, Signing, Capabilities and Info.plist covers capabilities, provisioning and the per-platform entitlement differences in general.

Migrating from Core Data to SwiftData

Apple describes two ways to move a Core Data app to SwiftData:

  • Keep the store file. Define @Model classes whose entity names, attributes and relationships match the Core Data model exactly, point both stacks at the same file, and turn on persistent history tracking in the Core Data stack (SwiftData always has it). Both frameworks can then run side by side, which allows an incremental move: Apple’s sample keeps the app on Core Data and writes its widget with SwiftData.

  • Import into a new store. Read the old store once, write its contents through the new layer, and delete the old store. This is the choice when the model changes shape, as it did for FieldNotes: tagList became an array, entities were renamed, and the store moved into the App Group.

FieldNotes 2 imports. The first launch after the update runs LegacyStoreImporter before NoteStore.load(). The importer reads the user’s own notes from the 1.x private store (Private.sqlite) on a background context, maps them to Note values, and saves them through the same NotePersisting protocol as every other write, so the import needs no knowledge of SwiftData at all. Notebooks other people shared with the user stay with their owners: SwiftData has no shared-database sync, so FieldNotes 2 replaced live shared notebooks with exported .fieldnotes files and public field guides, a trade-off worth weighing before any such migration.

// FieldNotes/Migration/LegacyStoreImporter.swift
import CoreData
import FieldNotesModel
import FieldNotesServices

actor LegacyStoreImporter {
    private let persistence: any NotePersisting

    init(persistence: any NotePersisting) { self.persistence = persistence }

    /// Imports the 1.x store if it exists, then deletes it. Safe to call on every launch.
    func importIfNeeded() async throws {
        let legacyURL = NSPersistentContainer.defaultDirectoryURL().appending(path: "Private.sqlite")
        guard FileManager.default.fileExists(atPath: legacyURL.path()) else { return }

        let container = NSPersistentContainer(name: "FieldNotes")
        container.persistentStoreDescriptions.first?.url = legacyURL
        container.persistentStoreDescriptions.first?.cloudKitContainerOptions = nil   // read locally only
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
            container.loadPersistentStores { _, error in
                if let error { continuation.resume(throwing: error) } else { continuation.resume() }
            }
        }

        let context = container.newBackgroundContext()
        let notes: [Note] = try await context.perform {
            let request: NSFetchRequest<CDNote> = CDNote.fetchRequest()
            return try context.fetch(request).map { legacy in
                Note(id: legacy.id ?? UUID(),
                     title: legacy.title ?? "",
                     body: legacy.body ?? "",
                     createdAt: legacy.createdAt ?? .now,
                     tags: (legacy.tagList ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) },
                     isPinned: legacy.isPinned)
            }
        }
        for note in notes {
            try await persistence.save(note)      // an upsert: re-running the import is harmless
        }

        try container.persistentStoreCoordinator.destroyPersistentStore(at: legacyURL, type: .sqlite)
    }
}

Three details make the import safe. It keeps each note’s id, so notes keep their identity, deep links such as fieldnotes://note/<uuid> keep working, and re-running an interrupted import updates instead of duplicating. It opens the old store without CloudKit options, so the legacy mirroring doesn’t start up and fight the new one. And it runs on every device the user upgrades: each device imports its own copy, and because the ids match, the upserts converge. If two devices upload the same note before either has seen the other’s copy, CloudKit can briefly hold two records for one id; the de-duplication in the V1 to V2 migration stage on the SwiftData page, and the upsert in SwiftDataNotePersistence, clean that up. The records the 1.x mirroring left in CloudKit (CD_CDNote) are simply no longer read.

The importer is called from the app’s launch task, before the store is loaded:

// FieldNotes/FieldNotesApp.swift (excerpt of the WindowGroup)
WindowGroup {
    ContentView()
        .environment(store)
        .task {
            try? await LegacyStoreImporter(persistence: persistence).importIfNeeded()
            await store.load()
        }
}

Here persistence is the SwiftDataNotePersistence the app creates in its initializer, as on the SwiftData page.

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

Core Data, NSPersistentContainer, @FetchRequest

Yes

Yes

Yes

Yes

Yes

NSManagedObjectContext.perform (async), destroyPersistentStore(at:type:options:)

Yes

Yes

Yes

Yes

Yes (no version listed)

NSPersistentCloudKitContainer (private, shared, public scopes)

Yes

Yes

Yes

Yes

Yes

Sharing: share(_:to:), acceptShareInvitations(from:into:)

Yes

Yes

Yes

Yes

Yes

CKShareTransferRepresentation with ShareLink

Yes

Yes

Yes

Not available

Not listed

Accepting shares in a UIWindowSceneDelegate

Yes

Yes

Not available (Mac Catalyst only)

Not available

Yes

CKContainer, CKDatabase async methods, CKQuerySubscription

Yes

Yes

Yes

Yes

Yes (no version listed)

CKSyncEngine

Yes

Yes

Yes

Yes

Not listed

NSUbiquitousKeyValueStore

Yes

Yes

Yes

Yes

Yes

iCloud Drive ubiquity container

Yes

Yes

Yes

Yes

Yes

SwiftData sync of the shared or public database

Not available

Not available

Not available

Not available

Not available

"No version listed" and "not listed" mean the API’s documentation page doesn’t give a visionOS version; check it on a device before relying on it there. On the Mac, native AppKit apps accept shares through NSApplicationDelegate instead of a scene delegate.

Where to Go Next

References