Skip to content
Back to Blog
·5 min read

Syncing SwiftData with CloudKit

Set up SwiftData sync through CloudKit: capabilities, ModelConfiguration, the model rules CloudKit imposes, schema deployment to production and private-database scope.

To sync SwiftData through iCloud, add two capabilities to the app (iCloud with CloudKit and a container, and Background Modes with Remote notifications), make your models CloudKit-compatible, and run the app so the development schema gets created. Then deploy that schema to production in CloudKit Console before you ship, because App Store builds can only reach the production environment. Most of the work is in the models: no unique constraints, optional relationships, and values for every attribute. To check a model file against these rules in your browser, use our SwiftData and CloudKit checker.

Apple's article says SwiftData uses Core Data's NSPersistentCloudKitContainer to do the CloudKit synchronization, and that it reads your app's entitlements to work out the CloudKit configuration.

Add the capabilities

Apple lists two capabilities that SwiftData needs for iCloud sync:

  1. iCloud. Turn on CloudKit and create or choose a container, which Apple describes as the object CloudKit uses to isolate your app's databases on the iCloud servers. The capability needs an Apple Developer account with admin permissions.
  2. Background Modes. Turn on the Remote notifications option. The system delivers these notifications silently, and SwiftData uses them to learn about changes made on other devices and to apply them.

Choose a CloudKit database in ModelConfiguration

ModelConfiguration (iOS 17, macOS 14) takes a cloudKitDatabase argument of type ModelConfiguration.CloudKitDatabase. It has three values:

  • The default. SwiftData reads your Entitlements.plist and picks the first container identifier it finds. If you never pass the argument, this is what you get.
  • .private("iCloud.com.example.Trips"). Use a specific container. Apple suggests this when the app has more than one CloudKit container.
  • .none. Turn off SwiftData's sync, overriding any container it would have found. Apple's use for it is an app that already uses CloudKit in its own way and whose schema may clash.
import SwiftUI
import SwiftData

@main
struct TripsApp: App {
    let modelContainer: ModelContainer

    init() {
        let config = ModelConfiguration(cloudKitDatabase: .private("iCloud.com.example.Trips"))
        do {
            modelContainer = try ModelContainer(for: Trip.self, Stay.self, configurations: config)
        } catch {
            fatalError(error.localizedDescription)
        }
    }

    var body: some Scene {
        WindowGroup { Text("Trips") }
            .modelContainer(modelContainer)
    }
}

func localOnlyContainer() throws -> ModelContainer {
    let config = ModelConfiguration(cloudKitDatabase: .none)
    return try ModelContainer(for: Trip.self, Stay.self, configurations: config)
}

func defaultContainer() throws -> ModelContainer {
    try ModelContainer(for: Trip.self, Stay.self)
}

One caution from Apple: if your app already has a production CloudKit schema, name only containers that SwiftData or Core Data managed before. Other containers are incompatible.

Shape the models for CloudKit

SwiftData's macros often produce a CloudKit-compatible schema already, but a few features do not carry over. This table pairs Apple's list with one extra rule.

In your modelsWhat CloudKit needs
@Attribute(.unique)Don't use it. CloudKit can't enforce uniqueness, because changes sync concurrently and at opportune times.
RelationshipsMake every relationship optional. The iCloud servers don't guarantee atomic processing of relationship changes.
Inverse relationshipsSwiftData sets the inverse for you only if it can infer it reliably from the schema. Otherwise, set it explicitly before saving, because CloudKit processes changes in an indeterminate order.
Delete rule .denyNot supported, because changes aren't synchronized immediately. Use another rule such as .cascade or .nullify.
AttributesGive each a default value or make it optional (see below).

The last row is not on Apple's SwiftData page. We saw it when Core Data loaded a store with a SwiftData model and CloudKit turned on: it failed with "CloudKit integration requires that all attributes be optional, or have a default value set." In our run, a default given only inside init() did not count, so write the default on the property itself.

@Model
final class Trip {
    var name: String = ""
    var startDate: Date = Date()
    var notes: String?

    @Relationship(deleteRule: .cascade, inverse: \Stay.trip)
    var stays: [Stay]?

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

@Model
final class Stay {
    var place: String = ""
    var trip: Trip?

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

If you are moving an existing Core Data app, our Core Data to SwiftData converter produces models, and the checker above reads them against this list. Apple's Core Data article for CloudKit also asks that all relationships have an inverse and that entities in a configuration don't relate to entities in another one.

Create the development schema

During development, CloudKit has a draft schema in the development environment, which you can rewrite as often as you like. Apple's article shows how to have it created from a debug build: build a ModelConfiguration, point an NSPersistentStoreDescription at its url, set the container identifier, load the store synchronously, make a managed object model from your SwiftData classes, and call initializeCloudKitSchema() on an NSPersistentCloudKitContainer. Then remove the store before SwiftData's own container loads, so the two don't both sync.

import CoreData

#if DEBUG
func initializeSchema() throws {
    let config = ModelConfiguration()
    try autoreleasepool {
        let desc = NSPersistentStoreDescription(url: config.url)
        desc.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.Trips")
        desc.shouldAddStoreAsynchronously = false
        if let mom = NSManagedObjectModel.makeManagedObjectModel(for: [Trip.self, Stay.self]) {
            let container = NSPersistentCloudKitContainer(name: "Trips", managedObjectModel: mom)
            container.persistentStoreDescriptions = [desc]
            container.loadPersistentStores { _, err in
                if let err { fatalError(err.localizedDescription) }
            }
            try container.initializeCloudKitSchema()
            if let store = container.persistentStoreCoordinator.persistentStores.first {
                try container.persistentStoreCoordinator.remove(store)
            }
        }
    }
}
#endif

Wrap it in #if DEBUG, as Apple does, so it never runs in a release build. Afterwards, open CloudKit Console and look for your record types. If they don't show up, Apple says you may need to turn on query support for them.

Deploy the schema to production

Apps in the App Store can access only the production environment, and Apple says you must deploy the development schema there before you publish. In CloudKit Console:

  1. Sign in at icloud.developer.apple.com and open the CloudKit Database app.
  2. Choose your container.
  3. Select Deploy Schema Changes.
  4. Review the pending changes and click Deploy.

Deploying copies record types, fields and indexes, but not records. You need edit rights to the production environment; an individual developer has them, and on a team the administrator does the step or grants the privilege.

After that, the schema is additive. You can add record types and fields, but you can't delete or change record types and fields that are already in production. Apple's strategies for later changes are to move people to a new store in a new container, to add fields step by step so older app versions still see every record (but not every field), or to keep a version attribute and fetch only records the current app understands. Plan the first production schema with this in mind; names you pick now stay.

Private database only

Look at the list of values above: the default, .private(_:) and .none. SwiftData's CloudKitDatabase offers no public or shared option. Apple's Core Data setup article, which describes the machinery SwiftData builds on, speaks of local stores "backed by a CloudKit private database". In practice that makes it sync between one person's own devices through their own iCloud account, and design your features on that basis. Apple's SwiftData pages say nothing about sharing records between different people, so we leave that out.

Sources

All read on 1 November 2026.

Apple's requirements change; check the pages above for current details.

Written by

Building local-first Mac & iOS productivity apps at Obelisk Club. About the author →