Mudar o banco local de um app Android parece simples enquanto o produto ainda está no emulador. Em produção, porém, cada usuário carrega uma versão diferente do schema no próprio aparelho. Alguns atualizaram ontem, outros ficaram meses sem abrir o app, outros têm dados criados offline e ainda não sincronizados. É por isso que migrations do Room com Kotlin são uma parte crítica da engenharia Android, não um detalhe burocrático de version = 2.

O Room ajuda bastante porque valida queries em tempo de compilação, gera código para DAOs e oferece suporte a migrations automáticas e manuais. Mas ele não adivinha intenção de produto. Renomear uma coluna, dividir uma tabela, criar índice, mudar tipo de campo ou preservar dados pendentes exige disciplina. Este guia mostra um caminho prático para evoluir schema com segurança em apps Android Kotlin em 2026.

Se você ainda está montando a base, comece pelo tutorial de Room Database com Kotlin, pela arquitetura Android offline-first com Kotlin e pelo guia de testes Android com Compose e Maestro. Migrations ficam muito mais previsíveis quando banco, sincronização e testes já têm responsabilidades claras.

Quando uma migration é necessária?

Toda mudança estrutural no banco local precisa de uma estratégia de migration. Exemplos comuns:

  • adicionar uma coluna em uma tabela existente;
  • renomear tabela ou coluna;
  • criar índice para uma query lenta;
  • mudar relacionamento entre entidades;
  • dividir uma entidade grande em duas tabelas;
  • alterar NOT NULL, valor padrão ou tipo de dado;
  • criar tabela para fila de sincronização offline;
  • remover campos antigos depois de uma mudança de produto.

Mudanças apenas no código Kotlin, como renomear uma propriedade mantendo @ColumnInfo(name = "campo_antigo"), podem não mudar o schema. Mas qualquer alteração no SQL final precisa ser tratada como evolução de dados.

Exporte o schema desde o começo

O primeiro hábito saudável é manter exportSchema = true e versionar os arquivos JSON gerados pelo Room. Eles são a memória formal do banco: permitem comparar versões, testar migrations e entender como o app chegou ao estado atual.

@Database(
    entities = [TarefaEntity::class, ProjetoEntity::class],
    version = 3,
    exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun tarefaDao(): TarefaDao
    abstract fun projetoDao(): ProjetoDao
}

No Gradle Kotlin DSL, a configuração moderna aponta o diretório de schemas:

android {
    defaultConfig {
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += mapOf(
                    "room.schemaLocation" to "$projectDir/schemas",
                    "room.incremental" to "true",
                )
            }
        }
    }
}

Em projetos com KSP, configure o argumento no bloco apropriado:

ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
    arg("room.incremental", "true")
}

Versione esses JSONs no Git. Sem eles, você perde uma das melhores proteções contra migrations quebradas.

AutoMigration para mudanças simples

Para mudanças previsíveis, o Room consegue gerar migrations automáticas. Adicionar uma coluna com valor padrão, por exemplo, costuma ser um bom caso.

@Entity(tableName = "tarefas")
data class TarefaEntity(
    @PrimaryKey val id: Long,
    val titulo: String,
    val concluida: Boolean,
    val prioridade: Int = 0,
)

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

AutoMigration é ótima quando a intenção é clara e o Room consegue inferir o SQL sem ambiguidade. Ela não deve ser usada como desculpa para não revisar o schema. Leia o diff do JSON gerado e rode teste de migration.

Quando há renome, remoção ou mudança ambígua, declare uma especificação:

@RenameColumn(
    tableName = "tarefas",
    fromColumnName = "nome",
    toColumnName = "titulo",
)
class Migration1To2Spec : AutoMigrationSpec

E conecte no banco:

@Database(
    entities = [TarefaEntity::class],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2, spec = Migration1To2Spec::class),
    ],
    exportSchema = true,
)
abstract class AppDatabase : RoomDatabase()

Migration manual para cenários de produto

Migrations manuais continuam essenciais quando a mudança envolve transformação real de dados. Imagine que o app tinha status como texto livre e agora quer separar concluida e arquivada.

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE tarefas ADD COLUMN concluida INTEGER NOT NULL DEFAULT 0")
        db.execSQL("ALTER TABLE tarefas ADD COLUMN arquivada INTEGER NOT NULL DEFAULT 0")

        db.execSQL("""
            UPDATE tarefas
            SET concluida = 1
            WHERE status = 'done' OR status = 'concluida'
        """.trimIndent())

        db.execSQL("""
            UPDATE tarefas
            SET arquivada = 1
            WHERE status = 'archived' OR status = 'arquivada'
        """.trimIndent())
    }
}

Depois registre no builder:

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

Esse tipo de migration merece revisão cuidadosa. Pense em dados nulos, valores inesperados, usuários que pulam versões e registros criados offline. Em um app offline-first, nunca apague campos de controle de sincronização sem garantir que operações pendentes foram preservadas ou migradas.

Testando migrations com MigrationTestHelper

A regra mais importante: migration que não foi testada é aposta. O Room oferece MigrationTestHelper para criar um banco na versão antiga, inserir dados e validar a chegada na versão nova.

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

    @Test
    fun migrate2To3_preservaTarefasConcluidas() {
        helper.createDatabase("test.db", 2).apply {
            execSQL("""
                INSERT INTO tarefas (id, titulo, status)
                VALUES (1, 'Enviar relatório', 'done')
            """.trimIndent())
            close()
        }

        val db = helper.runMigrationsAndValidate(
            "test.db",
            3,
            true,
            MIGRATION_2_3,
        )

        val cursor = db.query("SELECT concluida, arquivada FROM tarefas WHERE id = 1")
        cursor.moveToFirst()
        assertThat(cursor.getInt(0)).isEqualTo(1)
        assertThat(cursor.getInt(1)).isEqualTo(0)
        cursor.close()
    }
}

Teste pelo menos o caminho da versão imediatamente anterior. Em apps com muitos usuários, também vale testar saltos maiores, como 1 para 4, porque nem todo mundo instala todas as versões intermediárias.

Cuidados com fallback destrutivo

fallbackToDestructiveMigration() apaga e recria o banco quando a migration não existe. Isso pode parecer prático no desenvolvimento, mas em produção é perigoso. Se o banco tem favoritos, rascunhos, histórico, cache caro ou operações pendentes, destruir o banco vira perda de dados.

Use fallback destrutivo apenas em bases descartáveis, protótipos ou ambientes internos. Para produção, prefira falhar em teste e corrigir a migration antes do release.

Se existe uma tabela realmente descartável, isole esse dado. Nem todo cache precisa viver no mesmo banco que guarda estado importante do usuário.

Checklist antes do release

Antes de publicar uma versão que muda o schema, revise:

  • version do banco foi incrementada;
  • schemas JSON foram gerados e versionados;
  • migration cobre todos os caminhos necessários;
  • dados existentes são preservados ou descartados de forma intencional;
  • índices foram criados para queries novas ou mais pesadas;
  • migrations rodam em teste instrumentado;
  • saltos de versão relevantes foram testados;
  • fallbackToDestructiveMigration() não está escondendo perda de dados;
  • o app abre uma tela real após a migration;
  • métricas e logs conseguem distinguir erro de migration de erro de rede.

Para apps com WorkManager, DataStore e sincronização em background, pense também na ordem de inicialização. Não deixe um worker antigo tentar escrever em uma tabela que acabou de ser remodelada sem passar pelo novo código de repository.

Como pensar em migrations no dia a dia

A melhor migration é pequena, explícita e fácil de revisar. Evite acumular cinco mudanças de banco em um único release. Quando a mudança de produto for grande, quebre em etapas: adicionar nova coluna, preencher dados, passar leitura para o novo campo, remover o antigo depois de algumas versões.

Também vale tratar migration como parte do design da feature. Se a tarefa do sprint diz “adicionar favoritos offline”, ela deveria incluir entidade, DAO, sync, UI e migration. Deixar banco para o final aumenta a chance de quebrar usuários existentes.

Migrations bem feitas raramente aparecem para o usuário. Esse é o objetivo. O app atualiza, abre normalmente e os dados continuam lá. Para o time, porém, elas deixam um rastro claro no Git, nos schemas e nos testes. Esse rastro é o que permite evoluir um app Android Kotlin por anos sem medo de tocar no banco local.

Se você quer consolidar a trilha, estude também Paging 3 com Kotlin e Compose, segurança de dados locais no Android e KSP em Kotlin. Para comparar outras abordagens de persistência tipada, veja como o ecossistema irmão de Rust usa SQLx e Diesel em projetos com banco relacional.

Room precisa de migration para adicionar coluna?

Sim, se a coluna muda o schema da tabela. Para uma coluna nova simples com valor padrão, AutoMigration costuma resolver bem. Ainda assim, gere schema, revise o diff e teste a migration.

Posso usar fallbackToDestructiveMigration em produção?

Evite. Ele apaga e recria o banco quando falta migration. Em produção, isso pode perder favoritos, rascunhos, cache importante e operações offline pendentes.

Preciso testar migrations antigas?

Sim. Teste pelo menos a versão anterior para a atual. Se o app tem muitos usuários que demoram a atualizar, teste saltos maiores, como versão 1 para 4.

AutoMigration substitui migration manual?

Não. AutoMigration cobre mudanças simples ou explicitamente anotadas. Transformações de dados, regras de produto, tabelas temporárias e preservação de estados offline ainda pedem migrations manuais.