How to Store Data with Room Database in Android

Room is Android’s recommended persistence library for storing structured data locally. It provides a safer abstraction over SQLite, letting an app save records such as customer profiles, shopping lists, messages, bookings and cached API responses without relying on a remote connection every time.

For Australian users, local storage is particularly useful when an app needs to work on a train through Melbourne’s inner suburbs, during patchy regional coverage in Queensland, or while someone is travelling between Sydney and Canberra. Room helps preserve data offline, enforce a clear database structure and keep database operations away from the main user interface.

Why Room Is Useful for Local Storage

Room sits above SQLite and converts database tables into Kotlin objects. You define an entity to represent a table, a data access object (DAO) for queries, and a database class that brings those components together. Room then checks SQL queries during compilation, which can catch many mistakes before the app reaches users.

This approach is more maintainable than writing raw SQLiteOpenHelper code for most Android projects. Room supports relationships, migrations, observable queries and coroutine-based operations. It also works well with ViewModel, Jetpack Compose, LiveData and Kotlin Flow.

A local Room database should be treated as an app’s private storage layer. It is suitable for structured information that must remain available after the app closes, while sensitive credentials should usually be handled with encrypted storage and secure key management rather than placed in an ordinary table.

Preparing an Android Project

Create an Android application using Kotlin and add Room through the project’s Gradle configuration. If the project is still being created, this Android Studio project guide provides a useful starting point for configuring the basic application structure.

In a typical module-level build.gradle.kts file, add the Room runtime, Kotlin extensions and compiler integration. Modern projects commonly use KSP, although a project using older tooling may still use kapt.

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("androidx.room:room-runtime:<version>")
    implementation("androidx.room:room-ktx:<version>")
    ksp("androidx.room:room-compiler:<version>")
}

Use compatible versions from the official AndroidX release information rather than copying an outdated version number. After syncing Gradle, create a package for database-related classes, such as data.local, to keep persistence code organised.

Defining an Entity

An entity describes a table in the Room database. Each property becomes a column, and one property must act as the primary key. Auto-generated integer IDs are convenient for locally created records, while a server-provided ID may be better when synchronising data with an API.

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val completed: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

The table name is explicitly set to tasks, making SQL statements easier to read. Room can infer many Kotlin types, including String, Int, Long and Boolean. For more complex values such as dates, enums or lists, create a TypeConverter` that transforms the value into a supported database type.

Keep database entities focused on persistence. A separate domain model can prevent database-specific details from spreading into the user interface. This separation becomes valuable when a local task record later needs to combine with an API response or a user preference.

Creating a DAO

A DAO contains the operations the app is allowed to perform on a table. Room generates the implementation, while you write the interface and SQL annotations. Mark suspend functions for operations that should run away from the main thread.

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY createdAt DESC")
    fun observeTasks(): Flow<List<TaskEntity>>

    @Insert
    suspend fun insert(task: TaskEntity): Long

    @Update
    suspend fun update(task: TaskEntity)

    @Delete
    suspend fun delete(task: TaskEntity)

    @Query("DELETE FROM tasks WHERE id = :taskId")
    suspend fun deleteById(taskId: Long)
}

A Flow emits a new list whenever the underlying table changes, which makes it suitable for a reactive screen. The UI can collect that flow through a ViewModel and redraw automatically after an insertion or update.

Use query parameters such as :taskId rather than assembling SQL strings manually. Parameters improve readability and help avoid injection problems. For larger tables, add filtering, pagination or indexes so that a query does not load unnecessary records into memory.

Building the Room Database

The database class connects entities and DAOs. It should generally be a singleton, because creating several database instances can waste resources and cause inconsistent access.

@Database(
    entities = [TaskEntity::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

A provider can create the database when the application starts or when it is first needed:

val database = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "tasks.db"
).build()

For a production application, use dependency injection with a framework such as Hilt, or provide the database through an application-level dependency container. Avoid constructing it inside an Activity, since an Activity can be recreated during rotation or removed while the app is in the background.

Handling Migrations Safely

The database version must increase whenever the schema changes. If a new column is added, define a migration that changes the existing table while preserving user data.

val migrationOneToTwo = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL(
            "ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
        )
    }
}

Register the migration with the builder:

Room.databaseBuilder(context, AppDatabase::class.java, "tasks.db")
    .addMigrations(migrationOneToTwo)
    .build()

Avoid relying on destructive migration for an app that contains meaningful user records. Destructive migration deletes and recreates tables when Room cannot find a path between versions. That may be acceptable for disposable cache data, but it is a poor choice for a budgeting app, health log or regional delivery app where people expect their records to survive an update.

Export schemas and test migration paths. This matters when an application is distributed through Google Play to users across Australia, where devices may update at different times and some customers may remain on older app versions for weeks.

Connecting Room to the App

A repository can coordinate the DAO and provide a clean interface to the ViewModel. The ViewModel then launches writes inside viewModelScope, while the screen observes state rather than calling the database directly.

class TaskRepository(
    private val dao: TaskDao
) {
    val tasks: Flow<List<TaskEntity>> = dao.observeTasks()

    suspend fun addTask(title: String) {
        dao.insert(TaskEntity(title = title))
    }
}

class TaskViewModel(
    private val repository: TaskRepository
) : ViewModel() {
    val tasks = repository.tasks.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        emptyList()
    )

    fun addTask(title: String) {
        viewModelScope.launch {
            repository.addTask(title)
        }
    }
}

This arrangement keeps database work off the main thread and gives the interface a predictable state source. In a Compose screen, collect the state with lifecycle awareness. In a View-based application, expose LiveData or collect the Flow from a lifecycle-aware coroutine.

Room is also useful for cache-first applications. An app might display the latest saved weather or public transport information immediately, then request fresh results when connectivity returns. That pattern can reduce loading time for users on mobile data and make an app more dependable outside major metropolitan areas.

Practical Recommendations for a Reliable Database

Before releasing an app that uses Room, apply these practices:

  • Use meaningful table and column names, with primary keys chosen for the way records are created and synchronised.
  • Keep all database calls off the main thread with suspend functions, Flow and structured coroutines.
  • Add indexes to columns used frequently in WHERE, ORDER BY or relationship queries.
  • Write migration tests and preserve existing records when the schema changes.
  • Store private secrets elsewhere, and consider Australian Privacy Act obligations when collecting personal information.

A Room database is local to one installation, so it does not automatically synchronise across a user’s phone and tablet. If an app needs shared accounts, backups or multi-device access, combine Room with a remote service and define how conflicts, timestamps and failed uploads will be handled.

Use clear error states in the interface. A failed insert, full device or invalid migration should produce a recoverable result rather than silently losing a record. Helpful messages using familiar Australian wording can make an app feel more natural, whether it is used by a customer in Perth or a small business in regional New South Wales.

Build a small feature first: save a task, display it with a Flow, edit it, delete it and upgrade the schema with a migration. Once that path works, extend the same pattern to larger datasets and synchronisation. Start implementing the Room layer in your Android project, test it on both an emulator and a physical device, and verify that saved data remains available after restarts and updates.