Specify a webhook-driven task for OpenClaw
Describe event inputs, validation, deduplication, and intended actions before integration work.
Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios
---
name: axiom-realm-migration-ref
description: "Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios"
---
# 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 shut down on September 30, 2025. This guide is for apps still holding a Realm database that have to move to SwiftData.
---
## Critical Timeline
**Realm Device Sync shut down on September 30, 2025.**
If your app still uses Realm Sync:
- ⚠️ Sync is gone — it cannot run alongside a replacement, because there is no service left to sync with
- ✅ SwiftData with CloudKit is the recommended replacement
- ⏰ Migrations take 2-8 weeks for production apps, so the schedule is yours to set
**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
```swift
// 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 // remove if using CloudKit sync
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)` (not supported with CloudKit sync — remove if using CloudKit)
- Realm: Implicit init → SwiftData: Explicit init required
- Realm: `Object` base class → SwiftData: `@Model` macro on `final class`
#### Realm → SwiftData: Relationships
```swift
// REALM: One-to-Many
class RealmAlbum: Object {
@Persisted(primaryKey: true) var id: String
@Persisted var title: String
@Persisted var tracks: List<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] = []
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Track {
@Attribute(.unique) var id: String
var title: String
var album: Album? // Inverse automatically maintained
init(id: String, title: String, album: Album? = nil) {
self.id = id
self.title = title
self.album = album
}
}
```
**Key differences**:
- Realm: Explicit `List` 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
```swift
// 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
var genre: String = ""
var releaseDate: Date = Date()
init(id: String, genre: String = "", releaseDate: Date = Date()) {
self.id = id
self.genre = genre
self.releaseDate = releaseDate
}
// Indexes are declared with the freestanding #Index macro, NOT @Attribute.
// There is no .indexed attribute option. Each array is one index;
// pass multiple key paths in a single array for a compound index.
#Index<Track>([\.genre], [\.releaseDate])
}
```
**Key difference**: Realm marks indexes per-property with `@Persisted(indexed: true)`. SwiftData has no `@Attribute(.indexed)` option — use the freestanding `#Index` macro (macOS 15 / iOS 18+) inside the `@Model` class instead.
---
## Part 2: Threading Model Conversion
### Realm Threading → Swift Concurrency
#### Realm: Manual Thread Handling
```swift
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
```swift
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:
```swift
actor SchemaImporter {
let realmPath: String
let modelContainer: ModelContainer
func migrateFromRealm() async throws {
// 1. Open Realm database. `actor: self` isolates it to this actor; the
// bare `Realm(configuration:)` overload is @MainActor and opens the
// Realm on the main queue instead.
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig, actor: self)
// 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:
```swift
// 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 once provided automatic sync; the service shut down on September 30, 2025, and Realm 20 removed the API. SwiftData uses CloudKit directly:
```swift
// REALM SYNC (Realm 10.x): the signed-in user vends the configuration.
// Realm 20 deleted this surface — `Realm.Configuration` has no
// `syncConfiguration` member and `SyncConfiguration` no longer exists.
let realmConfig = app.currentUser!.configuration(partitionValue: "my-partition")
// 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
```swift
@MainActor
class CloudKitSyncMonitor: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncDate: Date?
@Published var syncError: Error?
let modelContainer: ModelContainer
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
func startMonitoring() {
// SwiftData publishes no "CloudKit sync completed" notification of its
// own. Its CloudKit mirroring is Core Data's, so observe that event
// stream — it fires when an import or export starts and again when it ends.
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let event = notification.userInfo?[
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event else { return }
// `Notification` is not Sendable, so read what we need off it here,
// then hop onto the main actor that `queue: .main` already promises.
let isSyncing = event.endDate == nil
let endDate = event.endDate
let error = event.error
MainActor.assumeIsolated {
self?.isSyncing = isSyncing
if let endDate { self?.lastSyncDate = endDate }
if let error { self?.syncError = error }
}
}
}
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
├─ Monitor CloudKit sync health
├─ Remove the Realm Sync code path
└─ Confirm no client still depends on the old service
```
---
## Part 5: Real-World Migration Scenarios
### Scenario A: Small App (< 10,000 Records)
**Timeline**: 1-2 weeks
**Data Size**: < 10 MB
```swift
// 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, actor: self)
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
```swift
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, actor: self)
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
```swift
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:
```swift
@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
```bash
# 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 (default store is per-platform)
# iOS: <app container>/Documents/default.realm
# macOS: ~/Library/Application Support/<bundle-id>/default.realm
# tvOS: <app container>/Library/Caches/default.realm
# Realm.Configuration.defaultConfiguration.fileURL prints the exact path
cp "<app container>/Documents/default.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-data (skills/swiftdata.md), axiom-concurrency, axiom-data (skills/database-migration.md)
---
**Status**: Realm Device Sync shut down September 30, 2025
**Estimated Migration Time**: 2-8 weeks depending on app complexity
---
name: axiom-realm-migration-ref
description: "Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios"
---
# 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 shut down on September 30, 2025. This guide is for apps still holding a Realm database that have to move to SwiftData.
---
## Critical Timeline
**Realm Device Sync shut down on September 30, 2025.**
If your app still uses Realm Sync:
- ⚠️ Sync is gone — it cannot run alongside a replacement, because there is no service left to sync with
- ✅ SwiftData with CloudKit is the recommended replacement
- ⏰ Migrations take 2-8 weeks for production apps, so the schedule is yours to set
**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
```swift
// 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 // remove if using CloudKit sync
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)` (not supported with CloudKit sync — remove if using CloudKit)
- Realm: Implicit init → SwiftData: Explicit init required
- Realm: `Object` base class → SwiftData: `@Model` macro on `final class`
#### Realm → SwiftData: Relationships
```swift
// REALM: One-to-Many
class RealmAlbum: Object {
@Persisted(primaryKey: true) var id: String
@Persisted var title: String
@Persisted var tracks: List<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] = []
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Track {
@Attribute(.unique) var id: String
var title: String
var album: Album? // Inverse automatically maintained
init(id: String, title: String, album: Album? = nil) {
self.id = id
self.title = title
self.album = album
}
}
```
**Key differences**:
- Realm: Explicit `List` 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
```swift
// 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
var genre: String = ""
var releaseDate: Date = Date()
init(id: String, genre: String = "", releaseDate: Date = Date()) {
self.id = id
self.genre = genre
self.releaseDate = releaseDate
}
// Indexes are declared with the freestanding #Index macro, NOT @Attribute.
// There is no .indexed attribute option. Each array is one index;
// pass multiple key paths in a single array for a compound index.
#Index<Track>([\.genre], [\.releaseDate])
}
```
**Key difference**: Realm marks indexes per-property with `@Persisted(indexed: true)`. SwiftData has no `@Attribute(.indexed)` option — use the freestanding `#Index` macro (macOS 15 / iOS 18+) inside the `@Model` class instead.
---
## Part 2: Threading Model Conversion
### Realm Threading → Swift Concurrency
#### Realm: Manual Thread Handling
```swift
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
```swift
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:
```swift
actor SchemaImporter {
let realmPath: String
let modelContainer: ModelContainer
func migrateFromRealm() async throws {
// 1. Open Realm database. `actor: self` isolates it to this actor; the
// bare `Realm(configuration:)` overload is @MainActor and opens the
// Realm on the main queue instead.
let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath))
let realm = try await Realm(configuration: realmConfig, actor: self)
// 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:
```swift
// 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 once provided automatic sync; the service shut down on September 30, 2025, and Realm 20 removed the API. SwiftData uses CloudKit directly:
```swift
// REALM SYNC (Realm 10.x): the signed-in user vends the configuration.
// Realm 20 deleted this surface — `Realm.Configuration` has no
// `syncConfiguration` member and `SyncConfiguration` no longer exists.
let realmConfig = app.currentUser!.configuration(partitionValue: "my-partition")
// 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
```swift
@MainActor
class CloudKitSyncMonitor: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncDate: Date?
@Published var syncError: Error?
let modelContainer: ModelContainer
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
func startMonitoring() {
// SwiftData publishes no "CloudKit sync completed" notification of its
// own. Its CloudKit mirroring is Core Data's, so observe that event
// stream — it fires when an import or export starts and again when it ends.
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let event = notification.userInfo?[
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event else { return }
// `Notification` is not Sendable, so read what we need off it here,
// then hop onto the main actor that `queue: .main` already promises.
let isSyncing = event.endDate == nil
let endDate = event.endDate
let error = event.error
MainActor.assumeIsolated {
self?.isSyncing = isSyncing
if let endDate { self?.lastSyncDate = endDate }
if let error { self?.syncError = error }
}
}
}
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
├─ Monitor CloudKit sync health
├─ Remove the Realm Sync code path
└─ Confirm no client still depends on the old service
```
---
## Part 5: Real-World Migration Scenarios
### Scenario A: Small App (< 10,000 Records)
**Timeline**: 1-2 weeks
**Data Size**: < 10 MB
```swift
// 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, actor: self)
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
```swift
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, actor: self)
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
```swift
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:
```swift
@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
```bash
# 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 (default store is per-platform)
# iOS: <app container>/Documents/default.realm
# macOS: ~/Library/Application Support/<bundle-id>/default.realm
# tvOS: <app container>/Library/Caches/default.realm
# Realm.Configuration.defaultConfiguration.fileURL prints the exact path
cp "<app container>/Documents/default.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-data (skills/swiftdata.md), axiom-concurrency, axiom-data (skills/database-migration.md)
---
**Status**: Realm Device Sync shut down September 30, 2025
**Estimated Migration Time**: 2-8 weeks depending on app complexity
Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.
Resource notes updated 2026-10-27 · Original instructions by CharlesWiltgen