Índices de base de datos Room en 2026: encontrar la consulta que realmente va lenta y arreglarla
Una guía práctica para indexar una base de datos Room/SQLite en Android — leer EXPLAIN QUERY PLAN, añadir @Index sin adivinar, y los errores que anulan un índice en silencio.
Una app basada en Room que se sentía instantánea en pruebas puede empezar a atascarse meses después, cuando un usuario real tiene mil transacciones en vez de las doce que usaste para probar. El instinto habitual es añadir un índice en algún sitio y esperar lo mejor. Eso funciona casi tan a menudo como no funciona, porque los índices no aceleran una tabla — aceleran un patrón de acceso concreto, y el equivocado añade coste de escritura sin ningún beneficio en lectura. Aquí va cómo encontrar la consulta que realmente va lenta, confirmarlo, e indexarla correctamente.
No adivines — mide
SQLite te dice exactamente cómo planea ejecutar una consulta si se lo pides. Antepón EXPLAIN QUERY PLAN a cualquier consulta y ejecútala vía adb shell o una consulta Room sin procesar:
@RawQuery
fun explain(query: SupportSQLiteQuery): List<ExplainRow>
EXPLAIN QUERY PLAN
SELECT * FROM transactions WHERE category_id = 7 ORDER BY date DESC;
La línea de salida cuenta toda la historia. SCAN transactions significa que SQLite está leyendo cada fila de la tabla y comprobando la condición en cada una — el coste crece linealmente con el tamaño de la tabla. SEARCH transactions USING INDEX idx_transactions_category (category_id=?) significa que salta directamente a las filas que coinciden. Todo lo relativo a la indexación se reduce a convertir SCAN en SEARCH para las consultas que realmente ejecutas a menudo, y dejar todo lo demás en paz.
El error que hay que evitar aquí es indexar según qué columna parece importante. Una columna notes rara vez se filtra; un category_id usado en la cláusula WHERE de cada pantalla de lista es una historia completamente distinta. Ejecuta EXPLAIN QUERY PLAN en tus cinco o seis consultas DAO más frecuentes antes de tocar nada — las que construyen las pantallas de lista principales y se ejecutan en cada apertura de la app.
Añadir el índice en Room
Room expone el CREATE INDEX de SQLite a través del parámetro indices de la anotación @Entity:
@Entity(
tableName = "transactions",
indices = [Index(value = ["category_id"]), Index(value = ["date"])],
)
data class Transaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val categoryId: Long,
val date: Long,
val amountCents: Long,
)
Esto es un cambio de esquema, así que necesita una Migration, igual que añadir una columna:
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date)")
}
}
La exportación de esquema de Room (exportSchema = true más el argumento del compilador room.schemaLocation) marcará una discrepancia entre los índices de tu @Entity y una migración escrita a mano si se desincronizan — así es como normalmente se detecta este error antes de publicarlo.
La trampa del índice compuesto
Una consulta que filtra por category_id y ordena por date — exactamente el patrón de arriba — no obtiene el beneficio completo de dos índices de columna única separados. SQLite puede usar un índice por tabla por consulta en la mayoría de los casos, así que elige el más selectivo y aun así tiene que ordenar los resultados en memoria. Un índice compuesto que cubra ambas columnas, en el orden correcto, permite a SQLite usar el índice tanto para el filtro como para devolver las filas ya ordenadas:
indices = [Index(value = ["category_id", "date"])]
El orden importa aquí. Este índice sirve a WHERE category_id = ? y a WHERE category_id = ? ORDER BY date, porque ambas condiciones leen el índice de izquierda a derecha. No ayuda a una consulta que filtra solo por date — para eso seguirías necesitando el índice de columna única sobre date, o un segundo índice compuesto con date primero. Vuelve a comprobar EXPLAIN QUERY PLAN después de añadir un índice compuesto; si sigues viendo USE TEMP B-TREE FOR ORDER BY en el plan, el orden de columnas no coincide con lo que necesita la consulta.
Los índices no son gratis
Cada índice que SQLite mantiene tiene que actualizarse en cada INSERT, UPDATE o DELETE que toque una columna indexada. Para una tabla como transactions en una app de presupuesto, las escrituras son relativamente raras comparadas con las lecturas — un puñado de inserciones al día frente a docenas de renderizados de lista — así que el trade-off es fácil. Para una tabla que se escribe constantemente y se lee poco (un registro de eventos, una cola de sincronización), los mismos tres índices que ayudaron a la tabla de transacciones pueden ralentizar las escrituras de forma medible sin ningún beneficio de lectura que nadie note. Indexa las tablas que se consultan en cada apertura de pantalla, no cada tabla del esquema.
La clave primaria ya recibe un índice implícito — incluirla otra vez en indices es redundante. Lo mismo para una columna marcada @PrimaryKey o ya declarada unique = true en otro índice; SQLite crea el índice subyacente automáticamente.
Dónde importa esto realmente
Encontré esto de la forma aburrida, en Granyn: una lista de transacciones que se sentía instantánea con unos cientos de filas empezó a tardar un instante visible al filtrar por categoría en cuanto el uso real superó unos miles de filas. EXPLAIN QUERY PLAN en la consulta DAO mostraba un SCAN completo — el filtro category_id no tenía ningún índice que usar. Añadir el índice compuesto de arriba llevó la consulta filtrada de un escaneo completo de tabla de vuelta a una búsqueda indexada, y el atasco desapareció. Sin cambios de arquitectura, sin librería nueva, solo las dos líneas correctas en una migración.
La lección se generaliza más allá de esta tabla: antes de recurrir a la paginación, el caché o una reescritura cuando una app local-first se ralentiza, ejecuta EXPLAIN QUERY PLAN en la consulta que realmente va lenta. La mayoría de las veces la solución es un índice, es pequeña, y reversible si te equivocas.
// Lecturas relacionadas
Más del diario
Room TypeConverters en 2026: cómo guardar enums, fechas y listas sin corromper tu esquema
Una guía práctica sobre los TypeConverters de Room en Android — enums, Instant/LocalDate y listas — además de los errores que convierten un converter en un bug silencioso de corrupción de datos.
@Relation de Room: consultar datos uno-a-muchos en Android sin consultas N+1
Una guía práctica de la anotación @Relation de Room — modelar datos uno-a-muchos como categorías y entradas sin consultas N+1 ni joins manuales.
Búsqueda de texto completo en Room: añadir búsqueda instantánea a una app Android local-first en 2026
Una guía práctica del soporte FTS4 de Room en Android — construir una tabla virtual de búsqueda, mantenerla sincronizada con triggers, y por qué FTS5 exige una migración manual.