Skip to main content

One post tagged with "SchemaMigrationPlan"

View All Tags

Mastering SwiftData: Migrating Core Data Apps in SwiftUI

Published: · 15 min read
Robin Alex Panicker
Cofounder and CPO, Appxiom

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 DataSwiftData
NSManagedObject@Model
NSPersistentContainerModelContainer
NSManagedObjectContextModelContext
NSFetchRequestFetchDescriptor
@FetchRequest@Query
Mapping models/custom migrationVersionedSchema + 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 DataSwiftData
StringString
Integer 16Int16
Integer 32Int32
Integer 64Int64
BooleanBool
DoubleDouble
DateDate
Binary DataData
TransformableAppropriate 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 @Model representations
  • Keep names stable where possible
  • Configure ModelContainer
  • Test against real representative stores
  • Replace @FetchRequest with @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.