Why I Chose SwiftData Over Core Data for 3 Production Apps
I’ve shipped three iOS apps with SwiftData: Mati (a calendar app), TechSheet (vehicle maintenance tracker), and Fescue (service business management). All three use SwiftData with CloudKit sync in production. Two more apps in my portfolio — Pressed and Forecastly — don’t need local persistence, so they sit this one out.
This isn’t a SwiftData tutorial. It’s what I’ve learned putting it into production code that real people use, and whether I’d make the same decision today.
What tipped the scales
Core Data works. It’s been battle-tested for over a decade. But when I started Fescue in early 2026, I was staring at the reality of writing NSManagedObject subclasses, maintaining .xcdatamodeld files, and wiring up NSPersistentCloudKitContainer boilerplate — all to get something SwiftData gives you with a macro and a configuration flag.
I target iOS 26 exclusively across all my apps. No backwards compatibility. That meant I could lean fully into SwiftData without worrying about runtime availability checks. For a solo developer with maybe 10-15 hours a week, every hour not spent on boilerplate goes directly into features.
What SwiftData gets right
The @Model macro eliminates real boilerplate. In TechSheet, my Vehicle model has relationships to maintenance records, documents, and schedules — all declared as plain Swift properties:
@Model
public final class Vehicle {
public var make: String = ""
public var model: String = ""
@Relationship(deleteRule: .cascade, inverse: \MaintenanceRecord.vehicle)
public var maintenanceRecords: [MaintenanceRecord]? = []
}
No managed object context ceremony. No @NSManaged. Just a class with a macro. The cascade delete rules and inverse relationships work exactly how you’d expect.
Swift-native predicates are a genuine improvement. In TechSheet I query non-deleted vehicles throughout the app. With SwiftData, that’s a type-checked expression the compiler validates:
@Query(filter: #Predicate<Vehicle> { !$0.isDeleted })
private var vehicles: [Vehicle]
Compare that to Core Data’s NSPredicate(format: "isDeleted == NO") — a stringly-typed format that crashes at runtime if you typo a key path. I have #Predicate queries across all three apps and I’ve never had a runtime predicate error. That alone is worth the switch.
Observable conformance for free. Every @Model class automatically conforms to Observable, which means @Bindable just works in SwiftUI. In TechSheet’s vehicle detail view, I pass a Vehicle directly and bind to its fields — no view model wrapper, no manual objectWillChange publisher:
@Bindable var vehicle: Vehicle
CloudKit sync with near-zero configuration. The entire CloudKit setup in TechSheet is one ModelConfiguration:
let modelConfiguration = ModelConfiguration(
schema: schema,
cloudKitDatabase: .automatic
)
That’s it. No NSPersistentCloudKitContainer. No mirror configuration. Apple handles the database, the sync engine, the conflict resolution, and the servers. For all three apps, my backend infrastructure cost is zero. For indie apps, this is the killer feature.
What still hurts
Enums need a raw-value workaround. SwiftData can’t persist custom Swift enums directly. Across all three apps, I use the same pattern — a private raw string property with a computed wrapper:
private var maintenanceTypeRaw: String = MaintenanceType.oilChange.rawValue
public var maintenanceType: MaintenanceType {
get { MaintenanceType(rawValue: maintenanceTypeRaw) ?? .custom }
set { maintenanceTypeRaw = newValue.rawValue }
}
This pattern repeats for every enum in every model. TechSheet has it for MaintenanceType, DocumentType, and PDFTemplateStyle. Fescue has it for ClientType, PropertyType, ServiceStatus, InvoiceStatus, PaymentMethod, and LineItemType. It works, but it’s the kind of boilerplate SwiftData was supposed to eliminate.
The migration story is immature. TechSheet has a VersionedSchema and SchemaMigrationPlan set up for future migrations, but right now it’s just V1 with an empty stages array. I’m dreading the day I need to rename a property or change a relationship. Lightweight migrations for additive changes work fine. Anything more complex and you’re in poorly-documented territory where the consequences of getting it wrong are corrupting user data.
Debugging is harder than Core Data. With Core Data, you could open the .sqlite file in a browser and see exactly what’s stored. With SwiftData, that option is effectively gone. When I hit a data issue in Fescue, I end up writing throwaway FetchDescriptor queries in the debugger to inspect state. It works, but it’s slower and less ergonomic.
CloudKit sync is a black box. When sync works — which is most of the time — it’s invisible and great. When it doesn’t, you have almost no visibility into why. In Mati, I listen for NSPersistentStoreRemoteChange notifications to refresh local state after a CloudKit sync, and that works reliably. But when a user reports that something isn’t syncing across devices, my debugging toolkit is basically “did you check your iCloud account?” and “try toggling iCloud Drive off and on.”
Real model complexity
These aren’t todo apps. Fescue has six @Model classes: BusinessProfile, Client, Property, Service, Invoice, and InvoiceLineItem — with cascade deletes for owned children and nullify for cross-references. TechSheet has five: Vehicle owning MaintenanceRecord, MaintenanceSchedule, and Document through cascade relationships, plus a UserProfile singleton. SwiftData handles these entity graphs without issues.
The CloudKit angle for indie developers
Mati, TechSheet, and Fescue all sync across devices with zero server infrastructure. No database to provision, no API to maintain, no hosting bill. Apple’s free CloudKit tier is generous enough that costs are a non-issue at indie scale.
For a solo developer shipping nights and weekends, the alternative is building a backend — ongoing costs, security responsibilities, and ops work stealing time from features. SwiftData + CloudKit lets me skip all of that.
Would I choose SwiftData again?
Yes. For any new iOS app targeting the current OS, SwiftData is the right choice. The developer experience is meaningfully better than Core Data, the CloudKit integration eliminates backend infrastructure, and the things that still hurt are inconveniences rather than blockers.
The enum workaround is annoying but mechanical. The migration story will mature. The debugging story will improve as tooling catches up. None of these are reasons to go back to Core Data for a new project.
The one case where I’d still consider Core Data is if I needed to support older OS versions or needed fine-grained control over the sync process. For everything else, SwiftData with CloudKit is the best infrastructure decision I’ve made as an indie developer.