Realm Schema Migration: Choosing Between Automatic and Custom Approaches
A decision guide for Realm schema migration: when to use automatic version bumps vs custom Migration classes, with a production-ready Kotlin implementation pattern and validation strategy.
02 Jan 2026, 02:22 UTC

The Decision You're Facing
Your Realm-backed mobile app needs a schema change. Maybe you're adding a lastSyncedAt field to a User object, or you need to split a fullName string into firstName and lastName. The choice isn't academic: pick the wrong migration strategy and you'll silently lose user data or corrupt the Realm file.
Takeaway: Use automatic migration (schemaVersion bump only) for purely additive changes. For any rename, transform, split, merge, or field removal where data must survive, write a custom Migration class. Flexible Sync is a third path — only if you already depend on Atlas App Services.
Constraints That Shape Every Choice
- No downgrades: A Realm file opened at schemaVersion
Ncan never be opened atN-1. Test upgrade paths from at least two prior versions in CI. - Mandatory version bump: Every schema change — even additive — requires
schemaVersion(N+1)inRealmConfiguration.Builder. - Thread confinement: Migration runs on the thread that first opens the Realm. Do not dispatch the initial
Realm.getInstance(config)call toDispatchers.IO; the migration callback executes on that same thread. - Encryption key first: If the Realm is encrypted, the key must be in the configuration before migration starts. Changing keys requires a full rewrite, not a migration.
Supported Options Compared
| Approach | Supported Changes | Complexity | Risk | Typical Use Case |
|---|---|---|---|---|
Automatic (schemaVersion++) | Add fields/classes; remove fields (data lost) | Low | Silent data loss on removal | Prototyping, additive-only releases |
Custom Migration class | Rename, transform, split/merge, defaults, conditional logic | Medium | Logic bugs, performance on large datasets | Production apps with destructive changes |
| Flexible Sync (Atlas App Services) | Server-side schema; client auto-migrates | High (backend ops) | Backend dependency, latency | Multi-device, offline-first with managed backend |
Trade-offs in Practice
Automatic Migration
Zero code. You increment schemaVersion and Realm adds new fields with default values (null for nullable, 0/false/empty for primitives). But removing a field silently discards its data — no callback, no warning. Never rely on automatic migration for production schema reductions.
Custom Migration
You implement Migration and register it via .migration(AppMigration()). Inside migrate(dynamicRealm, oldVersion, newVersion) you get a DynamicRealm — a stringly-typed API to read/write any schema version. This preserves data through transforms, but bugs in your migration logic run inside a write transaction; an unhandled exception corrupts the file. Wrap every step in try/catch and log extensively.
Flexible Sync
Schema lives in Atlas App Services. Clients pull schema changes automatically. You trade local migration code for backend operational complexity: schema deployments, versioning, and network latency on first open. Local-only apps cannot use this path.
Concrete Kotlin Implementation (Realm 10.x / Kotlin SDK 1.x+)
Below is a production-ready pattern for additive and destructive changes. It uses a when(oldVersion) ladder so each version step is explicit and testable.
class AppMigration : Migration {
override fun migrate(dynamicRealm: DynamicRealm, oldVersion: Long, newVersion: Long) {
// Each case handles ONE version step. Fall-through is intentional.
when (oldVersion) {
0L -> {
// v0 → v1: add nullable 'lastSyncedAt' to User
val userSchema = dynamicRealm.schema["User"]
userSchema.addField("lastSyncedAt", RealmFieldType.INTEGER, true)
// No data transform needed; new field defaults to null
}
1L -> {
// v1 → v2: split 'fullName' into 'firstName' + 'lastName'
val userSchema = dynamicRealm.schema["User"]
userSchema.addField("firstName", RealmFieldType.STRING, false)
userSchema.addField("lastName", RealmFieldType.STRING, false)
val users = dynamicRealm.where("User").findAll()
for (i in 0 until users.size()) {
val user = users[i]
val fullName = user.getString("fullName") ?: ""r> val parts = fullName.split(" ", limit = 2)r> user.setString("firstName", parts[0])r> user.setString("lastName", parts.getOrElse(1) { "" })r> }r> // Remove old field AFTER data is copied
userSchema.removeField("fullName")r> }r> 2L -> {r> // v2 → v3: add 'isActive' with default true for existing usersr> val userSchema = dynamicRealm.schema["User"]r> userSchema.addField("isActive", RealmFieldType.BOOLEAN, false)r> val users = dynamicRealm.where("User").findAll()r> for (i in 0 until users.size()) {r> users[i].setBoolean("isActive", true)r> }r> }r> else -> throw IllegalStateException("Unhandled migration from v$oldVersion")r> }r> }r>}
// Register in your Application or Realm module
val config = RealmConfiguration.Builder()
.schemaVersion(3)
.migration(AppMigration())
.build()
Realm.setDefaultConfiguration(config)Key details:
DynamicRealm.schema["ClassName"]returns aDynamicRealmObjectSchemafor field add/remove.- Field removal (
removeField) happens after data transformation in the same version step. - Nullable fields (
truethird arg) default to null; non-nullable get zero-values unless you explicitly set them. - The
whenblock falls through: a device jumping from v0 to v3 executes v0→v1, v1→v2, v2→v3 in one transaction.
Validation: Test the Migration, Not Just the Schema
Write instrumented tests that exercise the full upgrade path. The pattern:
- Create a Realm file at schemaVersion
0with realistic data (use Realm Studio or a script). - Copy that file into
androidTest/assets/realm_v0.realm. - In the test, open it with a config targeting
schemaVersion(3)and yourAppMigration. - Assert field existence, transformed values, and query performance.
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@Test
fun `v0 to v3 migration preserves and transforms data`() {
val src = ApplicationProvider.getApplicationContext()
.assets.open("realm_v0.realm").readBytes()
val dest = File(ApplicationProvider.getApplicationContext()
.cacheDir, "migrated.realm")
dest.writeBytes(src)
val config = RealmConfiguration.Builder()
.name(dest.name)
.schemaVersion(3)
.migration(AppMigration())
.build()
Realm.getInstance(config).use { realm -
val users = realm.where().findAll()
assertEquals(3, users.size)
users.forEach { user -
assertNotNull(user.lastSyncedAt) // added v1
assertFalse(user.firstName.isBlank()) // transformed v2
assertFalse(user.lastName.isBlank())
assertTrue(user.isActive) // defaulted v3
}
// Verify old field is gone
val schema = realm.schema["User"]
assertFalse(schema.fieldNames.contains("fullName"))
}
}
}Performance and Post-Migration Checks
- Large datasets (>100k objects): Batch transforms in background transactions. The example above runs on the calling thread; for 50k+ objects, move the
forEachloop into arealm.executeTransactionAsyncand await completion before returning frommigrate(). - Reclaim space: After migration, call
Realm.compactRealm(config)on a background thread. Verify file size reduction — fragmented Realm files can grow 2-3x during multi-step migrations. - Target: <2 seconds on mid-tier devices (Snapdragon 7-series / A13) for 50k objects. Measure in CI with a device farm or local emulator.
Limitations and Gotchas
- No rollback: Migration is one-way. If
AppMigrationthrows, the Realm file may be left in an inconsistent state. Your only recovery is restoring from backup or clearing app data. - Encryption key rotation: Not a migration. Changing the encryption key requires exporting all objects, deleting the Realm, and re-importing with the new key.
- Sync vs local: If you later adopt Flexible Sync, local migration code becomes obsolete for synced realms — but unsynced (local-only) realms still need it.
- Version sensitivity: Realm Core 12+ changed encryption handling during migration. Verify key management if you're on Core 12+ (Realm Java/Kotlin 10.10+, Kotlin SDK 1.2+).
Verification Checklist Before Shipping
- ☐ Test upgrade from v
N-2and vN-1to vNin CI (not just v0 → vN). - ☐ Assert no
RealmMigrationNeededExceptionis thrown on fresh installs at vN. - ☐ Run migration on a 50k-object dataset; confirm <2s duration and <10% file size overhead post-compaction.
- ☐ Attempt downgrade (open v
Nfile with vN-1config) — expect clearRealmMigrationNeededExceptionwith version mismatch message. - ☐ Log migration entry/exit with old/new version numbers; correlate with crash reports.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.