Pick the lightest store that fits the data: small preferences in UserDefaults, secrets in the Keychain, documents and blobs as files, and structured related data in SwiftData or Core Data.
**UserDefaults: a handful of small preferences: onboarding seen, last tab, a toggle. A plist loaded into memory, not encrypted.
Keychain: tokens, passwords, keys. Encrypted by the system and, unlike everything else here, it outlives deleting the app.
Files via FileManager: Codable JSON, images, downloads, exports. The directory you pick decides whether it is backed up or can be purged.
SwiftData**: the default for structured, related data in a new app on iOS 17+: @Model classes, @Query in SwiftUI.
Core Data: the same engine with a visual model; still the answer for existing stores and for features SwiftData lacks.
SQLite through GRDB, or Realm: when you want raw SQL and full control, or the team already runs on it.
CloudKit: a sync layer on top of a local store, not a replacement for one.
The two classic mistakes: a token in UserDefaults, which is a readable plist that lands in backups, and a database for what is really three settings.
iOSMiddle
iOS Persistence
UserDefaults, Keychain, Core Data, SwiftData, FileManager
Questions closed
0 / 25
- 0Review
- 7Asked often
Questions
UserDefaults is a small key-value store for user preferences, saved as a property list in the app's container and read into memory at launch.
Property-list types only: String, numbers and Bool, Date, Data, and arrays or string-keyed dictionaries of those. A plist is Apple's XML or binary format for exactly these types, the same format as Info.plist.
A custom type has to be encoded to Data first, for example with JSONEncoder. Passing it to set(_:forKey:) directly raises an invalid-argument exception at runtime.
Good for: flags, the chosen theme, the last opened tab, a sort order: small values you read synchronously.
Not for: secrets (a readable plist that ends up in backups, so use the Keychain), large data or ever-growing lists (the whole file sits in memory and is rewritten), anything you need to query or relate.
@AppStorage in SwiftUI is a property wrapper over the same store, and UserDefaults(suiteName:) opens a separate one, for example shared with a widget through an App Group.
Nothing was ever written under that key: the true in @AppStorage is only a fallback inside the property wrapper, and bool(forKey:) answers a missing key with false. Register the default with register(defaults:) at launch.
The settings screen shows the toggle on while SoundPlayer stays silent until the user flips it off and on again. Two readers, two different defaults.
register(defaults: ["soundEnabled": true]) fills the registration domain, which every reader consults when nothing was written. It is not saved to disk, so run it on every launch before the first read.
Typed getters flatten "missing" into false, 0 or 0.0. Use object(forKey:) == nil when you must tell "never chose" from "chose off".
To return to the default, call removeObject(forKey:); set(false, forKey:) stores a real choice you can never tell apart again.
A suite from UserDefaults(suiteName:) is a separate store and needs its own registration, or the widget disagrees with the app.
synchronize() is not the fix. Apple documents it as unnecessary; the system writes for you.
In the Keychain: the system encrypts it with keys tied to the device and its passcode, while UserDefaults is a plain plist that can be read from an unencrypted backup or a jailbroken phone.
The API is four C functions (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete) driven by dictionaries of kSec… keys. A token fits a kSecClassGenericPassword item identified by service and account.
SecItemAdd returns errSecDuplicateItem instead of overwriting, so update the item first and add it only when it does not exist.
Set kSecAttrAccessible explicitly: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly when a background refresh must read the token on a locked phone.
The Keychain is for small secrets. Encrypt large data into a file and keep only the key in the Keychain.
Yes: Keychain items live outside the app's container, so they survive deletion and a reinstall finds them. The accessibility class decides when an item can be decrypted, and the access group decides which of your apps and extensions can read it.
Surviving deletion: a reinstall can pick up the previous user's token. The usual fix is a first-launch flag in UserDefaults, which is deleted with the app: if it is missing, wipe your Keychain items. Apple has never promised this behaviour, so do not build features on it.
Accessibility (kSecAttrAccessible): WhenUnlocked is the default and fails while the phone is locked; AfterFirstUnlock is readable once the user has unlocked since boot, which background tasks and widgets need; WhenPasscodeSetThisDeviceOnly requires a passcode and the item is destroyed when the passcode is removed.
**ThisDeviceOnly: the item never moves to another device through a backup restore or iCloud Keychain. Right for device-bound tokens and keys.
Access groups**: every item belongs to one, by default your team ID plus bundle ID. Add a Keychain Sharing group (or use an App Group identifier) and pass it as kSecAttrAccessGroup to share items between the app, its extensions and your other apps.
**kSecAttrSynchronizable**: set to true, the item syncs through iCloud Keychain to the user's other devices.
Each app gets a sandbox container: Documents holds what the user created, Library/Application Support holds the app's own data, both backed up; Library/Caches is not backed up and can be purged; tmp is short-lived scratch space.
Documents: content the user would be upset to lose. Backed up, and visible in the Files app when you opt in.
Library/Application Support: data the app creates but the user does not browse, such as a database. Backed up too, so exclude the parts you can fetch again.
Library/Caches: anything re-downloadable. Never backed up, and the system may delete it under storage pressure, so every read must cope with the file being gone.
tmp: scratch files for one operation; delete them yourself and assume they can vanish between launches.
A 500 MB re-downloadable video belongs in Caches, not Documents. Filling the user's iCloud backup with cache data is a known App Review rejection; set isExcludedFromBackup on large files that must live outside Caches.
Protection class: files are readable after the first unlock by default. Complete protection suits sensitive data, but a background task cannot read such a file while the phone is locked.
Codable is the Swift protocol pair the compiler synthesises for structs, enums and classes; NSCoding is the older Objective-C protocol for NSObject classes, archived with NSKeyedArchiver. New code uses Codable.
NSCoding means hand-written encode(with:) and init?(coder:) for every field. You still meet it in init(coder:) of storyboard views and in old archives on disk.
When you must archive, adopt NSSecureCoding and decode with unarchivedObject(ofClass:from:). The old unchecked unarchiving APIs are deprecated, because decoding arbitrary classes from a file is an attack vector.
Codable is checked at compile time, works with value types and any encoder (JSON, plist), renames fields with CodingKeys, and a custom init(from:) handles the odd case.
Migrating an old archive: unarchive it once with NSKeyedUnarchiver, write it back as JSON, delete the old file.
Never write over a live file: put the new bytes somewhere else and swap them in with a rename, which the filesystem performs atomically.
data.write(to: url, options: .atomic) does exactly that: a temporary file in the same directory, then a rename. A crash leaves either the whole old file or the whole new one, never half of each.
The cost is real: .atomic needs room for both copies. When you prepared the replacement yourself (a finished download, a rebuilt cache), FileManager.replaceItemAt(_:withItemAt:) swaps it in and preserves the original's extended attributes and creation date.
Atomic is not durable. The rename orders the write; it does not force the bytes to flash. A sudden power loss can still cost you the last save, but the usual outcome is the whole old or the whole new file.
Encode first, swap second. A throwing encoder must fail before anything touches the live file, which is the whole reason to build Data in memory rather than stream into the destination.
Two processes are a different problem. An App Group file shared with a widget or an extension needs NSFileCoordinator; atomicity is per write, and coordination is what stops two writers interleaving.
Once the data is a collection that mutates, stop hand-rolling this. SQLite under SwiftData or Core Data gives you a journal, per-row writes and crash recovery you did not have to design.
The container directory carries a UUID that can change (restore from a backup, device migration, reinstall), so an absolute path saved earlier points at a directory that no longer exists. Persist the file name, never the path.
A sandbox path looks like /var/mobile/Containers/Data/Application/<UUID>/Documents/avatar.jpg and only the tail is yours. Store the part you own and rebuild the URL at read time from URL.documentsDirectory (or the App Group container).
The rule covers every hiding place for a path: a database column, UserDefaults, a CloudKit record, a cached URL in a singleton that outlives the process it was built in.
Files outside the sandbox (document picker, iCloud Drive): copy the file into your container at pick time, or persist a bookmark (bookmarkData() / URL(resolvingBookmarkData:bookmarkDataIsStale:)) and wrap every access in startAccessingSecurityScopedResource() / stopAccessingSecurityScopedResource().
Four pieces: NSManagedObjectModel is the schema, NSPersistentStoreCoordinator connects that model to the store files, NSManagedObjectContext is the scratchpad where you fetch and change objects, and NSPersistentContainer builds and owns all of them.
Model: entities, attributes and relationships from the .xcdatamodeld file, compiled into a .momd in the app bundle.
Coordinator and stores: the coordinator sits between contexts and one or more NSPersistentStores, usually SQLite, sometimes in-memory.
Context: tracks inserted, changed and deleted objects; nothing reaches disk until save(). Each context is bound to a queue: viewContext runs on the main queue for the UI, newBackgroundContext() is for heavy work, and you touch it only on its own queue: inside perform, or directly on the main thread for viewContext.
Container: call loadPersistentStores once at launch; persistentStoreDescriptions set the file URL, migration options and history tracking. NSPersistentCloudKitContainer is the subclass that adds iCloud sync.
Core Data is an object-graph manager rather than an ORM: it tracks relationships, faults, undo and change notifications, and SQLite is only the backing store. SwiftData's ModelContainer and ModelContext sit on the same pieces.
SwiftData is a Swift-native layer over the Core Data engine, with the schema written as @Model classes instead of an .xcdatamodeld file. Choose it for a new app on iOS 17+, and stay on Core Data for an existing store or for features SwiftData lacks.
Same ideas, new names: ModelContainer plays the role of NSPersistentContainer, ModelContext of NSManagedObjectContext, @Query of @FetchRequest, and #Predicate replaces string-based NSPredicate with a type-checked one. @ModelActor gives a background context instead of perform blocks.
Details are macros: @Attribute(.unique), @Relationship(deleteRule:inverse:), @Transient, and #Index / #Unique since iOS 18.
What you gain: far less boilerplate, compile-checked predicates, Observation-based updates in SwiftUI, autosave, CloudKit sync through one ModelConfiguration argument.
Where Core Data still wins: NSFetchedResultsController for UIKit lists, custom mapping models, batch insert and update requests, fine-grained merge policies, and support for iOS 16 and older.
Both can share one store: with matching entity names and the same file URL, an existing Core Data app can move screen by screen instead of in one rewrite.
SwiftData is younger, so predicate translation and migration edge cases are where you hit its limits first.
Each one wraps a fetch request (entity, predicate, sort order) and watches its context for saved changes, so the list updates itself instead of you re-fetching after every write.
**NSFetchRequest**: describes what to load: predicate, sortDescriptors, and fetchBatchSize so only the visible rows are loaded fully.
**NSFetchedResultsController**: the UIKit tool. It groups results into sections with sectionNameKeyPath and reports changes to its delegate; with a diffable data source, controller(_:didChangeContentWith:) hands you a snapshot to apply.
**@FetchRequest**: the same mechanics as a SwiftUI property wrapper, reading the context from \.managedObjectContext in the environment. @SectionedFetchRequest adds sections.
**@Query**: the SwiftData equivalent with #Predicate and SortDescriptor, reading \.modelContext, which .modelContainer(for:) installs.
Dynamic filters: a @Query cannot be changed after it is built, so pass the search text into a child view and build the query in its init. @FetchRequest lets you change nsPredicate at runtime.
They only see changes that reach their context. A Core Data background save appears only with automaticallyMergesChangesFromParent; SwiftData merges for you.
The search text is interpolated into the predicate's format string, so a name with an apostrophe such as O'Brien makes it unparseable and NSPredicate(format:) raises an exception that kills the app. Pass the text as a %@ argument instead.
**Why the catch does not help**: the crash is NSInvalidArgumentException "Unable to parse the format string", an Objective-C NSException. Swift do/catch only handles Swift Errors, and the exception fires while the predicate is built, before fetch even runs.
It is also an injection hole: whatever the user types becomes predicate syntax. Searching for x' OR TRUEPREDICATE OR name == 'y returns every row, which turns into a data leak once the same predicate also scopes rows by owner or account.
How the fix works: %@ substitutes the value as a constant that NSPredicate quotes and escapes itself, and %K does the same for a key path, so input can never change the structure of the query.
In SwiftData: #Predicate captures Swift values and is type-checked at compile time, so this bug cannot be written there.
Catching it next time: a unit test that searches for O'Brien, and a SwiftLint custom rule that flags \( inside NSPredicate(format:.
A relationship needs an inverse and an explicit delete rule, and the rule is what decides whether deleting the parent also deletes the children, merely unlinks them, or is refused.
The rules are cascade (delete the related objects too), nullify (keep them and clear the link), deny (refuse the delete while related objects exist) and noAction (leave the graph inconsistent, which is almost never what you want).
Nullify is the default, so without an explicit cascade, deleting a playlist keeps its tracks with playlist == nil. That is the orphan in the question.
Declare it with @Relationship(deleteRule:) in SwiftData or the delete rule popup in the Core Data editor, and put cascade on the owning side only. Cascade from the track to the playlist would delete the playlist when a single track goes.
Inverses keep both sides of the graph in sync. SwiftData infers one when there is a single obvious candidate, so declare the inverse explicitly as soon as a model has two relationships to the same type, or the wrong pair gets wired together.
Many-to-many is an array on both sides and the store maintains the join for you, but order is not preserved. Keep an explicit position attribute if the user can reorder.
NSBatchDeleteRequest runs in SQLite rather than in a context: validation does not run and objects already in memory are not told. Merge the deleted IDs back, and delete through a context when the graph rules matter.
article belongs to viewContext but is changed on the background context's queue, and it is not registered in background at all, so that save() writes nothing. Pass the NSManagedObjectID and load the object inside the other context.
A managed object is bound to its context, and a context to its queue. Touching it from another queue is a data race that sometimes works and sometimes corrupts the graph. The -com.apple.CoreData.ConcurrencyDebug 1 launch argument turns every violation into an immediate crash in development.
NSManagedObjectID is safe to pass between threads; existingObject(with:) in the target context returns that object, loading it from the store if needed, and throws if it was deleted.
An inserted but unsaved object has a temporary ID (isTemporaryID) that other contexts cannot resolve. Save first, or call obtainPermanentIDs(for:).
A fetched object is often a fault: a placeholder whose attributes load on first access. Firing a fault runs a fetch on the owning context's queue, one more reason never to touch it from elsewhere.
Do not persist object IDs as identity: they change when the store is rebuilt. Keep your own id.
SwiftData has the same rule: models are not Sendable, so pass PersistentIdentifier into a @ModelActor and load the model there.
Do the writing in a context the UI does not read from, and move results across the boundary as identifiers rather than as objects.
SwiftData: a @ModelActor type owns its own ModelContext on a background executor, so callers just await its methods. Model objects are not Sendable, so hand PersistentIdentifier values across after the save (before it they are temporary) and re-fetch on the other side. Create the actor from a background task: one initialised on the main actor has been seen running its context on the main thread.
Core Data: newBackgroundContext() or performBackgroundTask, with every touch of the context inside a perform block. Pass NSManagedObjectID between contexts and resolve it with existingObject(with:); passing a managed object itself is the classic crash.
Merging into the UI: set viewContext.automaticallyMergesChangesFromParent and choose a merge policy such as NSMergeByPropertyObjectTrumpMergePolicy, or a background save either never shows up or throws a conflict. SwiftData merges for you and @Query re-runs by itself.
Chunk the work: save every few hundred rows instead of once at the end, and reset the context between chunks so it does not grow to the size of the import. Turn autosave off while importing.
Mass deletes and updates have a shortcut: NSBatchDeleteRequest and NSBatchUpdateRequest run in SQLite directly, but they bypass the contexts, so you must merge the returned ids back into viewContext yourself.
Diagnose with the -com.apple.CoreData.SQLDebug 1 launch argument, which logs every SQL statement and works for SwiftData too. Almost every slow import turns out to be one save per row.
Make the server's identifier the model's identity with a uniqueness constraint, so importing a record that already exists updates the existing row instead of adding a second one.
SwiftData: @Attribute(.unique) var serverID: String. Inserting a model whose unique value is already stored performs an upsert: the existing row's properties are updated, nothing is duplicated and nothing throws.
Core Data: declare the constraint on the entity in the model editor, and then a merge policy becomes mandatory. With the default NSErrorMergePolicy the save fails on a constraint conflict; NSMergeByPropertyObjectTrumpMergePolicy means the incoming values win per property.
Never key on PersistentIdentifier or NSManagedObjectID. They identify a row in this store, change when the store is rebuilt or migrated, and mean nothing to the server.
A CloudKit-backed store cannot use unique constraints at all, so a synced schema dedupes by hand, and needs a deterministic winner (lowest id, earliest createdAt) when two devices created the same thing offline.
Do it in one fetch. Pull the existing rows for the whole batch with a single IN-style predicate into a dictionary; a fetch per record is the N+1 that turns a 20k import into minutes.
Constraint matching compares the exact stored value and is case- and diacritic-sensitive, so normalise an identifier the server does not guarantee before you store it.
Let the framework run a lightweight migration for additive changes, write a custom stage only where existing data must be transformed, and test against real old stores, because a failed migration is a crash on launch.
Lightweight migration: adding or removing attributes, adding entities, a new required attribute with a default, a rename through a renaming identifier. NSPersistentContainer infers it by itself: shouldMigrateStoreAutomatically and shouldInferMappingModelAutomatically are on by default.
Custom migration in Core Data: for what cannot be inferred: splitting an entity, deriving a value. Either a .xcmappingmodel with an NSEntityMigrationPolicy subclass or, since iOS 17, NSStagedMigrationManager, which chains lightweight and custom stages in code.
SwiftData: every shipped schema is a VersionedSchema, chained in a SchemaMigrationPlan. A stage is .lightweight or .custom with willMigrate / didMigrate closures that fix data around the change. Nest each version's models inside its schema and never edit a shipped one.
Doing it safely
Every change gets a new model version. Editing the current .xcdatamodel in place gives you "The model used to open the store is incompatible with the one used to create the store".
Test the migration on stores saved by each shipped version, not on a fresh one: users skip releases, and the bugs live in data you did not expect.
A long migration on the launch path can be killed by the watchdog. Keep heavy data fix-ups out of it, or show a progress screen.
Deleting and recreating the store is a debug escape hatch for testers, never the production fallback for a user's data.
Give each test its own store that exists only in memory, so every test starts from an empty database and nothing survives to the next one.
Keep a strong reference to the container for the whole test; the context does not retain it, and a container freed early crashes the test or loses the store.
SwiftData: ModelConfiguration(isStoredInMemoryOnly: true) and a fresh ModelContainer built inside the test. The code under test must take a container or context as a parameter rather than reaching for a global one.
Core Data: build the container in the test and point its store description at an in-memory store; the same trick of injecting the container applies.
Test your code, not the framework: the predicates you wrote, the mapping between API models and stored models, the de-duplication rules, the migration steps.
Migrations are the exception that needs a real file. Seed a copy of an old-version store, run the app's migration plan against it, and assert on the result.
Match production's concurrency. If the app writes through a model actor, the test should too, otherwise it passes while the real path races.
Put a repository protocol in front of the store and most of the app can be tested against an in-memory stub, leaving only the store's own tests to need a container.
An extension is a separate process with its own container, so everything shared has to live in an App Group directory both targets are entitled to open.
Add the same App Group to the app and the extension, then build every shared path from containerURL(forSecurityApplicationGroupIdentifier:) instead of the app's own Documents directory.
SwiftData: point ModelConfiguration(url:) at a file inside the group container. For Core Data, set the persistent store description's url the same way before loading the stores.
Preferences: UserDefaults(suiteName:) with the group identifier, which the widget reads with the same suite name. The standard defaults are not shared.
Keychain items are shared through an access group passed as kSecAttrAccessGroup: a Keychain Sharing group or the App Group identifier itself. Without it, the extension's queries simply find nothing.
Both processes can have the store open at once, so keep the widget read-only where possible, enable persistent history tracking to notice the app's writes, and call WidgetCenter.shared.reloadTimelines(ofKind:) after a save so the timeline is rebuilt.
Mind the lock screen: a widget renders while the device is locked, so files and Keychain items it reads must be accessible after first unlock rather than only while unlocked.
Deleting rows is not deleting data: you destroy the store file together with its sidecars, then clear everything that lives outside it (Keychain, preferences, caches and the App Group container).
A SQLite store is three files: Model.sqlite, -wal and -shm. Removing only the first leaves recent writes in the write-ahead log. Use destroyPersistentStore(at:type:options:) on the coordinator, and in SwiftData container.deleteAllData() or a rebuild of the container over a deleted file.
Deleting through a context only marks pages free; the bytes stay in the file until something overwrites them. If "gone" has to mean gone, the file itself has to go.
Keychain items survive logout and deleting the app. Delete each kSecClass you use explicitly; nothing clears them for you.
removePersistentDomain(forName:) empties a preferences suite, but the running process keeps values it already cached, so reset the objects holding them rather than trusting a relaunch.
The forgotten leftovers are all outside your store: URLCache, cookies, a WKWebsiteDataStore, thumbnails in Caches and tmp, the App Group container the widget reads, and the widget itself, which keeps rendering the old user until you reload its timelines.
Design it away: give each account its own directory under Application Support and put the store, the files and the thumbnails inside it. Logging out becomes deleting one directory and rebuilding the container.
Choose GRDB or raw SQLite when you need real SQL (FTS5, CTEs, window functions), large write-heavy data or plain value types without faulting; Realm is mostly a legacy choice today.
Signs you want GRDB or raw SQLite
You need SQL that Core Data cannot express: window functions, recursive CTEs, full-text search with FTS5, real reporting queries.
The workload is large or write-heavy and you want to choose the indices, joins and transaction boundaries yourself.
You want plain value types, with no faulting, no managed object context and no thread-confinement rules.
You share a schema or migrations with a server or with cross-platform code such as a KMP module.
What you give up
Faulting, undo, automatic change notification into SwiftUI, model validation, and the almost free CloudKit sync of Core Data and SwiftData. GRDB covers part of it with ValueObservation and its migrator, but that layer is now yours.
Realm
Its own non-SQLite engine with live objects and a simple API. MongoDB deprecated Atlas Device Sync and the Realm SDKs in 2024 and switched sync off in 2025; the database lives on as community open source, which makes it a hard sell for a new project.
Check that the filtering really happens in SQLite: a predicate the store can translate narrows rows in the database, while a closure over the fetched array loads every row into memory first.
#Predicate becomes SQL only from expressions SwiftData can translate: your own function is rejected at compile time, and a computed or @Transient property fails at fetch time. The usual workaround, fetching everything and filtering in Swift, is exactly what makes the list slow.
Index the columns you filter and sort on (the #Index macro in SwiftData, the indexed checkbox in the Core Data model editor). Sorting on an unindexed column re-sorts the whole table on every fetch.
Page instead of fetching everything: fetchLimit and fetchOffset on a FetchDescriptor, or a keyset predicate comparing against the last row's sort value, which stays correct when new rows arrive at the top.
Fetch only the properties the row displays, and keep images and other blobs in files with only the file name stored, so a list fetch does not drag megabytes into memory.
Walking a relationship inside a cell is a fault per row, the N+1 problem. Prefetch relationships with the fetch request's prefetching key paths, or denormalise the single value the cell needs.
Measure with the -com.apple.CoreData.SQLDebug 1 launch argument rather than guessing. A SQL statement per visible row means relationship faults (N+1); one full-table SELECT with no WHERE means the filter ran in Swift.
Each context saves against the snapshot it fetched, and when the row in the store no longer matches that snapshot it is the merge policy, not your code, that decides which values survive.
Core Data keeps a row snapshot for every registered object. On save it compares the snapshot with the store; a difference is an NSMergeConflict, and the context's mergePolicy resolves it before anything is written.
There are five. NSErrorMergePolicy is the default and makes the save throw so you can resolve it; the two by-property policies (…ObjectTrump, …StoreTrump) merge attribute by attribute against the snapshot, so only the attributes a context actually changed are candidates; NSOverwriteMergePolicy pushes every in-memory value over the store, including fields this context never looked at; NSRollbackMergePolicy drops the in-memory changes.
Setting ObjectTrump globally to make the errors stop is exactly how an edit goes missing. The conflict was real; it was just resolved silently.
Keep contexts short-lived. Give an editing screen a child context: cancel discards it, save pushes to the parent, and a snapshot from yesterday never survives to today.
For a context that must live long, stalenessInterval = 0 and refresh(_:mergeChanges: true) stop it serving values the store has already moved past.
SwiftData has no public merge-policy API, so keep an updatedAt or a version field in the model and compare before overwriting.
Switch the store to a CloudKit-backed configuration, but first migrate the shipped schema to CloudKit's rules (every attribute optional or defaulted, every relationship optional with an inverse, no unique constraints), because the store will not load otherwise.
The switch itself: ModelConfiguration(cloudKitDatabase:) in SwiftData, or NSPersistentCloudKitContainer in place of NSPersistentContainer, plus the iCloud capability and a container identifier.
CloudKit has no notion of a required column or a one-way link, so a non-optional property stops the store from loading.
That schema fix is a new model version: ship it as a lightweight migration (VersionedSchema or a new .xcdatamodel version) and test it on stores from every released version before turning sync on.
Unique constraints are not supported at all, so @Attribute(.unique) has to go and de-duplication becomes your job after a merge.
Sync is eventually consistent and merges property by property with last writer winning. Anything that needs real conflict resolution, such as a counter or a shared list, should sync operations rather than final values.
The private database belongs to the signed-in iCloud account. A signed-out user must still get a working app, so treat sync as a layer over the local store rather than as the store.
Test on two devices with one account, watch the CloudKit Console, and remember that schema changes reach production only when you promote them and that only additive changes are accepted.