Skip to main content
3Nsofts logo3Nsofts
iOS Architecture

SwiftData Tutorial for Production: Models, Migrations, Concurrency, and Performance

A production-focused SwiftData guide to schema design, compound uniqueness, indexes, migrations, ModelActor isolation, query performance, CloudKit, and release testing.

By Ehsan Azish · 3NSOFTS·August 2026·11 min read

SwiftData's first-run experience is excellent: declare an @Model, attach a container, and query it from SwiftUI. Production work begins after that demo succeeds.

Real apps need stable identity, query plans, actor boundaries, schema upgrades, disk-failure handling, and a testable path from every released store to the current one. Those concerns are not arguments against SwiftData. They are the work required to use any persistent store responsibly.

Start with an explicit schema

Model the identity and relationships your domain needs instead of relying on UI state or array position.

import SwiftData

@Model
final class Note {
    var id: UUID
    var title: String
    var body: String
    var createdAt: Date
    var modifiedAt: Date

    init(id: UUID = UUID(), title: String, body: String = "") {
        self.id = id
        self.title = title
        self.body = body
        self.createdAt = .now
        self.modifiedAt = .now
    }
}

SwiftData does not have an @Attribute(.primaryKey) option. Every persistent model already has framework-managed identity. Add a domain identifier such as UUID when imports, deep links, synchronization, or business rules need stable identity outside one store.

Choose relationship delete rules deliberately. A cascade is appropriate only when the child has no meaning outside its parent. Validate delete behavior using a store on disk; an in-memory preview does not expose every failure you will see during migration or cleanup.

Use uniqueness and indexes for different jobs

Uniqueness protects a domain invariant. An index accelerates a query. Neither substitutes for the other.

Modern SwiftData schemas can express compound constraints and indexes:

@Model
final class CachedArticle {
    #Unique<CachedArticle>([\.sourceID, \.remoteID])
    #Index<CachedArticle>([\.updatedAt])

    var sourceID: String
    var remoteID: String
    var updatedAt: Date
    var title: String

    init(sourceID: String, remoteID: String, title: String) {
        self.sourceID = sourceID
        self.remoteID = remoteID
        self.updatedAt = .now
        self.title = title
    }
}

Use the exact API available to your deployment target and guard newer schema features appropriately. There is no @Attribute(.index) option. Apple's #Index and #Unique documentation define the current macro forms and limitations.

Do not add indexes speculatively. Indexes consume storage and slow writes. Add them to properties used repeatedly in predicates and sorting, then measure representative datasets.

CloudKit compatibility imposes additional schema restrictions. Do not assume a local uniqueness constraint is also a cross-device conflict strategy. If CloudKit sync is planned, validate the model against Apple's current SwiftData and CloudKit requirements before freezing version 1.

Version before the first breaking change

Introduce a VersionedSchema and migration plan before you need a complex migration. It costs little early and gives each released model an explicit name.

enum NotesSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Note.self] }
}

enum NotesMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [NotesSchemaV1.self] }
    static var stages: [MigrationStage] { [] }
}

Adding an optional property or a property with a safe default is often compatible with lightweight migration; it does not automatically require custom code. Renaming, splitting, merging, or transforming data may require an explicit migration stage.

When changing a schema:

  1. preserve a fixture store created by the released app;
  2. open it with the new container and migration plan;
  3. assert counts, relationships, constraints, and representative values;
  4. repeat the test with a large store and interrupted-upgrade scenarios;
  5. keep every publicly released schema in the plan.

Never test migration only by deleting the simulator app. That tests a clean install, not an upgrade.

Keep model work inside its actor

ModelContext and persistent model instances should not wander across actor boundaries. Use @ModelActor for background persistence work and pass PersistentIdentifier values or immutable transfer values into and out of it.

struct NoteSummary: Sendable {
    let id: UUID
    let title: String
}

@ModelActor
actor NotesStore {
    func recent(limit: Int) throws -> [NoteSummary] {
        var descriptor = FetchDescriptor<Note>(
            sortBy: [SortDescriptor(\.modifiedAt, order: .reverse)]
        )
        descriptor.fetchLimit = limit

        return try modelContext.fetch(descriptor).map {
            NoteSummary(id: $0.id, title: $0.title)
        }
    }
}

Returning DTOs makes ownership obvious and avoids accidental use of a model created by another context. Apple's SwiftData concurrency support and the 2026 SwiftData Group Lab reinforce this boundary.

Keep UI-bound mutations on the main actor's context. Use a model actor for imports, cleanup, indexing, and other work that would otherwise block interaction. Saving from two contexts is a consistency problem to design and test, not something async solves automatically.

Make fetches bounded and measurable

The most common SwiftData performance bug is fetching an unbounded model graph and filtering it in Swift.

Push predicates and sorting into FetchDescriptor, set fetchLimit where the UI displays a limit, and index fields that appear in hot query paths. Avoid loading large relationship collections merely to compute a badge.

For list screens:

  • fetch only the visible ordering and filter;
  • paginate or cap large histories;
  • debounce search rather than creating a query for every keystroke;
  • avoid expensive computed properties in every row;
  • profile a release build with production-sized data;
  • watch both query duration and memory growth.

SwiftData supports batch operations for appropriate workloads. Use them when the platform version and semantics match the task, and verify that the UI context observes the resulting changes.

Treat CloudKit as a separate release surface

Enabling a private CloudKit database is configuration, not proof that synchronization is ready.

let configuration = ModelConfiguration(
    "Notes",
    schema: Schema([Note.self]),
    cloudKitDatabase: .private("iCloud.com.example.notes")
)

let container = try ModelContainer(
    for: Schema([Note.self]),
    configurations: [configuration]
)

The identifier must match entitlements and the CloudKit container configured for the signed target. Test with real devices and real iCloud accounts. Cover offline edits, account changes, quota failures, long-delayed synchronization, duplicate imports, schema deployment, and upgrading a device that has not launched for months.

Do not promise a particular conflict result without reproducing it for your schema and OS versions. Design stable identifiers and idempotent imports so retrying work is safe. If the app needs observable synchronization status, design that product state explicitly rather than presenting network reachability as proof that CloudKit is current.

For a deeper sync-focused treatment, see our local-first SwiftData and CloudKit walkthrough.

Handle files, failures, and recovery

Use external storage for large data only when it fits the model and platform behavior; do not make multi-megabyte media ordinary inline attributes. Keep replaceable caches outside the authoritative user-data store.

Container creation and saves can fail because of disk pressure, schema incompatibility, file protection, or account state. A production app should:

  • log the underlying error without recording private model values;
  • avoid silently replacing a failed user store with an empty one;
  • present a recoverable state when retrying can help;
  • export or preserve user data before destructive recovery;
  • distinguish a disposable cache from irreplaceable content.

A fatalError may be acceptable in a small internal prototype. It is rarely an adequate recovery design for a shipping data app.

Test the upgrade path

Maintain store fixtures for every released schema. Run migration tests in CI and on the oldest supported OS, because the compiler's newest SDK does not change the runtime on an older device.

Your release suite should include:

  • clean installation and first save;
  • upgrade from every public schema version;
  • relationship deletion and constraint failures;
  • large imports and cancellation;
  • concurrent UI and background operations;
  • low-storage behavior;
  • app termination during ordinary writes and migration;
  • CloudKit offline-to-online reconciliation on physical devices;
  • uninstall/reinstall behavior for local-only and synced data.

Production checklist

  • Define domain identity separately from SwiftData's internal identity when needed.
  • Use #Unique for invariants and #Index for measured query paths.
  • Establish a versioned schema and migration plan early.
  • Keep model instances and contexts within their actor boundary.
  • Return DTOs or persistent identifiers from model actors.
  • Bound fetches and profile production-sized stores.
  • Treat CloudKit as a tested subsystem, not a checkbox.
  • Preserve user data when container creation or migration fails.
  • Test actual upgrade stores instead of repeatedly reinstalling.

FAQ

Does SwiftData have primary keys?

SwiftData manages persistent identity for every model. It does not provide @Attribute(.primaryKey). Add a domain identifier when your application or synchronization design needs a stable external identity.

Does adding an optional property require a custom migration?

Not necessarily. Compatible additions can often use lightweight migration. Transformations such as splitting fields or changing meaning may require a custom stage. Test the exact released store rather than relying on a rule of thumb.

Is @Query enough for every list?

No. It is convenient for UI-driven queries, but large datasets still require intentional predicates, sorting, limits, indexes, and measurement. Background imports and maintenance are better isolated behind a model actor.

Does a uniqueness constraint solve CloudKit conflicts?

No. Local uniqueness, remote synchronization, duplicate imports, and concurrent device edits are separate concerns. Validate CloudKit compatibility and design idempotent reconciliation around stable domain identifiers.

Authoritative References