SwiftData in Production: What Changes from Core Data and What Stays the Same
SwiftData is not a replacement for Core Data in the way SwiftUI replaced UIKit. It is a Swift-native API layer built directly on the same SQLite-backed persistent store Core Data has always used. Understanding what actually changes — and what does not — is what makes the difference between a smooth adoption and a production failure.
The structural shift SwiftData introduces
SwiftData is not a replacement for Core Data in the way SwiftUI replaced UIKit. It is a Swift-native API layer built directly on top of the same SQLite-backed persistent store Core Data has always used. The constraint that shaped everything in Core Data — that persistence is fundamentally a graph problem with identity, relationships, and change tracking — still shapes SwiftData.
What changes is where that complexity surfaces. Core Data externalises it into NSManagedObject subclasses, NSFetchRequest configurations, and NSManagedObjectContext lifecycle management. SwiftData internalises it into macros, property wrappers, and Swift's type system.
That shift has real consequences in production. Some are improvements. Some introduce new failure modes that are harder to see precisely because the abstraction hides them.
What stays the same
The persistent store
The on-disk format is identical. A SwiftData store is a Core Data store — you can open the same .sqlite file with either framework. This matters for migration paths: an existing Core Data app can adopt SwiftData incrementally, and both stacks can coexist in the same process using ModelContainer alongside NSPersistentContainer.
The SQLite WAL journal, the row cache, the persistent history tracking mechanism — none of that changes. Store-level performance characteristics carry over directly.
CloudKit sync
SwiftData's CloudKit integration maps to the same NSPersistentCloudKitContainer infrastructure Core Data uses. The sync semantics are identical: records mirror to CloudKit's private database, conflict resolution uses last-write-wins at the record level, and background sync is driven by NSPersistentStoreRemoteChange notifications.
The constraints that apply to Core Data + CloudKit apply equally here: every attribute must be optional, relationships must be non-ordered, and the schema must stay within CloudKit's record size limits. None of that is abstracted away.
Concurrency constraints
The actor-isolation model in SwiftData is stricter at the API surface but identical in its underlying constraint: a model object is bound to the context that created it and must not be accessed from a different concurrency domain without coordination.
In Core Data, violating this produces a runtime crash or silent data corruption. In SwiftData, the Swift 6 compiler can catch some of these violations at compile time — but only when the model type and its context are correctly isolated. The constraint is the same. The enforcement mechanism is different.
What changes
Schema declaration
Core Data defines its schema in an .xcdatamodeld file — a binary XML document Xcode renders as a visual editor. SwiftData declares schema in Swift source using the @Model macro.
@Model
final class Shipment {
var id: UUID
var status: String
var dispatchedAt: Date
var destination: String?
@Relationship(deleteRule: .cascade, inverse: \LineItem.shipment)
var items: [LineItem] = []
init(id: UUID, status: String, dispatchedAt: Date) {
self.id = id
self.status = status
self.dispatchedAt = dispatchedAt
}
}
The macro generates the NSManagedObject subclass, the entity description, and the attribute metadata at compile time. The schema lives in version control alongside the code that uses it — which eliminates the class-model synchronisation errors that were a persistent source of friction in Core Data development.
The tradeoff: the visual editor is gone. For complex schemas with many relationships, the .xcdatamodeld editor made the graph navigable. With SwiftData, that graph exists only in source files.
The ModelContainer and ModelContext
Core Data's NSPersistentContainer and NSManagedObjectContext become ModelContainer and ModelContext in SwiftData. The roles are the same. The API surface is smaller.
let container = try ModelContainer(
for: Shipment.self, LineItem.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: false)
)
SwiftUI apps receive a modelContext environment value automatically when a ModelContainer is attached to the scene. This removes the boilerplate of injecting a context through the view hierarchy manually. In production, the consequence is that the context lifecycle is tied to the SwiftUI environment — correct for most use cases, but requiring explicit handling for background work.
Fetching data
NSFetchRequest becomes the @Query macro in SwiftUI views, and ModelContext.fetch(_:) for imperative use.
@Query(sort: \Shipment.dispatchedAt, order: .reverse)
var shipments: [Shipment]
@Query is a property wrapper that observes the store and re-renders the view when matching records change. It replaces NSFetchedResultsController for the common case. The predicate syntax uses Swift's #Predicate macro rather than NSPredicate format strings — predicate errors surface at compile time rather than at runtime.
The constraint: #Predicate does not cover the full expressiveness of NSPredicate. Subquery predicates, aggregate functions, and some string operations are not supported. For complex filtering requirements, you drop to NSPredicate via a FetchDescriptor initialiser that accepts a raw predicate string — which reintroduces the runtime-error surface #Predicate was designed to eliminate.
Relationships and cascade rules
Relationships in SwiftData are declared as typed Swift properties. Delete rules are set via the @Relationship macro attribute.
@Relationship(deleteRule: .cascade, inverse: \LineItem.shipment)
var items: [LineItem] = []
The available delete rules — .nullify, .cascade, .deny — map directly to Core Data's delete rules. The inverse relationship must be declared explicitly. Omitting it does not produce a compiler error; it produces a runtime warning and potentially inconsistent graph state. This is one of the places where SwiftData's abstraction does not make the constraint visible enough.
Production constraints SwiftData does not solve
Migration
Lightweight migration in Core Data is automatic when the changes qualify: adding optional attributes, renaming entities with a mapping model, removing attributes. SwiftData supports the same lightweight migration path via VersionedSchema and SchemaMigrationPlan.
enum ShipmentSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Shipment.self] }
}
enum ShipmentMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[ShipmentSchemaV1.self, ShipmentSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: ShipmentSchemaV1.self,
toVersion: ShipmentSchemaV2.self
)
}
The mechanism is cleaner than Core Data's mapping model editor. The underlying constraint is not: migrations outside the lightweight rules still require custom stage implementations, and a failed migration on a user's device still produces a store that cannot be opened. The consequences of getting migration wrong are identical.
Background processing
Background import or batch operations require a separate ModelContext created from the same ModelContainer. That context runs on the calling actor. For true background execution, the work must be dispatched to a background actor or a Task with appropriate isolation.
func importRecords(_ data: [ShipmentData]) async {
let container = modelContainer
await Task.detached {
let context = ModelContext(container)
for record in data {
context.insert(Shipment(from: record))
}
try? context.save()
}.value
}
The pattern is cleaner than Core Data's performBackgroundTask API. The constraint — that context access must be actor-isolated — is unchanged.
Predicate expressiveness
This is the most significant regression from Core Data. NSPredicate supports subqueries, aggregate expressions, relationships traversal, and complex compound conditions that #Predicate does not. Production apps that require complex queries will encounter this ceiling.
The workaround — dropping to raw NSPredicate strings — works, but it reintroduces the runtime error surface that SwiftData's type-safe predicates were designed to eliminate.
When to choose SwiftData over Core Data
SwiftData is the right default for new projects with iOS 17+ deployment targets. The schema-in-code model and compile-time predicate safety eliminate a category of bugs that Core Data's format-string predicates and .xcdatamodeld synchronisation regularly produced.
Use Core Data when:
- The deployment target is below iOS 17
- Predicate complexity exceeds
#Predicateexpressiveness - The existing migration history makes
.xcdatamodeldthe source of truth - Fine-grained context control or custom fetch request configurations are required
Use SwiftData when:
- The deployment target is iOS 17+
- The team wants schema and model code in the same file
- Compile-time predicate safety is worth the expressiveness tradeoff
- The app is being built from scratch without an existing Core Data schema
For existing Core Data apps, SwiftData adoption is incremental. Both stacks can coexist in the same process. Migrate entities one at a time as the benefit is clear, rather than attempting a full migration under deadline pressure.