Saltar al contenido
Todas las entradas

Migraciones de bases de datos Room en 2026: desplegar cambios de esquema sin perder una sola fila

Una guía práctica sobre las migraciones de bases de datos Room en Android — AutoMigration, objetos Migration escritos a mano, y cómo probar una migración antes de que tus usuarios encuentren el error.

MFKAPPS 5 min de lectura

Toda aplicación local-first termina necesitando un esquema con el que no se lanzó en la versión 1. Una columna nueva, una tabla renombrada, un campo que era un String y necesita convertirse en Int. En el momento en que eso pasa, estás a una anotación descuidada de borrar los datos de cada usuario en su próxima actualización. Las migraciones de Room existen para evitar eso, y la mayor parte del dolor que la gente sufre con ellas viene de saltarse las partes que parecen opcionales pero no lo son.

La trampa: fallbackToDestructiveMigration()

Room lanza una IllegalStateException en el instante en que el número de versión de tu @Database sube sin una ruta de migración correspondiente. La solución que aparece en cada respuesta de Stack Overflow es una línea:

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .fallbackToDestructiveMigration()
    .build()

Esto compila, se despliega y funciona perfectamente — en builds de debug, en tu propio dispositivo, donde no te importa perder datos de prueba. En producción significa: la próxima vez que subas la versión del esquema, Room elimina todas las tablas y las recrea vacías. Para Granyn, eso significa que los dos años de entradas de presupuesto de alguien desaparecen silenciosamente en una actualización que no pidió. No hay diálogo, no hay advertencia, no hay deshacer. Simplemente pasa, silenciosamente, la próxima vez que se abre la aplicación. Si esta línea existe fuera de una configuración de build de debug, trátala como un error, no como una conveniencia.

AutoMigration cubre más de lo que la gente asume

Para los casos comunes — añadir una columna con un valor por defecto, añadir o eliminar una tabla, renombrar una columna — el @AutoMigration de Room puede generar la migración por ti a partir de dos instantáneas de esquema:

@Database(
    version = 2,
    entities = [Entry::class],
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase()

Esto solo funciona si has activado la exportación de esquema (room.schemaLocation en tu configuración de Gradle), para que Room tenga una instantánea JSON de cada versión con la que comparar. Sáltate esa configuración y AutoMigration no tiene nada que comparar — lo descubrirás en tiempo de compilación, no a las 2 a.m. en un informe de fallos. Para una columna renombrada o eliminada, también necesitas una pequeña clase de especificación @RenameColumn o @DeleteColumn que le indique a Room los nombres antiguo y nuevo — no puede inferir la intención solo de un diff.

Dónde AutoMigration deja de ayudar

Cualquier cosa que necesite remodelar datos existentes — dividir una columna en dos, convertir un monto String almacenado en centavos como Int, rellenar un nuevo campo obligatorio a partir de otras filas — está fuera del alcance de AutoMigration. Eso no es una limitación que sortear; es la señal de que necesitas un objeto Migration real con SQL real dentro.

Escribir la migración a mano

Una Migration escrita a mano es simplemente un par de versiones y un bloque de SQL del que eres responsable:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE entries ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'"
        )
        db.execSQL(
            "UPDATE entries SET currency = (SELECT default_currency FROM user_prefs LIMIT 1)"
        )
    }
}

Regístrala junto a cualquier AutoMigration con .addMigrations(MIGRATION_2_3) en el builder de la base de datos. El ALTER TABLE de SQLite es limitado — no se pueden eliminar columnas, no se puede cambiar el tipo de una columna en el sitio — así que cualquier cosa más allá de añadir una columna generalmente requiere el clásico baile de tres pasos: crear una nueva tabla con la forma deseada, copiar los datos con INSERT INTO ... SELECT, eliminar la tabla antigua, renombrar la nueva. Es más SQL del que te gustaría escribir, y es exactamente el SQL que tiene que ser correcto, porque se ejecuta una vez, silenciosamente, en cada dispositivo en su próximo lanzamiento.

Prueba la migración, no solo el esquema final

El error que más bugs genera aquí no está en el SQL — es no ejecutarlo nunca contra una base de datos real y envejecida. El MigrationTestHelper de Room existe exactamente para esto:

@get:Rule
val helper = MigrationTestHelper(
    InstrumentationRegistry.getInstrumentation(),
    AppDatabase::class.java
)

@Test
fun migrate2To3_preservesExistingRows() {
    helper.createDatabase(TEST_DB, 2).apply {
        execSQL("INSERT INTO entries (id, amount) VALUES (1, 4200)")
        close()
    }
    helper.runMigrationsAndValidate(TEST_DB, 3, true, MIGRATION_2_3)
}

Esto construye una base de datos en versión 2, la siembra como se vería el dispositivo de un usuario real, luego ejecuta tu migración contra ella y valida que el esquema resultante coincide con lo que Room espera. Captura los dos modos de fallo que realmente ocurren en producción: una migración que funciona en una base de datos vacía pero falla en una con filas, y una migración cuyo SQL está bien pero cuyo esquema resultante no coincide con tus definiciones de entidad (Room comprueba esto estrictamente, hasta el orden de las columnas y los valores por defecto).

La lista de verificación

Antes de que se despliegue cualquier subida de versión de esquema: la exportación de esquema está activada, cada salto de versión tiene una AutoMigration o una Migration escrita a mano registrada, fallbackToDestructiveMigration() no aparece en ningún lugar fuera del código de pruebas, y al menos una prueba MigrationTestHelper siembra filas reales y verifica que sobreviven. Nada de esto es trabajo emocionante. También es la diferencia entre un cambio de esquema que nadie nota y una bandeja de soporte llena de “mis datos desaparecieron” a la mañana siguiente de un lanzamiento.