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
NSPersistentCloudKitContaineralso 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 —
NSUbiquitousKeyValueStorefor 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
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 entitiesCDNoteandCDNotebook; -
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()
}
}
}
See Apple Developer Documentation — Setting up a Core Data stack and Apple Developer Documentation — NSPersistentContainer.
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
}
}
See Apple Developer Documentation — Mirroring a Core Data store with CloudKit and Apple Developer Documentation — NSPersistentCloudKitContainer.
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
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
See Apple Developer Documentation — Sharing Core Data objects between iCloud users and Apple Developer Documentation — CKShareTransferRepresentation.
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 byiCloud.com.example.fieldnotes, with three databases:privateCloudDatabase,sharedCloudDatabaseandpublicCloudDatabase. -
CKDatabase— saves, fetches, queries and deletes records and zones, withasyncmethods. -
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 inCKAssetfields. -
CKQuery— a record type plus anNSPredicateand 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
See Apple Developer Documentation — Integrating a text-based schema into your workflow and Apple Developer Documentation — Deploying an iCloud container’s schema.
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.fieldnotesselected, for SwiftData and Core Data sync and for direct CloudKit access; Key-value storage forNSUbiquitousKeyValueStore; 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-environmenton 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>
See Apple Developer Documentation — Configuring iCloud services and Apple Developer Documentation — iCloud Services entitlement.
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>
See Apple Developer Documentation — Configuring background execution modes and Apple Developer Documentation — CKSharingSupported.
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
@Modelclasses 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:
tagListbecame 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, |
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes (no version listed) |
|
Yes |
Yes |
Yes |
Yes |
Yes |
Sharing: |
Yes |
Yes |
Yes |
Yes |
Yes |
|
Yes |
Yes |
Yes |
Not available |
Not listed |
Accepting shares in a |
Yes |
Yes |
Not available (Mac Catalyst only) |
Not available |
Yes |
|
Yes |
Yes |
Yes |
Yes |
Yes (no version listed) |
|
Yes |
Yes |
Yes |
Yes |
Not listed |
|
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
-
Persisting Models with SwiftData — the store this page’s migration fills.
-
Preferences, Files and Document-Based Apps — local preferences, the App Group container, file coordination and the
.fieldnotesdocument. -
Background Execution — how silent pushes and background tasks wake the app.
-
Security and Privacy — the Keychain, for secrets that must never go into
NSUbiquitousKeyValueStoreor CloudKit records. -
SDKs, connectors & Couchbase Mobile — Couchbase Lite and Sync Gateway, for data that must sync beyond Apple’s platforms.
-
Storage and Offline — the same choices seen from a React Native app.
References
-
Apple Developer Documentation — Setting up a Core Data stack
-
Apple Developer Documentation — Mirroring a Core Data store with CloudKit
-
Apple Developer Documentation — NSPersistentCloudKitContainer
-
Apple Developer Documentation — Creating a Core Data model for CloudKit
-
Apple Developer Documentation — Reading CloudKit records for Core Data
-
Apple Developer Documentation — Sharing Core Data objects between iCloud users
-
Apple Developer Documentation — Accepting share invitations in a SwiftUI app
-
Apple Developer Documentation — Consuming relevant store changes
-
Apple Developer Documentation — Adopting SwiftData for a Core Data app
-
Apple Developer Documentation — Deciding whether CloudKit is right for your app
-
Apple Developer Documentation — Designing and creating a CloudKit database
-
Apple Developer Documentation — CKShareTransferRepresentation
-
Apple Developer Documentation — Managing iCloud containers with the CloudKit Database app
-
Apple Developer Documentation — Deploying an iCloud container’s schema
-
Apple Developer Documentation — Integrating a text-based schema into your workflow
-
Apple Developer Documentation — url(forUbiquityContainerIdentifier:)
-
Apple Developer Documentation — Configuring background execution modes
-
Apple Developer Documentation — iCloud Key-Value Store entitlement
-
Apple Developer Documentation -- task(name:priority:file:line:_:)