Skip to content

CloudKit Basics (Swift)

A SwiftUI guide for coding agents. Also covers cloudkit save fetch records, ckrecord converter, ckcontainer private database, icloud sync ios, ckquery predicate indexes, account status check.

CloudKit stores app data in iCloud, syncs it across the user's devices, and can share it between users. Enable it under Signing and Capabilities, which creates a container holding three databases: public (all users), private (per user, counts against their iCloud storage), and shared (data others shared with the user).

Check the Account First

Saving fails when no Apple ID is signed in or iCloud Drive is off, so check CKContainer.default().accountStatus() before touching the database and alert when it is not .available.

final class CloudKitService {
  func checkAccountStatus() async throws -> CKAccountStatus {
    try await CKContainer.default().accountStatus()
  }
}

Saving Records

Create the record type in the CloudKit Console schema first. In code, model the value as a struct and convert it to a CKRecord, keeping field names in one enum so they cannot drift.

struct Fasting: Hashable {
  var start: Date
  var end: Date
  var goal: TimeInterval
}

enum FastingRecordKeys: String {
  case type = "Fasting"
  case start, end, goal
}

extension Fasting {
  var record: CKRecord {
    let record = CKRecord(recordType: FastingRecordKeys.type.rawValue)
    record[FastingRecordKeys.goal.rawValue] = goal
    record[FastingRecordKeys.start.rawValue] = start
    record[FastingRecordKeys.end.rawValue] = end
    return record
  }
}

extension CloudKitService {
  func save(_ record: CKRecord) async throws {
    try await CKContainer.default().privateCloudDatabase.save(record)
  }
}

Fetching with CKQuery

Queries fail silently without indexes: mark each queried or sorted field in the dashboard under Schema, then Indexes. Add a failable initializer from CKRecord and query with an NSPredicate.

extension Fasting {
  init?(from record: CKRecord) {
    guard
      let start = record[FastingRecordKeys.start.rawValue] as? Date,
      let end = record[FastingRecordKeys.end.rawValue] as? Date,
      let goal = record[FastingRecordKeys.goal.rawValue] as? TimeInterval
    else { return nil }
    self = .init(start: start, end: end, goal: goal)
  }
}

extension CloudKitService {
  func fetchFastingRecords(in interval: DateInterval) async throws -> [Fasting] {
    let predicate = NSPredicate(
      format: "\(FastingRecordKeys.start.rawValue) >= %@ AND \(FastingRecordKeys.end.rawValue) <= %@",
      interval.start as NSDate,
      interval.end as NSDate
    )
    let query = CKQuery(recordType: FastingRecordKeys.type.rawValue, predicate: predicate)
    query.sortDescriptors = [.init(key: FastingRecordKeys.end.rawValue, ascending: true)]

    let result = try await CKContainer.default().privateCloudDatabase.records(matching: query)
    let records = result.matchResults.compactMap { try? $0.1.get() }
    return records.compactMap(Fasting.init)
  }
}

In the view, .task fetches on appear, .refreshable re-fetches, and .redacted(reason: isLoading ? .placeholder : []) covers the loading state.

Environments

Debug builds use the development environment automatically. Deploy the schema to production in the CloudKit dashboard before shipping to TestFlight or the App Store, or production queries hit an empty schema.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-getting-started-with-cloudkit" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems