Realm to SwiftData Migration — Reference Guide
Purpose: Complete migration path from Realm to SwiftData Swift Version: Swift 5.9+ (Swift 6 with strict concurrency recommended) iOS Version: iOS 17+ (iOS 26+ recommended) Context: Realm Device Sync sunset Sept 30, 2025. This guide is essential for Realm users migrating before deadline.
Critical Timeline
Realm Device Sync DEPRECATION DEADLINE = September 30, 2025
If your app uses Realm Sync:
-
⚠️ You MUST migrate by September 30, 2025
-
✅ SwiftData is the recommended replacement
-
⏰ Time remaining: Depends on current date, but migrations take 2-8 weeks for production apps
This guide provides everything needed for successful migration.
Migration Strategy Overview
Phase 1 (Week 1-2): Preparation & Planning ├─ Audit current Realm usage ├─ Understand model relationships ├─ Plan data migration path └─ Set up test environment
Phase 2 (Week 2-3): Development ├─ Create SwiftData models from Realm schemas ├─ Implement data migration logic ├─ Convert threading model to async/await └─ Test with real data
Phase 3 (Week 3-4): Migration ├─ Migrate existing app users' data ├─ Run in parallel (Realm + SwiftData) ├─ Verify CloudKit sync works └─ Monitor for issues
Phase 4 (Week 4+): Production ├─ Deploy update with parallel persistence ├─ Gradual cutover from Realm to SwiftData ├─ Deprecate Realm code └─ Monitor CloudKit sync health
Part 1: Pattern Equivalents
Model Definition Conversion
Realm → SwiftData: Basic Model
// REALM class RealmTrack: Object { @Persisted(primaryKey: true) var id: String @Persisted var title: String @Persisted var artist: String @Persisted var duration: TimeInterval @Persisted var genre: String? }
// SWIFTDATA @Model final class Track { @Attribute(.unique) var id: String var title: String var artist: String var duration: TimeInterval var genre: String?
init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) {
self.id = id
self.title = title
self.artist = artist
self.duration = duration
self.genre = genre
}
}
Key differences:
-
Realm: @Persisted(primaryKey: true) → SwiftData: @Attribute(.unique)
-
Realm: Implicit init → SwiftData: Explicit init required
-
Realm: Object base class → SwiftData: @Model macro on final class
Realm → SwiftData: Relationships
// REALM: One-to-Many class RealmAlbum: Object { @Persisted(primaryKey: true) var id: String @Persisted var title: String @Persisted var tracks: RealmSwiftCollection<RealmTrack> }
// SWIFTDATA: One-to-Many @Model final class Album { @Attribute(.unique) var id: String var title: String
@Relationship(deleteRule: .cascade, inverse: \Track.album)
var tracks: [Track] = []
}
@Model final class Track { @Attribute(.unique) var id: String var title: String var album: Album? // Inverse automatically maintained }
Key differences:
-
Realm: Explicit RealmSwiftCollection type → SwiftData: Native [Track] array
-
Realm: Manual relationship management → SwiftData: Inverse relationships automatic
-
Realm: No delete rules → SwiftData: deleteRule: .cascade / .nullify / .deny
Realm → SwiftData: Indexes
// REALM class RealmTrack: Object { @Persisted(primaryKey: true) var id: String @Persisted(indexed: true) var genre: String @Persisted(indexed: true) var releaseDate: Date }
// SWIFTDATA @Model final class Track { @Attribute(.unique) var id: String @Attribute(.indexed) var genre: String = "" @Attribute(.indexed) var releaseDate: Date = Date() }
Part 2: Threading Model Conversion
Realm Threading → Swift Concurrency
Realm: Manual Thread Handling
class RealmDataManager { func fetchTracksOnBackground() { DispatchQueue.global().async { let realm = try! Realm() // Must get Realm on each thread let tracks = realm.objects(RealmTrack.self)
DispatchQueue.main.async {
self.updateUI(tracks: Array(tracks))
}
}
}
func saveTrackOnBackground(_ track: RealmTrack) {
DispatchQueue.global().async {
let realm = try! Realm()
try! realm.write {
realm.add(track)
}
}
}
}
Problems:
-
Manual DispatchQueue threading error-prone
-
Easy to access objects on wrong thread
-
No compile-time guarantees
SwiftData: Actor-Based Concurrency
actor SwiftDataManager { let modelContainer: ModelContainer
func fetchTracks() async -> [Track] {
let context = ModelContext(modelContainer)
let descriptor = FetchDescriptor<Track>()
return (try? context.fetch(descriptor)) ?? []
}
func saveTrack(_ track: Track) async {
let context = ModelContext(modelContainer)
context.insert(track)
try? context.save()
}
}
// Usage (automatic thread handling) @MainActor class ViewController: UIViewController { @State private var tracks: [Track] = [] private let manager: SwiftDataManager
func loadTracks() async {
tracks = await manager.fetchTracks()
}
}
Advantages:
-
No manual DispatchQueue
-
Compile-time thread safety
-
Automatic actor isolation
-
Swift 6 strict concurrency compatible
Common Threading Patterns
Realm Pattern SwiftData Pattern
DispatchQueue.global().async
async/await in background actor
realm.write { }
context.insert()
- context.save()
Manual thread-local Realm instances Shared ModelContainer
- background ModelContext
Thread.isMainThread checks @MainActor annotations
Part 3: Schema Migration Strategies
Simple Schema Migration (Direct Conversion)
For apps with simple schemas (< 5 tables, < 10 fields), direct migration is straightforward:
actor SchemaImporter { let realmPath: String let modelContainer: ModelContainer
func migrateFromRealm() async throws {
// 1. Open Realm database
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig)
// 2. Create SwiftData context
let context = ModelContext(modelContainer)
// 3. Migrate each model type
try migrateAllTracks(from: realm, to: context)
try migrateAllAlbums(from: realm, to: context)
try migrateAllPlaylists(from: realm, to: context)
// 4. Save all at once
try context.save()
print("Migration complete!")
}
private func migrateAllTracks(from realm: Realm, to context: ModelContext) throws {
let realmTracks = realm.objects(RealmTrack.self)
for realmTrack in realmTracks {
let sdTrack = Track(
id: realmTrack.id,
title: realmTrack.title,
artist: realmTrack.artist,
duration: realmTrack.duration,
genre: realmTrack.genre
)
context.insert(sdTrack)
}
}
private func migrateAllAlbums(from realm: Realm, to context: ModelContext) throws {
let realmAlbums = realm.objects(RealmAlbum.self)
for realmAlbum in realmAlbums {
let sdAlbum = Album(
id: realmAlbum.id,
title: realmAlbum.title
)
context.insert(sdAlbum)
// Connect relationships after creating all records
for realmTrack in realmAlbum.tracks {
if let sdTrack = findTrack(id: realmTrack.id, in: context) {
sdAlbum.tracks.append(sdTrack)
}
}
}
}
private func findTrack(id: String, in context: ModelContext) -> Track? {
let descriptor = FetchDescriptor<Track>(
predicate: #Predicate { $0.id == id }
)
return try? context.fetch(descriptor).first
}
}
Complex Schema Migration (Transformation Layer)
For apps with complex schemas, many computed properties, or data transformations:
// Step 1: Define transformation layer struct TrackDTO { let realmTrack: RealmTrack
var id: String { realmTrack.id }
var title: String { realmTrack.title }
var cleanTitle: String { realmTrack.title.trimmingCharacters(in: .whitespaces) }
var durationFormatted: String {
let minutes = Int(realmTrack.duration) / 60
let seconds = Int(realmTrack.duration) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}
// Step 2: Migrate through transformation layer actor ComplexMigrator { let modelContainer: ModelContainer
func migrateWithTransformation(from realm: Realm) throws {
let context = ModelContext(modelContainer)
let realmTracks = realm.objects(RealmTrack.self)
for realmTrack in realmTracks {
let dto = TrackDTO(realmTrack: realmTrack)
// Transform data during migration
let sdTrack = Track(
id: dto.id,
title: dto.cleanTitle, // Cleaned version
artist: realmTrack.artist,
duration: realmTrack.duration
)
context.insert(sdTrack)
}
try context.save()
}
}
Part 4: CloudKit Sync Transition
Realm Sync → SwiftData CloudKit
Realm Sync (now deprecated) provided automatic sync. SwiftData uses CloudKit directly:
// REALM SYNC: Automatic but deprecated let config = Realm.Configuration( syncConfiguration: SyncConfiguration(user: app.currentUser!) )
// SWIFTDATA: CloudKit (recommended replacement) let schema = Schema([Track.self, Album.self]) let config = ModelConfiguration( schema: schema, cloudKitDatabase: .private("iCloud.com.example.MusicApp") )
let container = try ModelContainer(for: schema, configurations: config)
Sync Status Monitoring
@MainActor class CloudKitSyncMonitor: ObservableObject { @Published var isSyncing = false @Published var lastSyncDate: Date? @Published var syncError: Error?
let modelContainer: ModelContainer
func startMonitoring() {
// Monitor CloudKit sync notifications
NotificationCenter.default.addObserver(
forName: NSNotification.Name("CloudKitSyncDidComplete"),
object: nil,
queue: .main
) { [weak self] _ in
self?.isSyncing = false
self?.lastSyncDate = Date()
}
}
func syncNow() async {
isSyncing = true
do {
let context = ModelContext(modelContainer)
// SwiftData sync happens automatically
// Manually fetch to trigger sync
let descriptor = FetchDescriptor<Track>()
_ = try context.fetch(descriptor)
} catch {
syncError = error
}
isSyncing = false
}
}
Migration Timing: Realm Sync → CloudKit
Timeline: Week 1-2: Development & Testing ├─ Create SwiftData models ├─ Test migrations in non-CloudKit mode └─ Prepare CloudKit configuration
Week 3: CloudKit Sync Testing ├─ Enable CloudKit in test build ├─ Verify sync works with small datasets ├─ Test multi-device sync └─ Test conflict resolution
Week 4+: Production Rollout ├─ Deploy app with SwiftData + CloudKit ├─ Initially run parallel (Realm Sync + SwiftData CloudKit) ├─ Monitor both sync mechanisms ├─ Gradually deprecate Realm Sync └─ Final cutoff before Sept 30, 2025
Part 5: Real-World Migration Scenarios
Scenario A: Small App (< 10,000 Records)
Timeline: 1-2 weeks Data Size: < 10 MB
// 1. Export Realm data let realmPath = Realm.Configuration.defaultConfiguration.fileURL!
// 2. Migrate in background task actor SmallAppMigration { let modelContainer: ModelContainer
func migrateSmallApp() async throws {
let realmConfig = Realm.Configuration(fileURL: realmPath)
let realm = try await Realm(configuration: realmConfig)
let context = ModelContext(modelContainer)
// All-at-once migration (safe for < 10k records)
let allTracks = realm.objects(RealmTrack.self)
for realmTrack in allTracks {
let track = Track(from: realmTrack)
context.insert(track)
}
try context.save()
print("✅ Migrated \(allTracks.count) tracks")
}
}
// 3. Deploy // Option 1: Migrate on first launch (offline) // Option 2: Provide manual "Migrate Data" button // Option 3: Automatic migration in background
Scenario B: Medium App (100,000 - 1,000,000 Records)
Timeline: 3-4 weeks Data Size: 100 MB - 1 GB Challenge: Progress reporting, memory management
actor MediumAppMigration { let modelContainer: ModelContainer let realmPath: String
typealias ProgressCallback = (Int, Int) -> Void
func migrateMediumApp(onProgress: @MainActor ProgressCallback) async throws {
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig)
let context = ModelContext(modelContainer)
let allTracks = realm.objects(RealmTrack.self)
let totalCount = allTracks.count
// Chunk-based migration for memory efficiency
var count = 0
for chunk in Array(allTracks).chunked(into: 5000) {
for realmTrack in chunk {
let track = Track(from: realmTrack)
context.insert(track)
}
// Save periodically
try context.save()
count += chunk.count
await onProgress(count, totalCount)
// Check for cancellation
if Task.isCancelled {
throw CancellationError()
}
}
}
}
// 4. Show progress UI @MainActor class MigrationViewController: UIViewController { @IBOutlet weak var progressView: UIProgressView! @IBOutlet weak var statusLabel: UILabel!
func startMigration() {
Task {
do {
try await migrator.migrateMediumApp { current, total in
self.progressView.progress = Float(current) / Float(total)
self.statusLabel.text = "Migrated \(current) of \(total)..."
}
self.statusLabel.text = "✅ Migration complete!"
} catch {
self.statusLabel.text = "❌ Migration failed: \(error)"
}
}
}
}
Scenario C: Large App (Enterprise, > 1 Million Records)
Timeline: 6-8 weeks Data Size: > 1 GB Challenge: Minimal downtime, data integrity, rollback plan
class EnterpriseGradualMigration { let coreDataStack: CoreDataStack // Existing Realm let modelContainer: ModelContainer let batchSize = 10000
// Phase 1: Parallel migration
func startGradualMigration() async {
var offset = 0
let totalRecords = countAllRecords()
while offset < totalRecords {
let batch = fetchRealmBatch(limit: batchSize, offset: offset)
try? await migrateBatch(batch)
offset += batchSize
await reportProgress(offset, totalRecords)
}
}
private func migrateBatch(_ batch: [RealmTrack]) async throws {
let context = ModelContext(modelContainer)
for realmTrack in batch {
let track = Track(from: realmTrack)
context.insert(track)
track.migrationStatus = .completedPhase1
}
try context.save()
// Give main thread time to breathe
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
}
// Phase 2: Verify all migrated
func verifyMigrationComplete() async throws {
let sdContext = ModelContext(modelContainer)
let sdCount = try sdContext.fetch(FetchDescriptor<Track>())
let realmCount = countAllRealmRecords()
guard sdCount.count == realmCount else {
throw MigrationError.countMismatch(sd: sdCount.count, realm: realmCount)
}
print("✅ Verified: \(sdCount.count) records migrated")
}
// Phase 3: Rollback plan
func rollbackToRealm() {
// Keep Realm database intact until 100% confident
// Only delete Realm after running stable on SwiftData for 2+ weeks
}
}
Part 6: Testing & Verification
Data Integrity Checklist
Before going live with SwiftData:
@MainActor class MigrationVerifier { func verifyMigration() async throws { print("🔍 Running migration verification...")
// 1. Count verification
let sdCount = try await countSwiftDataRecords()
let realmCount = countRealmRecords()
print("✓ Record count: SD=\(sdCount), Realm=\(realmCount)")
guard sdCount == realmCount else {
throw VerificationError.countMismatch
}
// 2. Data integrity sampling (spot checks)
try await verifySampleRecords(count: min(100, sdCount / 10))
print("✓ Spot checked 100 records - all valid")
// 3. Relationship integrity
try await verifyRelationships()
print("✓ All relationships intact")
// 4. CloudKit sync test
try await verifyCloudKitSync()
print("✓ CloudKit sync working")
// 5. Performance test
try await verifyPerformance()
print("✓ Query performance acceptable")
print("✅ All verifications passed!")
}
private func verifySampleRecords(count: Int) async throws {
let sdContext = ModelContext(modelContainer)
let descriptor = FetchDescriptor<Track>()
let tracks = try sdContext.fetch(descriptor)
let sample = Array(tracks.prefix(count))
for track in sample {
// Verify fields populated
assert(!track.id.isEmpty, "Track has empty ID")
assert(!track.title.isEmpty, "Track has empty title")
assert(track.duration > 0, "Track has invalid duration")
}
}
private func verifyRelationships() async throws {
let sdContext = ModelContext(modelContainer)
let albumDescriptor = FetchDescriptor<Album>()
let albums = try sdContext.fetch(albumDescriptor)
for album in albums {
// Verify inverse relationships
for track in album.tracks {
assert(track.album?.id == album.id, "Relationship broken")
}
}
}
private func verifyCloudKitSync() async throws {
let sdContext = ModelContext(modelContainer)
// Insert test record
let testTrack = Track(
id: "test-" + UUID().uuidString,
title: "Test Track",
artist: "Test Artist",
duration: 240
)
sdContext.insert(testTrack)
try sdContext.save()
// Verify CloudKit sync initiated
// (Check iCloud → iPhone → Settings → iCloud for sync status)
print("ℹ️ Check iCloud app to verify sync initiated")
}
private func verifyPerformance() async throws {
let sdContext = ModelContext(modelContainer)
let start = Date()
let descriptor = FetchDescriptor<Track>(
sortBy: [SortDescriptor(\.title)]
)
_ = try sdContext.fetch(descriptor)
let elapsed = Date().timeIntervalSince(start)
print("Fetch time: \(String(format: "%.2f", elapsed))s")
guard elapsed < 2.0 else {
throw VerificationError.performanceIssue
}
}
}
Part 7: Troubleshooting
Common Migration Issues
Issue Cause Solution
"Property must have default" CloudKit constraint Add defaults: var title: String = ""
Relationships not synced Missing inverse Add inverse: \Track.album
Sync stuck CloudKit auth issue Check Settings → iCloud → CloudKit
Memory bloat during import No chunking Implement batch import (1000 at a time)
Data loss No backup Keep Realm copy for 2 weeks post-migration
Part 8: Success Criteria
Your migration is successful when:
-
All data migrated correctly (count matches)
-
Sample record verification passes (spot checks 100+ records)
-
Relationships intact (inverse relationships work)
-
CloudKit sync enabled and working
-
Performance acceptable (queries < 1 second)
-
No data races (Swift 6 strict concurrency)
-
Tested on real device (not just simulator)
-
Rollback plan documented and tested
-
Realm database kept as backup for 2 weeks
-
Zero crashes in production after 1 week
Quick Reference: Command Checklist
1. Audit Realm usage
grep -r "RealmTrack|RealmAlbum" . --include="*.swift"
2. Count Realm records (in app)
let realm = try! Realm() let count = realm.objects(RealmTrack.self).count
3. Export Realm database
cp ~/Library/Developer/Realm/my_realm.realm ~/Downloads/backup.realm
4. Test SwiftData models
// Create in-memory test container let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try ModelContainer(for: Track.self, configurations: config)
5. Verify CloudKit
Settings → [Your Name] → iCloud → Check CloudKit status
Resources
WWDC: 2024-10137
Docs: /swiftdata
Skills: axiom-swiftdata, axiom-swift-concurrency, axiom-database-migration
Created: 2025-11-30 Status: Production-ready migration guide Urgency: Realm Device Sync sunset September 30, 2025 Estimated Migration Time: 2-8 weeks depending on app complexity