# SwiftData in Production: 6 Months of Real-World Experience and 3 Scenarios That Sent Us Back to Core Data
SwiftData arrived with iOS 17, and at WWDC23 Apple presented it as "the modern successor to Core Data." After six months in production, here is the verdict: SwiftData is genuinely ergonomic, but it cannot fully replace Core Data. On some projects we went back. In this article we cover which ones, why, and the decision matrix to use when weighing the move.
Pro Tip: Before you start a SwiftData migration, leave your Core Data store unrenamed. SwiftData keeps the name 'Default.store', and if you ever need to roll back, the original name is critical.
Table of Contents
- What SwiftData Is — and What It Isn't
- Core Data → SwiftData Migration Strategy
- The
@ModelMacro: Inner Mechanics - Querying:
@QueryvsFetchDescriptorvs the Predicate DSL - Concurrency:
@ModelActorand Its Pitfalls - Performance Baseline — Real Numbers
- iCloud Sync: SwiftData CloudKit Integration
- 3 Scenarios That Sent Us Back to Core Data
- Migration Decision Matrix
- The 2026-2027 SwiftData Roadmap
1. What SwiftData Is — and What It Isn't
SwiftData is a Swift-native abstraction layer built on top of Core Data. Let's be explicit:
- Not a new database engine — it uses SQLite + the Core Data store
- Pure-Swift API — no Objective-C interop
- Macro-driven —
@Model,@Query, and@Attributedo their work at compile time - Native to modern concurrency —
async/awaitfirst - Type-safe predicates — the
#Predicatemacro killed off KVC strings
What it can't do:
- You can't use a custom NSManagedObject subclass
- No programmatic model migration (lightweight only, automatic)
- NSPersistentHistoryToken — change tracking isn't at the same level
- No cross-store fetch (Core Data multi-context)
- NO backwards compatibility before iOS 17
2. Core Data → SwiftData Migration Strategy
We had three different scenarios, each with its own strategy:
Scenario A — Greenfield iOS 17+ project (TahminApp v3):
1// Direkt SwiftData2@Model3final class Prediction {4 var id: UUID5 var title: String6 var createdAt: Date7 8 init(id: UUID = .init(), title: String, createdAt: Date = .now) {9 self.id = id10 self.title = title11 self.createdAt = createdAt12 }13}14 15let container = try ModelContainer(for: Prediction.self)Scenario B — iOS 17+ minimum, existing Core Data app (ESPPoint v4):
Convert the Core Data .xcdatamodeld file into a SwiftData mapping. Apple offers the Schema(versionedSchema:) and ValueTransformer APIs for this pattern:
1let container = try ModelContainer(2 for: Schema([Device.self, Reading.self]),3 migrationPlan: ESPPointMigrationPlan.self4)Scenario C — iOS 15+ support required (MADPAW):
Stay on Core Data. SwiftData requires iOS 17 as a minimum, and you can't afford to lose your existing users.
3. The `@Model` Macro: Inner Mechanics
The @Model Swift macro derives an NSManagedObject subclass at compile time. Inspection:
1@Model2final class User {3 var id: UUID4 var email: String5 @Attribute(.unique) var username: String6 var posts: [Post] = []7 8 init(id: UUID = .init(), email: String, username: String) {9 self.id = id10 self.email = email11 self.username = username12 }13}Macro expansion:
Userfinal class → inherits from NSManagedObjectvar id→@NSManagedgetter/setter + KVC keypath@Attribute(.unique)→ store-level unique constraintvar posts→ to-many relationship + automatic inverse
Performance cost: Compile time +5-8s per 10 models. Build cache is effective. Runtime overhead is nil.
Pro Tip: @Attribute(.transformable(by: ...)) is the equivalent of Core Data's transformable attribute. It's critical for custom Codable types. JSON-style data isn't supported by default in SwiftData.4. Querying: `@Query` vs `FetchDescriptor` vs the Predicate DSL
There are three query APIs, each with its own place:
1. `@Query` — SwiftUI view-level:
1struct PostListView: View {2 @Query(filter: #Predicate<Post> { $0.isPublished == true },3 sort: \.createdAt, order: .reverse)4 var posts: [Post]5 6 var body: some View {7 List(posts) { post in PostRow(post: post) }8 }9}2. `FetchDescriptor` — programmatic:
1let descriptor = FetchDescriptor<Post>(2 predicate: #Predicate { $0.authorID == userID },3 sortBy: [SortDescriptor(\.createdAt, order: .reverse)]4)5descriptor.fetchLimit = 506descriptor.propertiesToFetch = [\.title, \.excerpt] // partial fetch7 8let posts = try modelContext.fetch(descriptor)3. The `#Predicate` macro — type-safe filtering:
1// Eski Core Data: NSPredicate(format: "title CONTAINS[cd] %@ AND year > %d", search, 2023)2// SwiftData: type-safe3let pred = #Predicate<Post> { post in4 post.title.localizedStandardContains(search) && post.year > 20235}Compile-time errors, no string typos, autocomplete.
Gotcha:#Predicatesupports Foundation closures (.contains,.starts(with:), etc.) but not every Swift function. Computed properties can't be used — you need a@Transientmark or a stored property.
5. Concurrency: `@ModelActor` and Its Pitfalls
SwiftData aims for full compatibility with Swift 6 strict concurrency. @ModelActor is the new primitive:
1@ModelActor2actor BackgroundDataProcessor {3 func batchInsert(_ items: [ItemDTO]) async {4 for item in items {5 modelContext.insert(Item(dto: item))6 }7 try? modelContext.save()8 }9}10 11// Kullanım — main'den uzakta12let processor = BackgroundDataProcessor(modelContainer: container)13await processor.batchInsert(largeDataset)Pitfall 1: @ModelActor actor isolation is strict — you can't copy models out of the actor. The result either has to be a Sendable struct, or it has to be re-fetched via its PersistentID:
1// ❌ Yanlış — Item Sendable değil (NSManagedObject)2let items = await processor.fetchAll() // hata3 4// ✅ Doğru — ID return + main context'te re-fetch5let ids: [PersistentIdentifier] = await processor.fetchAllIDs()6let mainContext = container.mainContext7let items = ids.compactMap { mainContext.model(for: $0) as? Item }Pitfall 2: A save on a background context doesn't automatically invalidate the main context. Listen with NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave), or drive a reactive UI with @Observable.
6. Performance Baseline — Real Numbers
On an A17 Pro / iOS 18, with a 100K row Post entity:
Operation | Core Data | SwiftData | Delta |
|---|---|---|---|
Insert (batch 1000) | 240ms | 280ms | +17% |
Fetch all + filter | 180ms | 195ms | +8% |
Fetch + sort + limit 50 | 45ms | 52ms | +15% |
Save context | 35ms | 38ms | +9% |
App cold launch (DB load) | 280ms | 310ms | +11% |
Interpretation: SwiftData is roughly 10-15% slower than Core Data, because of macro overhead and NSManagedObject wrapping. For most apps this is negligible. It can matter on data-heavy (1M+ row) projects.
Memory: SwiftData containers hold about 15-20MB more baseline. iOS 18 closed part of that gap.
7. iCloud Sync: SwiftData CloudKit Integration
SwiftData + CloudKit:
1let modelConfiguration = ModelConfiguration(2 schema: Schema([Item.self]),3 cloudKitDatabase: .private("iCloud.com.muhittincamdali.app")4)5let container = try ModelContainer(6 for: Item.self,7 configurations: modelConfiguration8)Automatic sync: Inserts, updates and deletes are pushed to CloudKit. Conflict resolution defaults to last-writer-wins.
Pitfalls:
- Schema changes have to be deployed manually from the CloudKit dashboard
- The CloudKit
record_typehas to match the SwiftData entity exactly - SwiftData doesn't support the public database yet — private + shared only
- The initial sync doesn't show the user a "Loading from iCloud..." spinner — build your own UX
8. 3 Scenarios That Sent Us Back to Core Data
Over six months we moved three projects back from SwiftData to Core Data. The reasons:
Scenario 1: 1.5M+ rows, complex queries
An e-commerce inventory app with 1.5M products and multi-level filtering (category × brand × price × stock × tag). On complex compound conditions, SwiftData's #Predicate ran 25-40% slower than Core Data. For this app that was unacceptable.
Scenario 2: Custom NSManagedObject behavior
A banking app needed audit logging, validation hooks (willChangeValue) and a custom description override. SwiftData's @Model blocks all of these. After the migration we needed override subclasses in 47 places — trivial in Core Data.
Scenario 3: Persistent History Tracking
A cross-device, offline-first app tracks sync state with NSPersistentHistoryToken. SwiftData offers no comparable API — in particular there's no equivalent of fetchHistory(after: token). Implementing it by hand takes 200+ lines.
Pro Tip: Before putting SwiftData into production, evaluate your data scale and your special requirements. <100K rows + standard CRUD → SwiftData. 1M+ rows or custom behavior → Core Data.
9. Migration Decision Matrix
Criterion | SwiftData | Core Data |
|---|---|---|
iOS 17+ exclusive is OK | ✅ | ✅ |
iOS 15-16 support required | ❌ | ✅ |
<100K rows | ✅ | ✅ |
1M+ rows | ⚠️ measure | ✅ |
Custom NSManagedObject behavior | ❌ | ✅ |
CloudKit private DB sync | ✅ | ✅ |
Persistent history tracking | ❌ | ✅ |
Type-safe predicates | ✅ | ❌ (string-based) |
Native SwiftUI binding | ✅ | ⚠️ wrapper |
Migration backwards compat | ⚠️ | ✅ |
Programmatic model | ❌ | ✅ |
Native async/await | ✅ | ✅ (iOS 15+) |
Rule: If there are 3+ red boxes on the Core Data side → take the SwiftData route.
If there are 3+ red boxes on the SwiftData side → Core Data is the safer bet.
10. The 2026-2027 SwiftData Roadmap
Signals picked up at WWDC25:
- Q3 2026: Custom model migration API (programmatic plan)
- Q4 2026: Public CloudKit database support
- Q1 2027: NSPersistentHistoryToken-equivalent change tracking
- Q2 2027: Index hint API (compound + ordered index)
- Q3 2027: Sequence sync (offline queue with conflict UI)
- Q4 2027: SwiftData 2.0 — native schema versioning
Direction of travel: Apple is continuing to invest in SwiftData. Within 18-24 months, all of Core Data's strengths could be present in SwiftData. The gap is closing right now, but it isn't closed.
Conclusion
SwiftData is the right call for iOS 17+ projects — modern Swift, type-safe predicates, SwiftUI bindings and less boilerplate. But for scenarios that involve 1M+ rows of data, custom NSManagedObject behavior or persistent history, Core Data still wins.
The pragmatic decision: new project + iOS 17+ minimum + standard CRUD → SwiftData. Existing Core Data + working production → run it through the decision matrix before migrating. If there are 3+ blockers, stay where you are.
Once you've cleared the decision matrix, budget 2-4 weeks for the migration and 1 week for the schema migration. Watch production crashes closely for 2-3 weeks — making sure existing users lose no data during the migration is critical.
Related resources:
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

