SwiftData's Real Cost Is Schema Discipline

Get Bananas’s ShoppingItem is the canonical example of why SwiftData schema discipline matters. The original schema did not include a lastModified timestamp; adding it later required a specific migration shape because existing data was already on disk, and the field was made optional specifically to fix a migration crash that hit when it was first added as non-optional.1

SwiftData’s API is two macros. @Model on a class makes it a persistent type. @Attribute(.unique) on a property gives it a uniqueness constraint. The framework hides Core Data’s stack management, value-transformer dance, and the NSManagedObjectContext boilerplate. The thing the framework does not hide is the schema migration; it just makes the migration declarative instead of imperative. The cost of not paying attention to migrations is the bug that wipes a user’s data on a routine update.

The thesis: SwiftData is cheap to start and expensive to migrate sloppily. The discipline is naming, optionality, and VersionedSchema from day one, not the day you realize you should have.

TL;DR

  • @Model macro turns a class into a persistent SwiftData type. The framework generates the schema from the property declarations at compile time.
  • Adding a new optional property is a no-op migration: SwiftData’s lightweight migration handles it. Adding a non-optional property to an existing schema requires a VersionedSchema plus a MigrationPlan that tells the framework how to populate the new field for existing rows.
  • The cost of skipping VersionedSchema from day one is that any non-trivial v2 schema change risks dropping a user’s database, because the lightweight path is conservative and bails when it cannot infer the migration.
  • @Attribute(.unique) is the right tool for natural keys (a UUID you generated, an external ID you imported). @Relationship is the right tool for parent/child references. Both are macros that generate the right Core Data plumbing under the hood.2

What @Model Actually Does

A SwiftData type is a Swift class with the @Model macro applied. Get Bananas’s ShoppingItem is the canonical shape:

import Foundation
import SwiftData

@Model
final class ShoppingItem {
    @Attribute(.unique) var id: UUID
    var name: String
    var amount: String
    var section: String
    var isChecked: Bool
    var isOptional: Bool
    var sortOrder: Int
    var lastModified: Date?

    init(id: UUID = UUID(), name: String, amount: String, section: String,
         isOptional: Bool = false, sortOrder: Int = 0) {
        self.id = id
        self.name = name
        self.amount = amount
        self.section = section
        self.isChecked = false
        self.isOptional = isOptional
        self.sortOrder = sortOrder
        self.lastModified = Date()
    }
}

Three details about that shape that the API hides.

@Model does not require a separate persistent-store schema declaration. SwiftData reads the class definition at compile time and synthesizes the schema. The class’s properties become the model’s attributes; their Swift types become the column types. There is no .xcdatamodeld file to maintain (though Core Data’s underlying NSManagedObjectModel still exists and is what backs the schema at runtime).2

@Attribute(.unique) is a constraint on a single column, not a PRIMARY KEY declaration. SwiftData’s persistent identity is the PersistentIdentifier, generated automatically per row. The @Attribute(.unique) declaration tells the framework “this column stores at most one row per value.” When you insert a model with a .unique value that already exists, SwiftData performs an upsert: the existing row is updated rather than rejected. The semantics matter for product code: .unique is not a UI-level validation that prevents duplicates from being submitted; it is an at-most-one storage guarantee that quietly merges. The id: UUID pattern above is the recommended one for cross-process synchronization (where you want a stable identifier that survives the in-process PersistentIdentifier going away), and the upsert behavior is exactly what you want when the same UUID arrives from two sync paths.

@Model classes are reference types, not value types. Mutating a property on a ShoppingItem instance triggers SwiftData’s change tracking; the framework registers the change and persists it on the next context save. The SwiftUI integration through @Query re-renders any view observing the matching predicate. The pattern is similar to @Observable (covered in What SwiftUI Is Made Of), with persistence layered on top.

Optional Fields Are The Cheap Migration

The lastModified: Date? field on ShoppingItem is optional, and the optionality is load-bearing. The field was added after v1 shipped to support cross-device sync and conflict resolution; existing rows on user devices had no lastModified value. An optional field with no default lets SwiftData’s lightweight migration handle the addition without writing any migration code: existing rows get nil; new rows get whatever the init sets.3

The lightweight migration path is the framework’s polite path. SwiftData inspects the new schema and the persistent store, infers the smallest compatible change, and applies it. The migration is automatic; the user does not see anything; the app launches normally on the existing data. The cases the lightweight path handles cleanly:

  • Adding an optional property
  • Removing a property (the data is dropped; existing reads no longer see the column)
  • Renaming an attribute that the framework can match by hint (using @Attribute(originalName: ...))
  • Renaming a @Model class that the framework can match (using @Model.originalName or a hint)

The cases the lightweight path bails on:

  • Adding a non-optional property with no default to an existing schema (existing rows have no value to populate it with)
  • Changing a property’s type (e.g., IntString)
  • Splitting a model into two models, or merging two into one
  • Anything that requires custom logic to migrate

When the lightweight path bails, the safe behavior is to fail the migration. The unsafe behavior would be to drop the database and start over; the framework is conservative and refuses to do that silently. The user sees the app crash on launch with a migration error; the developer sees a stack trace pointing to the schema mismatch; nobody loses data, but everybody loses confidence.

The cost of skipping VersionedSchema from day one shows up at the v2 → v3 boundary, when you add the third feature whose schema change exceeds what the lightweight path handles.

VersionedSchema And MigrationPlan: The Day-One Discipline

VersionedSchema declares a specific version of the model schema. MigrationPlan declares how to migrate from one version to the next.4 The shape:

import SwiftData

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

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        SchemaV1.self,
        SchemaV2.self,
    ]

    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
    ]
}

The model classes themselves move into the versioned-schema namespace:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

The ModelContainer is constructed with the migration plan:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

The migration plan gives the framework a typed graph of how the schema evolves. When the v2-shipping app launches against a v1 database, the framework walks the migration plan, applies the named stages, and brings the database to v2. When you ship v3, you add SchemaV3.self to schemas and a new MigrationStage between v2 and v3.

The discipline is to ship VersionedSchema in v1, even when there is only one version. The cost of doing so is one extra file and one extra enum declaration. The cost of not doing so is that v2’s first non-trivial schema change requires retroactively wrapping v1 in a VersionedSchema, which is doable but requires care to match the exact v1 shape so the framework can identify the existing data as SchemaV1. Future-you working on v2 will pay the tax; present-you can pay it once and forget about it.

Custom MigrationStage For The Hard Cases

Lightweight migrations cover most additive changes. Type changes, splits, merges, and conditional populations need a MigrationStage.custom:

static var stages: [MigrationStage] = [
    MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Read v1 rows; stage any derived state to a transient store
            // (UserDefaults / temp file) since the v1 and v2 contexts do
            // not share state, and didMigrate cannot read v1.
            let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
            stageDerivedState(from: v1Items)
        },
        didMigrate: { context in
            // Populate v2-only fields on existing rows
            let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
            for item in v2Items where item.lastModified == nil {
                item.lastModified = Date()
            }
            try context.save()
        }
    )
]

The two closures fire before and after the framework applies the structural migration. willMigrate runs against the v1 schema; didMigrate runs against the v2 schema. The closure body is normal SwiftData code (fetch descriptors, model context saves, the same APIs used in the running app), operating against a transient in-migration context.

The pattern that survives production is to keep willMigrate empty and put all population logic in didMigrate. Reading v1 data inside willMigrate is allowed, but the v2 schema does not exist yet from the framework’s perspective, so any computation has to be staged into a transient store the didMigrate closure can read. The simpler rule: structural migrations are framework’s job; populating v2-only fields on existing rows is didMigrate’s job.

When @Attribute And @Relationship Earn Their Names

Two macros do most of the schema decoration work in @Model classes.

@Attribute decorates a single property with a constraint or hint:

  • @Attribute(.unique) enforces uniqueness, as in ShoppingItem.id
  • @Attribute(.externalStorage) stores large Data blobs outside the database (image data, audio buffers)
  • @Attribute(originalName: "old_field_name") matches a property to a renamed column during migration
  • @Attribute(.transformable(by: ...)) applies a ValueTransformer to a non-Codable type

The right discipline: use .unique for fields that genuinely should be unique (a UUID you generated, an external ID), use .externalStorage for any blob over a few KB, use originalName when a v2 rename of a property would otherwise lose the v1 data.

@Relationship decorates a property that points to another @Model class or a collection of them:

@Model
final class List {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
    var items: [ShoppingItem] = []
}

@Model
final class ShoppingItem {
    var name: String
    var list: List?
}

The deleteRule: .cascade means deleting the parent List deletes all child ShoppingItem rows. The inverse: parameter tells the framework which property on the child points back to the parent; the framework uses it for predictable bidirectional maintenance. SwiftData can sometimes infer the inverse automatically, and inverse: nil is supported for explicitly unidirectional relationships, but the safe default is to declare inverse: whenever the inference would be ambiguous.5

The right discipline: declare relationships with explicit deleteRule (the default is .nullify, which is rarely what you want) and declare inverse: whenever the relationship is bidirectional (rather than relying on the framework’s inference). The implicit defaults are usually wrong; the explicit form is one extra parameter and a forever-saved bug.

What I Would Build Differently

Three patterns the apps in the cluster either ship or wish they shipped.

Ship VersionedSchema from v1. Every shipping @Model class should live inside a VersionedSchema from day one. The cost is one wrapping enum per schema version. The benefit is that v2’s first non-trivial change is a one-line addition to MigrationPlan.schemas instead of a two-day retroactive refactor.

Make every timestamp optional. Fields like lastModified, createdAt, and updatedAt that exist for cross-device sync or conflict resolution should be optional in v1 if the v1 product does not need them. Optionality keeps the migration to v2 (when you do need them) cheap. Filling them on existing rows during didMigrate is one loop; making them non-optional from v1 is a constraint that can break backfill on user data.

Use UUIDs as the natural key, not the PersistentIdentifier. SwiftData’s PersistentIdentifier is in-process. Cross-device sync, MCP integration (covered in Two Agent Ecosystems, One Shopping List), and any out-of-process reference need a stable identifier. A UUID with @Attribute(.unique) is the right shape; the in-process PersistentIdentifier is the wrong shape for anything that crosses a process boundary.

When @Model Is The Wrong Answer

Three cases where SwiftData is not the right tool:

Single-record key/value state. App settings, the user’s selected language, the timestamp of the last sync. Use UserDefaults or NSUbiquitousKeyValueStore (covered in Five Apple Platforms, Three Shared Files). SwiftData’s overhead for a single row is wasted ceremony; key-value stores are the right substrate.

Server-authoritative data with no offline writes. A list fetched from a REST API and displayed read-only. SwiftData is overkill if the source of truth is the server and the local cache is just a cache. A simple Codable snapshot in Documents/ plus a memory-cached array is enough; the SwiftData migration tax is not worth paying if the data does not survive a hard reset.

Multi-process coordination. SwiftData operates inside a process. An MCP server running outside the iOS app cannot read or write the app’s SwiftData container. Cross-process state needs a different shape: an iCloud Drive JSON file, a shared App Group container, or an explicit synchronization layer that bridges processes. (Get Bananas pairs SwiftData with iCloud Drive JSON for exactly this reason.)6

The data is large blobs that change rarely. A 10MB audio file, a 50MB image dataset. Use @Attribute(.externalStorage) if the blobs are inside SwiftData rows; otherwise use the filesystem directly with metadata in SwiftData pointing to file URLs.

What The Pattern Means For Apps Shipping On iOS 26+

Three takeaways.

  1. The macros are the easy part. The migrations are the cost. @Model and @Attribute are two-line declarations that hide a lot of Core Data plumbing. Migration discipline is what you actually pay for over the app’s lifetime; design v1 with v2 in mind.

  2. VersionedSchema from day one is non-negotiable for shipping apps. The wrapping enum is one extra file. The retroactive cost of adding it later is much higher.

  3. Optional fields and explicit relationships are the cheap insurance. Optional timestamps for sync metadata, explicit deleteRule and inverse: on relationships. Both are tiny declarations that buy a lot of v2 flexibility.

The full Apple Ecosystem cluster: typed App Intents for Apple Intelligence; MCP servers for cross-LLM agents; the routing question between them; Foundation Models for on-device LLM and the Tool protocol; Live Activities for the Lock Screen state machine on iOS; the watchOS runtime contract on Apple Watch; SwiftUI internals for the framework substrate; RealityKit’s spatial mental model for visionOS scenes; Liquid Glass patterns for the visual layer; multi-platform shipping for cross-device reach. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

FAQ

What’s the difference between @Model and Core Data’s NSManagedObject?

@Model is a Swift macro that generates the NSManagedObject plumbing under the hood. SwiftData uses Core Data as its backing store, so the runtime model is the same; the difference is the surface. @Model removes the .xcdatamodeld file, the value-transformer ceremony, and the NSManagedObjectContext lifecycle management. You get the same persistent store with a Swift-shaped API.

Do I need VersionedSchema if I never plan to change the schema?

If your app might ship a v2, yes. If it is a one-shot demo, no. The cost of VersionedSchema from v1 is one extra enum declaration. The cost of adding it retroactively at v2 is matching the exact v1 schema shape so the framework recognizes existing data, which is doable but error-prone. Most shipping apps will eventually need a schema change; budget for it in v1.

When should I use @Attribute(.unique)?

When the field is a natural key for the row: a UUID you generated, an external ID you imported, a slug you assigned. SwiftData treats .unique as upsert: if you insert a model whose .unique value already exists, the existing row is updated rather than a new row appended. That semantics is what makes upsert-style sync paths (the same UUID coming from two devices) safe; it is also why .unique is the wrong tool on display-name fields like title, because two users typing the same title would silently merge their rows instead of producing two distinct records.

How do I handle a non-optional field added to an existing schema?

Use a MigrationStage.custom with a didMigrate closure that populates the field on existing rows. Or, easier: declare the field as optional in the new schema version and lazily fill it on access. Optionality is the cheaper migration; non-optional adds need explicit population logic.

What’s PersistentIdentifier vs my own UUID?

PersistentIdentifier is SwiftData’s in-process row ID; it is generated automatically and survives the lifetime of the running process. Your own UUID with @Attribute(.unique) is a stable cross-process, cross-device identifier. Use PersistentIdentifier for in-process references inside the app. Use a UUID for anything that crosses a process boundary (cross-device sync, external integrations, MCP tools, network calls).

References


  1. Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The ShoppingItem model evolved across the early development cycle; the lastModified: Date? field was added after the initial schema (commit 268a00d on 2025-12-01, “Make lastModified optional to fix migration crash”) because making it non-optional broke migration when existing rows had no value to populate it. 

  2. Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The @Model macro, the @Attribute constraint surface, and the relationship to Core Data’s NSManagedObjectModel

  3. Apple Developer, “Preserving your app’s model data across launches” and “Adopting SwiftData for a Core Data app”. Lightweight migration semantics and what triggers the framework to bail. 

  4. Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the ModelContainer constructor that takes a migration plan. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The @Relationship macro, deleteRule options (.cascade, .nullify, .deny, .noAction), and the role of the inverse: parameter in bidirectional relationship maintenance. 

  6. Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and Five Apple Platforms, Three Shared Files. The Get Bananas + Return cross-process and cross-device sync patterns that complement (and sometimes replace) SwiftData inside a multi-process workflow. 

Related Posts

Two Agent Ecosystems, One Shopping List: An MCP Server Living Alongside an iOS App

Get Bananas runs on iOS, macOS, watchOS, visionOS. It also lives inside Claude Desktop as an MCP server. The bridge is i…

19 min read

Foundation Models On-Device LLM: The Tool Protocol

iOS 26's Foundation Models framework puts a 3B-parameter LLM on every Apple Intelligence device. The Tool protocol is th…

15 min read

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min read