Mastering SwiftData: Migrating Core Data Apps in SwiftUI
SwiftData was introduced with iOS 17 as Apple's Swift-native approach to persistence. Since then, it has continued to evolve, and iOS 26 adds important capabilities such as model inheritance, while the broader SwiftData API has continued to improve around schema migration, querying, relationships, indexing, and persistent history.
For teams maintaining a production application built with Core Data, moving to SwiftData doesn't mean throwing away the existing persistence layer and starting from scratch. The migration can be approached incrementally, allowing you to preserve existing data while gradually adopting SwiftData's APIs.
This guide explains how to migrate a legacy Core Data application to SwiftData in a modern SwiftUI codebase. We'll cover model conversion, ModelContainer, @Query, schema versioning, SchemaMigrationPlan, lightweight migrations, and the iOS 26 model inheritance capabilities.
The goal isn't simply to replace Core Data APIs. It's to modernize the persistence layer without putting existing user data at risk.
Prerequisites
For the examples in this article, you'll need:
- Xcode 26 or later
- iOS 26 SDK
- Swift and SwiftUI from the current Xcode toolchain
- An existing application using Core Data
- A persistent Core Data store, typically backed by SQLite
If your application supports earlier iOS versions, make sure iOS 26-specific APIs are protected with the appropriate availability checks.
Why Migrate from Core Data to SwiftData?
Core Data remains a capable persistence framework, so migration isn't something you should do simply because SwiftData is newer.
The strongest reasons to migrate are usually related to the development experience.
SwiftData provides:
- Swift-native model definitions
- Declarative queries through
@Query - Direct SwiftUI integration
- Type-safe model relationships
- Versioned schemas
- Migration plans
- Modern Swift language features
- Less framework-specific model boilerplate
Instead of defining an NSManagedObject subclass and configuring fetch requests around it, you can define a persistent model using the @Model macro and work with it directly from Swift code.
However, if your application has a large and stable Core Data implementation, a full rewrite may introduce unnecessary risk. An incremental migration can be a better approach.
Core Data vs. SwiftData
The APIs look different even though both frameworks solve the same fundamental problem: managing persistent application data.
| Core Data | SwiftData |
|---|---|
NSManagedObject | @Model |
NSPersistentContainer | ModelContainer |
NSManagedObjectContext | ModelContext |
NSFetchRequest | FetchDescriptor |
@FetchRequest | @Query |
| Mapping models/custom migration | VersionedSchema + SchemaMigrationPlan |
SwiftData provides a Swift-native persistence API and integrates closely with SwiftUI. ModelContainer manages the schema and persistent storage, while ModelContext provides the environment for fetching, inserting, deleting, and saving models.
The migration therefore isn't just an API replacement. It is also a change in how your application defines and interacts with its persistence model.
Should You Migrate Everything at Once?
Usually, no.
For a small application, a complete conversion may be manageable. For a production application with years of stored data and many Core Data entities, an incremental approach is generally easier to validate.
A practical migration can look like this:
Existing Core Data App
│
▼
Audit existing model
│
▼
Create SwiftData models
│
▼
Configure ModelContainer
│
▼
Migrate individual SwiftUI screens
│
▼
Replace @FetchRequest with @Query
│
▼
Introduce schema versioning
│
▼
Remove legacy Core Data code
Apple's current SwiftData guidance also demonstrates adopting SwiftData in existing applications and evolving schemas over multiple releases rather than treating persistence migration as a single isolated operation.
Step 1: Audit Your Existing Core Data Model
Before creating your first SwiftData model, document the Core Data schema you're migrating.
Check:
- Entity names
- Attribute names
- Attribute types
- Optionality
- Relationships
- Delete rules
- Unique constraints
- Indexes
- Transformable attributes
- Custom value transformers
- Existing migration versions
Don't immediately rename everything to make it look more Swift-like.
During the first migration, keeping entity and attribute names stable can make the transition easier to reason about.
For example, if your Core Data model contains:
Task
├── id: UUID
├── title: String
├── createdAt: Date
└── isDone: Bool
your first SwiftData model should closely represent that structure.
Step 2: Convert Core Data Entities to @Model
SwiftData uses the @Model macro to define persistent model types.
The Core Data entity above can become:
import SwiftData
@Model
final class Task {
@Attribute(.unique)
var id: UUID
var title: String
var createdAt: Date
var isDone: Bool
init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}
The important part here is mapping the existing model, not redesigning it.
A typical mapping looks like:
| Core Data | SwiftData |
|---|---|
| String | String |
| Integer 16 | Int16 |
| Integer 32 | Int32 |
| Integer 64 | Int64 |
| Boolean | Bool |
| Double | Double |
| Date | Date |
| Binary Data | Data |
| Transformable | Appropriate Swift/Codable type |
SwiftData also supports indexes and unique constraints through Swift macros, allowing the model definition to express constraints that previously lived in Core Data's model configuration.
Step 3: Configure the ModelContainer
Once the model has been created, SwiftData needs a ModelContainer to manage the persistent store.
A basic configuration looks like this:
import SwiftData
@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")
let configuration = ModelConfiguration(
url: storeURL
)
return try ModelContainer(
for: Task.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}
Then provide the container to your SwiftUI application:
import SwiftUI
import SwiftData
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(PersistenceController.shared)
}
}
ModelContainer is responsible for managing the application's persistent storage and schema. For an existing production application, the store configuration and migration path should be tested carefully before release.
Do not test an in-place migration only against a newly created database. Use copies of representative existing stores so that you know how real user data behaves.
When rolling out persistent store migrations in production, monitoring startup times, memory spikes, and data-related crashes is critical. You can track migration stability and catch migration failures in real-time with Appxiom.
Step 4: Replace @FetchRequest with @Query
One of the most noticeable changes when migrating a SwiftUI application is replacing Core Data's @FetchRequest.
A Core Data implementation might look like:
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(
keyPath: \Task.createdAt,
ascending: false
)
]
)
private var tasks: FetchedResults<Task>
The SwiftData version is:
@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]
A complete SwiftUI view can then use the model directly:
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext)
private var modelContext
@Query(
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]
var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
HStack {
Text(task.title)
Spacer()
if task.isDone {
Image(
systemName:
"checkmark.circle.fill"
)
}
}
}
.onDelete(perform: delete)
}
.navigationTitle("Tasks")
.toolbar {
Button("Add") {
addTask()
}
}
}
}
private func addTask() {
let task = Task(title: "New Task")
modelContext.insert(task)
do {
try modelContext.save()
} catch {
print("Failed to save task: \(error)")
}
}
private func delete(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(tasks[index])
}
do {
try modelContext.save()
} catch {
print("Failed to delete task: \(error)")
}
}
}
This is one of the main advantages of SwiftData for SwiftUI applications: persistent data can be queried and consumed using Swift-native types without the same amount of Core Data-specific boilerplate.
Step 5: Version Your SwiftData Schema
The initial migration is only one part of the problem.
Your model will continue changing after you move to SwiftData.
For example, suppose version 1 contains:
import SwiftData
enum AppSchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(1, 0, 0)
}
static var models: [any PersistentModel.Type] {
[Task.self]
}
@Model
final class Task {
@Attribute(.unique)
var id: UUID
var title: String
var createdAt: Date
var isDone: Bool
init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
}
}
Notice that versionIdentifier uses Schema.Version:
Schema.Version(1, 0, 0)
A later release can introduce a new property:
import SwiftData
enum AppSchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version {
Schema.Version(2, 0, 0)
}
static var models: [any PersistentModel.Type] {
[Task.self]
}
@Model
final class Task {
@Attribute(.unique)
var id: UUID
var title: String
var createdAt: Date
var isDone: Bool
var notes: String?
init(
id: UUID = UUID(),
title: String,
createdAt: Date = .now,
isDone: Bool = false,
notes: String? = nil
) {
self.id = id
self.title = title
self.createdAt = createdAt
self.isDone = isDone
self.notes = notes
}
}
}
Keep historical schema definitions available when they are required by your migration path. Existing users may have data created under an older schema even though new installations start with the latest one.
Step 6: Create a SchemaMigrationPlan
Once your application has multiple schema versions, define how it moves between them.
For a simple change such as adding an optional property, a lightweight migration may be sufficient:
import SwiftData
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
AppSchemaV1.self,
AppSchemaV2.self
]
}
static var stages: [MigrationStage] {
[
.lightweight(
fromVersion: AppSchemaV1.self,
toVersion: AppSchemaV2.self
)
]
}
}
Then create the container with the migration plan:
import SwiftData
@MainActor
enum PersistenceController {
static let shared: ModelContainer = {
do {
let storeURL = try FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("MyStore.sqlite")
let configuration = ModelConfiguration(
url: storeURL
)
return try ModelContainer(
for: AppSchemaV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: configuration
)
} catch {
fatalError(
"Failed to create ModelContainer: \(error)"
)
}
}()
}
The migration path now looks like:
Schema V1
│
│ Lightweight migration
▼
Schema V2
As the application evolves, you can extend the chain:
Schema V1
│
▼
Schema V2
│
▼
Schema V3
│
▼
Schema V4
Apple's WWDC25 SwiftData migration example follows this same principle: historical schemas are retained and connected through migration stages, allowing an application to evolve its model while preserving existing data.
Step 7: Understand Lightweight vs. Custom Migration
Not every schema change needs custom migration code.
Use lightweight migration when:
- Adding an optional property
- Making supported schema changes that SwiftData can infer
- Adding compatible relationships
- Applying supported schema changes without transforming existing values
Use custom migration when:
- Existing values need to be transformed
- Multiple properties need to be combined
- One entity becomes multiple entities
- Multiple entities become one
- Required values need to be generated
- Business logic determines how old records map to the new schema
For example, if an old model contains:
firstName
lastName
and the new model requires:
fullName
the application needs to determine how existing values should be combined. That's a data transformation, not simply a new optional attribute.
The important rule is:
Don't force a complex data transformation into a lightweight migration just because the code is shorter.
A migration that technically succeeds but produces incorrect data is still a failed migration.
Step 8: Take Advantage of iOS 26 Model Inheritance
One of the most important SwiftData additions for iOS 26 is model inheritance.
Apple's WWDC25 SwiftData session demonstrates using inheritance to model different types of trips while sharing common properties.
For example:
import SwiftData
@Model
class Trip {
var destination: String
var startDate: Date
var endDate: Date
init(
destination: String,
startDate: Date,
endDate: Date
) {
self.destination = destination
self.startDate = startDate
self.endDate = endDate
}
}
A specialized model can inherit from it:
@available(iOS 26, *)
@Model
final class BusinessTrip: Trip {
var companyName: String
init(
destination: String,
startDate: Date,
endDate: Date,
companyName: String
) {
self.companyName = companyName
super.init(
destination: destination,
startDate: startDate,
endDate: endDate
)
}
}
The resulting hierarchy is:
Trip
├── BusinessTrip
└── PersonalTrip
Inheritance is useful when there is a genuine "is-a" relationship.
A BusinessTrip is a Trip.
If two models simply share a few properties without forming a natural hierarchy, composition or protocol-based design may be more appropriate. Apple recommends using inheritance deliberately rather than treating it as the default solution for shared properties.
Step 9: Migrate an Existing Schema to Inheritance
Introducing inheritance changes the schema, so existing users still need a migration path.
Apple's WWDC25 SampleTrips example evolved through multiple schema versions and introduced inheritance in its iOS 26 schema. The migration plan then connects the previous schema to the new inheritance-based schema.
The important lesson isn't to copy the exact SampleTrips implementation.
It's to treat the inheritance change as another versioned schema transition:
Schema V2
│
▼
Schema V3
│
▼
Schema V4
│
└── New inheritance hierarchy
This keeps the migration understandable and gives you a clear way to test users upgrading from older releases.
Step 10: Use Modern SwiftData Query Features
iOS 26 also adds sectioning support to SwiftData queries.
Suppose your model contains a category:
@Model
final class Task {
@Attribute(.unique)
var id: UUID
var title: String
var category: String
var createdAt: Date
init(
id: UUID = UUID(),
title: String,
category: String,
createdAt: Date = .now
) {
self.id = id
self.title = title
self.category = category
self.createdAt = createdAt
}
}
You can group query results using sectionBy:
@Query(
sort: \Task.createdAt,
order: .reverse,
sectionBy: \Task.category
)
private var tasks: [Task]
You can then access the sections through the projected query:
List {
ForEach(_tasks.sections) { section in
Section(section.id) {
ForEach(section) { task in
Text(task.title)
}
}
}
}
Apple added sectioning to SwiftData's query APIs as part of its newer SwiftData updates.
This can simplify SwiftUI screens that previously fetched Core Data records and manually grouped them after the fetch.
Step 11: Handle Legacy Transformable Data Carefully
Core Data applications often contain Transformable attributes.
During migration, don't automatically replace every Transformable attribute with a new SwiftData model.
First ask:
- Is this data a natural Swift value type?
- Does it need to be queried?
- Does it need sorting or filtering?
- Is it a type controlled by the application?
- Is it a third-party type?
For types that SwiftData can model natively, using a regular SwiftData model or supported value type is usually preferable.
For external Codable types that SwiftData cannot inspect directly, newer SwiftData releases provide Codable-based persistence through schema attributes. Apple describes this as useful for types you don't directly control.
The important distinction is that Codable persistence is not a replacement for modeling data that you actually need SwiftData to query and index.
Testing the Migration
A migration should never be tested only with a fresh installation.
Create test stores representing different historical states:
Core Data V1
Core Data V2
SwiftData V1
SwiftData V2
SwiftData V3
Current SwiftData schema
Test upgrades such as:
V1 → Current
V2 → Current
V3 → Current
Also test:
- Empty stores
- Small stores
- Large stores
- Missing optional values
- Existing relationships
- Duplicate records
- Unique constraints
- Renamed properties
- Newly introduced fields
- Migration failures
- Memory usage
- Migration duration
A migration that works with 100 development records may behave very differently with a production store containing years of data.
The most important test isn't simply:
"Did the app launch?"
It is:
"Did the user's existing data remain correct?"
Common Migration Problems
The store schema doesn't match
If SwiftData cannot reconcile the persisted store with the current schema or migration plan, the container may fail to open the store.
Check:
- Model names
- Attribute names
- Attribute types
- Schema versions
- Migration stages
- Persistent store configuration
Renamed properties don't migrate correctly
Renaming a property can cause problems if the migration doesn't have enough information to associate the old and new representations.
When possible, keep names stable during the initial Core Data to SwiftData migration.
Large migrations cause memory pressure
Avoid fetching the entire database into memory.
Process records in batches instead.
The UI freezes
Don't perform large data transformations synchronously on the main UI path.
Use an appropriate model context and process large datasets in manageable batches.
When Should You Use a Side-by-Side Migration?
In-place migration is attractive when your Core Data schema maps closely to the SwiftData model.
A side-by-side migration can make more sense when the new schema is substantially different.
Consider it when:
- Most entities are being redesigned
- Relationships are changing significantly
- Multiple entities are being merged
- One entity becomes multiple entities
- Legacy data needs substantial transformation
The architecture looks like:
Existing Core Data Store
│
▼
Read old records
│
▼
Transform data
│
▼
Write SwiftData
│
▼
Validate
│
▼
Switch to new store
This requires more implementation work, but it gives you complete control over how old records are transformed.
Production Migration Checklist
Before shipping your Core Data to SwiftData migration:
- Audit every Core Data entity
- Document relationships and delete rules
- Document existing schema versions
- Create the initial
@Modelrepresentations - Keep names stable where possible
- Configure
ModelContainer - Test against real representative stores
- Replace
@FetchRequestwith@Query - Introduce
VersionedSchema - Create a
SchemaMigrationPlan - Test lightweight migrations
- Test custom migrations where necessary
- Test users upgrading across multiple releases
- Test large stores
- Test migration failures
- Verify data integrity after migration
- Test iOS 26-specific model changes separately
- Monitor migration behavior after release
Final Takeaway
Migrating from Core Data to SwiftData isn't about replacing one persistence API with another overnight.
The safer approach is to treat it as a controlled schema migration.
Start by mapping your existing Core Data entities to SwiftData @Model types. Keep the initial schema as close as practical to the existing data model, configure the ModelContainer, and move SwiftUI screens from @FetchRequest to @Query incrementally.
As the application evolves, use VersionedSchema and SchemaMigrationPlan to describe how your persistent model changes between releases.
And if you're targeting iOS 26, SwiftData now provides additional modeling capabilities such as class inheritance, allowing applications to represent genuine model hierarchies while continuing to evolve their schemas.
The key principle remains simple:
A migration isn't successful because the app launches. It's successful when existing users retain correct data and the application can continue evolving safely.
Safeguard Your SwiftData Migration with Appxiom
Migrating your persistence layer shouldn't mean flying blind. Appxiom gives mobile teams full visibility into production performance, crash telemetry, and release health as you update your SwiftUI stack.
