NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI CloudKit Sync Tutorial — iCloud Data Sync Made Simple

A complete guide to syncing data across Apple devices using CloudKit with SwiftUI, Core Data, and SwiftData.

Ahmed GaganAhmed Gagan
12 min read

TL;DR

CloudKit is Apple's free, zero-configuration cloud sync service that lets users' data follow them across every Apple device. For Core Data, swap NSPersistentContainer for NSPersistentCloudKitContainer and you get automatic sync. For SwiftData, add ModelConfiguration(cloudKitDatabase: .automatic) and you are done in one line. CloudKit is perfect for device-to-device sync within the Apple ecosystem. If you need cross-platform support, user auth, or a full backend API, use Supabase or Firebase instead. This guide covers both approaches with real, working code.

CloudKit is Apple's built-in cloud sync framework that pairs seamlessly with Core Data and SwiftData. It is free for all Apple Developer Program members, requires no server setup, and uses the user's existing iCloud account for authentication. If you have ever opened Notes on your Mac and seen the note you typed on your iPhone, that is CloudKit at work. This SwiftUI CloudKit tutorial walks through every approach to iCloud sync: the Core Data integration via NSPersistentCloudKitContainer, the SwiftData shortcut, and the raw CloudKit API for custom use cases. I have shipped two production apps with CloudKit sync and one with Supabase, and the trade-offs between them are real.

What Is CloudKit and When Should You Use It?

CloudKit is Apple's cloud database framework, introduced in iOS 8 (2014). It stores data in Apple's iCloud servers and syncs it across all of a user's signed-in devices. There is no user registration, no password, no email verification. If the user is signed into iCloud on their device, they are authenticated. That is both its biggest strength and its biggest limitation.

CloudKit operates on three databases:

  • Private database — Data belongs to the signed-in iCloud user. Only they can read and write it. This is where most app data goes. Storage counts against the user's iCloud quota, not yours.
  • Public database — Data readable by all users of your app. You (the developer) own this data and it counts against your app's CloudKit quota. Useful for shared content, leaderboards, or reference data.
  • Shared database — Data shared between specific users via CKShare. Think shared shopping lists or collaborative documents. This is more complex to implement but very powerful.

Here is when CloudKit makes sense vs. when you should reach for something else:

  • Use CloudKit when your app is Apple-only, you need device-to-device sync (iPhone to iPad to Mac), you want zero backend cost, and you do not need a custom API or cross-platform support.
  • Use Supabase when you need a full backend with SQL queries, Row Level Security, Edge Functions, file storage, real-time subscriptions, or cross-platform support (iOS + web + Android).
  • Use Firebase when you need cross-platform sync with a NoSQL document model, push notifications via FCM, or you are already invested in the Google Cloud ecosystem.

For most indie iOS apps that only need to sync a user's data across their own devices, CloudKit is the simplest and cheapest option. You literally do not pay anything beyond the Apple Developer Program fee.

How Do You Set Up CloudKit in Your Xcode Project?

Before writing any code, you need to enable CloudKit in your Xcode project. This involves three steps: adding the iCloud capability, creating a container, and verifying entitlements.

  1. Add the iCloud capability — Open your Xcode project, select your app target, go to the Signing & Capabilities tab, click + Capability, and add iCloud. Check the CloudKit checkbox. If you are using Core Data with CloudKit, also check Remote Notifications under the Background Modes capability (needed for silent push notifications that trigger sync).
  2. Create a CloudKit container — Under the iCloud capability, click the +button next to Containers and create a new container. The naming convention is iCloud.com.yourcompany.yourapp. This container is your database in the cloud.
  3. Verify entitlements — Xcode should automatically create or update your .entitlements file. Verify it contains:
<!-- YourApp.entitlements -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
    <string>iCloud.com.yourcompany.yourapp</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
    <string>CloudKit</string>
</array>

Once this is configured, open the CloudKit Dashboard at https://icloud.developer.apple.com. This is your admin panel for viewing records, managing schemas, checking logs, and resetting development data. You will use it frequently during development.

Important: The development environment and production environment are separate in CloudKit. Schema changes you make during development must be explicitly deployed to production before your app goes live. This is a manual step in the CloudKit Dashboard that many developers forget on their first submission.

How Do You Sync Core Data with CloudKit?

The fastest path to CloudKit sync is through NSPersistentCloudKitContainer. If you already have a Core Data stack, the change is remarkably small: swap your container class and Apple handles the rest. Records are automatically mirrored between your local SQLite store and the user's private CloudKit database.

Here is a complete persistence controller with CloudKit sync:

// PersistenceController.swift
import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentCloudKitContainer

    init(inMemory: Bool = false) {
        // Replace NSPersistentContainer with NSPersistentCloudKitContainer
        container = NSPersistentCloudKitContainer(name: "YourDataModel")

        if inMemory {
            container.persistentStoreDescriptions.first?.url =
                URL(fileURLWithPath: "/dev/null")
        }

        // Configure the store description for CloudKit
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No persistent store descriptions found.")
        }

        // Enable remote change notifications
        description.setOption(true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        // Set the CloudKit container identifier
        description.cloudKitContainerOptions =
            NSPersistentCloudKitContainerOptions(
                containerIdentifier: "iCloud.com.yourcompany.yourapp"
            )

        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                // In production, log this and show a user-facing error
                fatalError("Core Data failed to load: \(error)")
            }
        }

        // Automatically merge changes from CloudKit
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

That is the entire setup. Apple's framework handles schema mirroring (your Core Data entities become CloudKit record types), change tracking, conflict resolution, and background sync. Here is what happens under the hood:

  1. When you save to Core Data locally, NSPersistentCloudKitContainer detects the change and queues it for upload.
  2. A background task pushes the change to the user's private CloudKit database.
  3. Other devices receive a silent push notification, pull the change, and merge it into their local store.
  4. The automaticallyMergesChangesFromParent flag ensures SwiftUI views update automatically.

Critical requirement: Your .xcdatamodeld must follow CloudKit compatibility rules. Every attribute needs a default value (CloudKit does not support required fields without defaults). Relationships must be optional. Unique constraints are not supported. The Deny delete rule is not allowed — use Nullify or Cascade instead. If you violate these rules, the sync will silently fail without crashing your app — you will only see errors in the CloudKit Dashboard logs.

To observe remote changes in SwiftUI, listen for the notification:

// In your SwiftUI view or view model
import Combine

final class DataViewModel: ObservableObject {
    @Published var items: [Item] = []
    private var cancellables = Set<AnyCancellable>()

    init() {
        // Listen for remote CloudKit changes
        NotificationCenter.default.publisher(
            for: .NSPersistentStoreRemoteChange
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _ in
            self?.fetchItems()
        }
        .store(in: &cancellables)

        fetchItems()
    }

    func fetchItems() {
        let request = NSFetchRequest<Item>(entityName: "Item")
        request.sortDescriptors = [
            NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)
        ]
        do {
            items = try PersistenceController.shared
                .container.viewContext.fetch(request)
        } catch {
            print("Fetch failed: \(error)")
        }
    }
}

For a deeper comparison of Core Data and SwiftData as persistence layers, see the SwiftData vs Core Data comparison.

How Do You Sync SwiftData with CloudKit?

If you are building a new app targeting iOS 17+, SwiftData with CloudKit is even simpler than the Core Data approach. You do not need a persistence controller, a store description, or any of the NSPersistentCloudKitContainer boilerplate. One line of configuration does everything.

// YourApp.swift
import SwiftUI
import SwiftData

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Item.self, Tag.self], cloudKitDatabase: .automatic)
    }
}

// Or with a custom ModelConfiguration:
@main
struct YourApp: App {
    let container: ModelContainer

    init() {
        let config = ModelConfiguration(
            "YourDataModel",
            schema: Schema([Item.self, Tag.self]),
            cloudKitDatabase: .automatic  // This single parameter enables CloudKit sync
        )
        do {
            container = try ModelContainer(for: Item.self, Tag.self, configurations: config)
        } catch {
            fatalError("Failed to create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

The .automatic option tells SwiftData to use the private CloudKit database associated with your app's iCloud container. Your @Model classes are automatically mirrored to CloudKit record types, just like Core Data entities.

Here is a simple model and view that syncs automatically:

// Item.swift
import SwiftData

@Model
class Item {
    var title: String = ""
    var note: String = ""
    var isComplete: Bool = false
    var createdAt: Date = Date()

    init(title: String, note: String = "") {
        self.title = title
        self.note = note
        self.createdAt = Date()
    }
}

// ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \Item.createdAt, order: .reverse) private var items: [Item]

    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    HStack {
                        Image(systemName: item.isComplete ? "checkmark.circle.fill" : "circle")
                            .onTapGesture { item.isComplete.toggle() }
                        VStack(alignment: .leading) {
                            Text(item.title)
                            if !item.note.isEmpty {
                                Text(item.note)
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .navigationTitle("Items")
            .toolbar {
                Button("Add") { addItem() }
            }
        }
    }

    private func addItem() {
        let item = Item(title: "New Item")
        modelContext.insert(item)
    }

    private func deleteItems(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(items[index])
        }
    }
}

Every insert, update, and delete is automatically synced to iCloud. Open the same app on another device signed into the same Apple ID, and the data appears within seconds.

Limitations of SwiftData + CloudKit: The same CloudKit compatibility rules apply. All properties need default values. Optional relationships are required. Unique constraints (the #Unique macro) are not supported with CloudKit sync. And as of iOS 18, SwiftData's CloudKit integration does not support the shared database (CKShare) — only private sync. If you need sharing between users, you still need Core Data with NSPersistentCloudKitContainer or the raw CloudKit API.

How Do You Use the CloudKit API Directly?

Sometimes you need more control than the Core Data or SwiftData integration provides. The raw CloudKit API gives you direct access to CKRecord, CKDatabase, queries, and subscriptions. This is useful for public databases, shared data, or when you want to sync data that does not fit neatly into a Core Data model.

// CloudKitService.swift
import CloudKit

final class CloudKitService: ObservableObject {
    private let container = CKContainer(identifier: "iCloud.com.yourcompany.yourapp")
    private var privateDatabase: CKDatabase {
        container.privateCloudDatabase
    }

    // MARK: - Save a Record
    func saveItem(title: String, note: String) async throws -> CKRecord {
        let record = CKRecord(recordType: "Item")
        record["title"] = title as CKRecordValue
        record["note"] = note as CKRecordValue
        record["isComplete"] = false as CKRecordValue
        record["createdAt"] = Date() as CKRecordValue

        return try await privateDatabase.save(record)
    }

    // MARK: - Fetch All Records
    func fetchItems() async throws -> [CKRecord] {
        let query = CKQuery(
            recordType: "Item",
            predicate: NSPredicate(value: true)
        )
        query.sortDescriptors = [
            NSSortDescriptor(key: "createdAt", ascending: false)
        ]

        let (results, _) = try await privateDatabase.records(
            matching: query,
            resultsLimit: 100
        )

        return results.compactMap { _, result in
            try? result.get()
        }
    }

    // MARK: - Query with Predicate
    func fetchIncompleteItems() async throws -> [CKRecord] {
        let predicate = NSPredicate(format: "isComplete == %@", NSNumber(value: false))
        let query = CKQuery(recordType: "Item", predicate: predicate)

        let (results, _) = try await privateDatabase.records(
            matching: query,
            resultsLimit: 50
        )

        return results.compactMap { _, result in
            try? result.get()
        }
    }

    // MARK: - Update a Record
    func toggleComplete(record: CKRecord) async throws -> CKRecord {
        let currentValue = record["isComplete"] as? Bool ?? false
        record["isComplete"] = !currentValue as CKRecordValue
        return try await privateDatabase.save(record)
    }

    // MARK: - Delete a Record
    func deleteItem(recordID: CKRecord.ID) async throws {
        try await privateDatabase.deleteRecord(withID: recordID)
    }

    // MARK: - Subscribe to Changes (Real-Time Updates)
    func subscribeToChanges() async throws {
        let subscription = CKQuerySubscription(
            recordType: "Item",
            predicate: NSPredicate(value: true),
            subscriptionID: "item-changes",
            options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
        )

        let notification = CKSubscription.NotificationInfo()
        notification.shouldSendContentAvailable = true  // Silent push
        subscription.notificationInfo = notification

        try await privateDatabase.save(subscription)
    }
}

The direct API gives you full control over what gets stored and how. You can mix private and public databases in the same app, implement custom caching layers, and handle large binary assets with CKAsset. The trade-off is that you lose the automatic Core Data/SwiftData integration — you are responsible for managing your own local cache and merge logic.

How Does CloudKit Compare to Supabase and Firebase?

This is the question I get asked most often. The answer depends entirely on what you are building. Here is an honest comparison based on real production experience:

FeatureCloudKitSupabaseFirebase
PricingFree (included with Apple Developer Program)Free tier, then $25/moFree tier, then pay-as-you-go
AuthenticationiCloud account (automatic)Email, Apple, Google, OAuth, magic linkEmail, Apple, Google, phone, anonymous
Database typeKey-value records (CKRecord)PostgreSQL (relational)Firestore (NoSQL documents)
Cross-platformApple onlyiOS, Android, web, serveriOS, Android, web, server
Real-time syncVia subscriptions + silent pushWebSocket real-timeFirestore listeners
Core Data / SwiftData integrationNative (1-line setup)Manual mapping requiredManual mapping required
Server-side logicNoneEdge Functions (Deno/TypeScript)Cloud Functions (Node.js/Python)
File storageCKAsset (tied to records)S3-compatible StorageCloud Storage
User-to-user sharingCKShare (complex)RLS policies (flexible)Security rules
ComplexityLow (for basic sync)MediumMedium
Vendor lock-inHigh (Apple ecosystem only)Low (open source, standard Postgres)High (Google proprietary)
Private data storage quotaUser's iCloud quota500 MB free, then paid1 GiB free, then paid

The key insight: CloudKit is for device-to-device sync within the Apple ecosystem. Supabase and Firebase are full backend platforms. They solve different problems. A notes app that syncs between iPhone and Mac is a CloudKit use case. A social app with user profiles, feeds, and a web dashboard is a Supabase or Firebase use case. Many production apps use both — CloudKit for local data sync and Supabase for the social/server-side features.

For a deep dive on the Supabase vs Firebase decision, read the full comparison.

How Do You Handle Conflict Resolution?

Conflicts happen when two devices edit the same record while offline. Device A changes the title, Device B changes the title, and both come back online. Someone has to win. How CloudKit handles this depends on which integration layer you are using.

NSPersistentCloudKitContainer (Core Data)

Core Data's CloudKit integration uses a last-writer-wins strategy at the record level. The merge policy you set on the viewContext determines how local conflicts are resolved:

  • NSMergeByPropertyObjectTrumpMergePolicy — The in-memory (local) version wins for changed properties. This is the most common choice and what I recommend for most apps.
  • NSMergeByPropertyStoreTrumpMergePolicy — The persisted (store) version wins. Useful if you want remote changes to always take precedence over local uncommitted edits.
  • NSOverwriteMergePolicy — The in-memory object completely overwrites the store version. Use carefully.
  • NSRollbackMergePolicy — Discards in-memory changes entirely. Rarely useful in practice.
// Set the merge policy on your view context
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

// For more granular control, handle the remote change notification
NotificationCenter.default.addObserver(
    forName: .NSPersistentStoreRemoteChange,
    object: container.persistentStoreCoordinator,
    queue: .main
) { notification in
    // Fetch changes and resolve conflicts manually if needed
    // For most apps, the merge policy handles this automatically
}

SwiftData

SwiftData uses the same underlying merge mechanism as Core Data. The default behavior is last-writer-wins, and as of iOS 18, there is no public API to customize the merge policy in SwiftData directly. If you need fine-grained conflict resolution, Core Data with NSPersistentCloudKitContainer gives you more control.

Direct CloudKit API

When using the CKRecord API directly, CloudKit uses server record change tagsto detect conflicts. If you try to save a record that was modified on the server since you last fetched it, CloudKit returns a CKError.serverRecordChanged error with three versions of the record:

func saveWithConflictResolution(record: CKRecord) async throws -> CKRecord {
    do {
        return try await privateDatabase.save(record)
    } catch let error as CKError where error.code == .serverRecordChanged {
        // Three versions available:
        guard let serverRecord = error.serverRecord,
              let clientRecord = error.clientRecord else {
            throw error
        }
        // error.ancestorRecord is the common ancestor (optional)

        // Strategy: merge by copying client changes onto the server record
        for key in clientRecord.allKeys() {
            serverRecord[key] = clientRecord[key]
        }

        // Retry with the merged record
        return try await privateDatabase.save(serverRecord)
    }
}

This gives you full control over the merge logic. You can implement field-level merging, show the user a conflict resolution UI, or apply domain-specific rules. It is more work than the automatic Core Data approach, but it is also more predictable.

What Are CloudKit's Limitations and Gotchas?

CloudKit is excellent for what it does, but it has real constraints that you need to understand before committing to it. These are the issues that caught me off guard in production:

  • Apple ecosystem only — There is no Android SDK, no web SDK, no REST API for third-party clients. If you ever need a web dashboard, an Android companion app, or a server that reads your data, CloudKit cannot help. This is the single biggest reason apps outgrow CloudKit.
  • No server-side logic — You cannot run code on CloudKit's servers. No equivalent of Supabase Edge Functions or Firebase Cloud Functions. Every operation must originate from a client device.
  • Debugging is painful — The CloudKit Dashboard shows records and logs, but error messages are often cryptic. Sync failures are silent — your app does not crash, data just does not appear on the other device. The com.apple.coredata.cloudkit.debug launch argument helps but produces extremely verbose output.
  • Sync is not instant — Initial sync after app install can take anywhere from a few seconds to several minutes depending on data volume. There is no progress indicator API. Users will complain that their data is "missing" when it is actually still downloading.
  • Rate limits — CloudKit has request rate limits per user and per app. For most indie apps these are generous, but if you are doing heavy read/write operations (like syncing hundreds of records rapidly), you can hit CKError.requestRateLimited. Implement exponential backoff.
  • Schema migration is manual — You can add new fields and record types, but you cannot rename or delete existing ones in production. This means your first schema design matters. Plan carefully or you end up with abandoned fields you can never remove.
  • No aggregate queries — You cannot run COUNT, SUM, or GROUP BY on CloudKit. Every aggregation must be done client-side by fetching all records. This scales poorly for apps with large datasets.
  • iCloud account required — If the user is not signed into iCloud, CloudKit sync silently stops working. Your app needs to detect this and show an appropriate message. Check CKContainer.default().accountStatus at launch.
// Check iCloud account status at launch
func checkiCloudStatus() async -> Bool {
    do {
        let status = try await CKContainer.default().accountStatus()
        switch status {
        case .available:
            return true
        case .noAccount:
            // Show: "Sign in to iCloud in Settings to sync your data"
            return false
        case .restricted:
            // Parental controls or MDM restrictions
            return false
        case .couldNotDetermine:
            return false
        case .temporarilyUnavailable:
            // iCloud is temporarily down
            return false
        @unknown default:
            return false
        }
    } catch {
        return false
    }
}

How Do You Test CloudKit Sync?

Testing CloudKit sync is one of the most frustrating parts of iOS development. You cannot unit test it in a meaningful way because it requires real iCloud accounts and real network connectivity. Here is the testing strategy I use:

  1. Two physical devices, same Apple ID — This is the only reliable way to test sync. Simulators support CloudKit but are unreliable for testing sync timing and conflict resolution. Use a real iPhone and a real iPad (or Mac with Catalyst/Mac Catalyst).
  2. CloudKit Dashboard — After saving a record on Device A, open the CloudKit Dashboard and verify the record appears in the private database. This confirms the upload worked. Then check Device B to confirm the download.
  3. Development vs production environment — CloudKit has separate development and production environments. During development, use the development environment (this is the default when running from Xcode). Before submitting to the App Store, deploy your schema to production in the CloudKit Dashboard.
  4. Reset the development environment — If your CloudKit data gets into a bad state during development, go to the CloudKit Dashboard and select Reset Development Environment. This wipes all development data and schemas. Production data is never affected.
  5. Enable verbose logging — Add -com.apple.coredata.cloudkit.debug 1 as a launch argument in your Xcode scheme. This prints detailed sync logs to the console. Increase to 3 for maximum verbosity (warning: extremely noisy).
// Xcode Scheme > Run > Arguments > Arguments Passed On Launch:
// -com.apple.coredata.cloudkit.debug 1

// In code, you can also check sync events programmatically:
import CoreData

// Monitor sync events (iOS 16+)
func monitorSyncEvents() async {
    let container = PersistenceController.shared.container

    do {
        let events = try container.persistentStoreCoordinator
            .persistentStores
        // Check the event history for sync status
        for store in events {
            print("Store: \(store.url?.lastPathComponent ?? "unknown")")
            print("Type: \(store.type)")
        }
    }
}

Pro tip: Create a second, free Apple ID specifically for testing CloudKit sync. Sign into this account on your test device. This prevents your personal iCloud data from getting mixed up with development data and lets you reset the environment freely without worry.

CloudKit vs. The Swift Kit Approach — Which Is Right for You?

The Swift Kit uses Supabase as its cloud backend instead of CloudKit. This was a deliberate architectural decision. Supabase gives you a full PostgreSQL database with SQL queries, Row Level Security, Edge Functions, file storage, and cross-platform support — features that CloudKit simply does not offer.

That said, CloudKit is the right choice for many apps. If your app is Apple-only, stores user data that only the owner needs to see (like notes, settings, or bookmarks), and you want zero backend cost, CloudKit is hard to beat. The one-line SwiftData integration is genuinely magical.

Here is my decision framework based on the indie iOS developer tech stack I recommend:

  • Personal data sync (notes, preferences, bookmarks) — Use CloudKit. Free, native, and automatic.
  • User profiles, social features, or shared data — Use Supabase. You need auth, RLS, and a real database.
  • Cross-platform or web companion — Use Supabase or Firebase. CloudKit is Apple-only.
  • Subscriptions and payments — Pair with RevenueCat regardless of your sync choice.

If you want the Supabase path fully wired up with auth, database queries, storage, and proper MVVM architecture, The Swift Kit gives you that out of the box. Check out the full feature list or see pricing to get started. If CloudKit is the right fit for your project, everything in this tutorial will get you there.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit