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 full migration model, including which changes are automatic, which need a declared stage, and the checksum crash you hit by declaring a V2 you did not need, is the subject of the companion piece, SwiftData migrations: lightweight vs custom.

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.

Crossing An Actor Boundary: Send The Identifier, Not The Graph

A @Model class is not Sendable, and the right move is to stop trying to make it one. The instance is a reference into a live object graph held by a ModelContext; the framework cannot promise that graph is safe to read from another actor, so the type is deliberately left non-Sendable. Forcing the conformance does not make the data race go away; it hides it.7

The pattern that works is to send the identity and the plain values, then re-fetch on the other side. PersistentIdentifier is Sendable, so it crosses the boundary cleanly. Pull out whatever scalar values the destination needs (a name, a flag, a delta in a small struct), pass those alongside the identifier, and have the receiving actor re-fetch the model from its own context using the identifier:

// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)

// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem

The failure mode to avoid is passing the model graph itself. When part of the graph crosses the boundary, the receiver gets a model that partially hydrates on the far side: relationships and lazily loaded properties that were never faulted in on the source context resolve against the wrong context (or not at all), and the bugs that follow are the quiet kind. The identifier plus extracted values is the safe contract; the graph is not. A ModelActor encapsulates this discipline by owning a context and vending values rather than instances.7

CloudKit Sync And The App-Group Entitlement Trap

Moving a SwiftData store into an app-group container so a widget or extension can read it interacts with CloudKit sync in a way that bites apps after they ship. Two facts make the rest follow.

First, the store location. With the default ModelConfiguration, SwiftData copies the existing store into the app-group container for you when an app evolves from no-group to app-group; Apple’s wording is that SwiftData “copies the existing store to the app group container.”8 With a custom store URL, you own the location: you copy the file to the new container and point the configuration at it yourself. The default path is the convenient one precisely because the framework does the copy; the custom path trades that convenience for control.

Second, the entitlement. Every app-group member that reads a CloudKit-synced store must carry the same CloudKit entitlement, because each of those processes will sync that container on its own behalf. That requirement is the trap: a widget or extension does not have the runtime budget or the foreground window to drive a verbose sync, and handing it the CloudKit entitlement forces it to try. The fix is to split into two ModelConfiguration instances: one synced store (CloudKit entitlement, owned by the main app) and one local store in the app-group container that the widget and extensions read without ever syncing. Put the sync where a foregrounded app can do it well, and keep the shared-for-reading data out of the sync path.8

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.

When Core Data Still Wins

SwiftData is a layer over Core Data, not a replacement for all of it, and three years in, a specific set of jobs still belongs to the older framework. Choosing Core Data for one of these is not a legacy decision; it is the correct current one.

Database-side aggregates. SwiftData has no equivalent to Core Data’s NSExpression-based fetches, the ones that push sum, average, min, and max into SQLite so the database computes them without loading rows9. In SwiftData you fetch and reduce in memory, which defeats the point on a large table. The documented escape hatch is coexistence: Apple describes running “two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store,” which lets the Core Data side run the SQL-pushed aggregate against the file SwiftData owns9. The mechanics, including the NSPersistentHistoryTrackingKey requirement, are covered in SwiftData performance is a storage problem.

Sharing and the public CloudKit database. SwiftData’s automatic iCloud sync rides NSPersistentCloudKitContainer under the hood, and the container it configures mirrors your store to the user’s private CloudKit database10. Collaboration between different iCloud users through CKShare, and publishing to the public database, are documented Core Data + CloudKit capabilities with no SwiftData-level API as of the iOS 27 betas10. An app whose core feature is shared lists or collaborative documents either drops to Core Data for the synced stores or builds the CloudKit layer by hand.

Batch updates at the store level. Core Data’s NSBatchUpdateRequest rewrites matching rows directly in the store without loading objects11. SwiftData’s ModelContext has the delete half (delete(model:where:) takes a predicate) but no batch-update counterpart, so a mass field rewrite in SwiftData means materializing every affected model.

A deployment floor below iOS 17. SwiftData requires iOS 17; Core Data reaches back as far as any app still supports, and its CloudKit container back to iOS 131012. A codebase with a long OS tail does not get to choose.

Worth naming what fell off this list: “you need NSFetchedResultsController outside a view” was a Core Data win until the iOS 27 betas added ResultsObserver, which observes a fetch anywhere in the app through Swift Observation13. The gap list shrinks release by release. The way to bet: start new apps on SwiftData, adopt coexistence for the specific jobs above, and treat a full Core Data stack as the answer only when sharing, the public database, or the deployment floor forces it.

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).

When should I still choose Core Data over SwiftData?

Four cases as of the iOS 27 betas: database-side aggregates (NSExpression fetches that SwiftData lacks), sharing between iCloud users via CKShare or the public CloudKit database (SwiftData’s sync covers the private database), store-level batch updates (NSBatchUpdateRequest), and deployment targets below iOS 1791011. For the aggregate case you do not have to abandon SwiftData: run a coexisting Core Data stack against the same store file.

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. 

  7. Apple Developer, “PersistentIdentifier” (conforms to Sendable) and “ModelActor”. The SwiftData team confirmed during the WWDC 2026 SwiftData Group Lab that @Model objects are not Sendable and should not be forced to conform, because they are a reference graph living inside a context; the recommended boundary contract is to pass the Sendable PersistentIdentifier plus extracted plain values and re-fetch on the destination context, and that passing the model graph leaves the receiver with a partially hydrated object. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”, which states that with the default configuration “SwiftData copies the existing store to the app group container,” while a custom store URL leaves the location for you to manage. The CloudKit-entitlement requirement for app-group members and the two-ModelConfiguration split (one synced, one local) for keeping widgets and extensions out of the sync path were described during the WWDC 2026 SwiftData Group Lab. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. 

  9. Apple, WWDC 2023 session 10189, “Migrate to SwiftData”, source of the coexistence framing (“two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store”), and Apple Developer, “NSExpression”, the mechanism behind Core Data’s SQL-pushed aggregate fetches, which SwiftData provides no equivalent to. The gap was confirmed by the SwiftData engineering panel at the WWDC 2026 SwiftData Group Lab (paraphrased from a locally transcribed recording). 

  10. Apple Developer, “Syncing model data across a person’s devices”, which states that “SwiftData uses the NSPersistentCloudKitContainer class from Core Data to handle CloudKit synchronization”; “NSPersistentCloudKitContainer” (iOS 13.0+), whose abstract describes mirroring “select persistent stores to a CloudKit private database”; and “Sharing Core Data objects between iCloud users”, the documented Core Data path for CKShare-based collaboration. The SwiftData documentation exposes no sharing or public-database API as of the iOS 27 betas. 

  11. Apple Developer, “NSBatchUpdateRequest”, and “ModelContext.delete(model:where:includeSubclasses:)”, SwiftData’s predicate-based batch delete. SwiftData’s ModelContext documentation lists no batch-update counterpart. 

  12. Platform availability per Apple Developer documentation: SwiftData (iOS 17.0+) and Core Data (iOS 3.0+). 

  13. Apple Developer, “ResultsObserver” (iOS 27.0 beta), which “observes and tracks changes to a collection of persistent models in a model context” and conforms to Observable, serving the outside-a-view observation role that previously required Core Data’s NSFetchedResultsController

Related Posts

SwiftData Migrations: Lightweight vs Custom, And When You Don't Need a V2

SwiftData's migration model uses VersionedSchema, MigrationStage, and SchemaMigrationPlan. Most schema changes don't nee…

17 min read

SwiftData in iOS 27: Observation and History

iOS 27 gives SwiftData first-class change observation with ResultsObserver, persistent-history observation with HistoryO…

13 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