Android Data, Networking & APIs

The Room Persistence Library

18 min Lesson 3 of 12

The Room Persistence Library

In the previous lesson you saw how to work with SQLite directly using SQLiteOpenHelper. That approach works, but it forces you to write repetitive boilerplate: create tables in raw SQL, write ContentValues packing and unpacking code, and cast columns by hand. Room is Google's official abstraction layer on top of SQLite. It eliminates most of that boilerplate while giving you compile-time verification of your SQL and seamless integration with the rest of the Jetpack ecosystem.

Room is not a replacement for SQLite. It is a mapping layer that generates the SQLite code for you. Under the hood your data still lives in a standard .db file on the device; Room just spares you from writing the repetitive parts by hand.

The Three Pillars of Room

Every Room setup involves exactly three types of class:

  1. Entity — a Java class annotated with @Entity. Each instance maps to one row in a database table.
  2. DAO (Data Access Object) — an interface or abstract class annotated with @Dao. You declare methods here and Room generates the SQL implementation.
  3. Database — an abstract class annotated with @Database that extends RoomDatabase. It is the main entry point that ties everything together.

Adding Room to Your Project

Open your module-level build.gradle and add the dependencies. Room ships as three separate artifacts:

// build.gradle (Module: app) dependencies { def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" // Java (not Kotlin) // Optional: helpers for testing Room in isolation testImplementation "androidx.room:room-testing:$room_version" }
Use annotationProcessor, not kapt. kapt is the Kotlin annotation processor. For a pure-Java Android project you must use annotationProcessor; otherwise Room's code generator will not run and you will get cryptic "cannot find symbol" errors at build time.

Defining an Entity

An entity is a plain Java object (POJO) decorated with Room annotations. Room reads these annotations at compile time and generates a CREATE TABLE statement for you.

import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.PrimaryKey; @Entity(tableName = "tasks") public class Task { @PrimaryKey(autoGenerate = true) public int id; @ColumnInfo(name = "title") public String title; @ColumnInfo(name = "is_done") public boolean isDone; @ColumnInfo(name = "created_at") public long createdAt; // Unix epoch millis // Room requires a no-arg constructor (or a matching @Ignore constructor) public Task() {} public Task(String title) { this.title = title; this.isDone = false; this.createdAt = System.currentTimeMillis(); } }

Key annotation details:

  • @Entity(tableName = "tasks") — the generated table name. If you omit it, Room uses the class name lowercased.
  • @PrimaryKey(autoGenerate = true) — tells Room to use SQLite's AUTOINCREMENT equivalent. Every entity must have exactly one primary key.
  • @ColumnInfo(name = "...") — maps the Java field to a specific column name. Optional, but strongly recommended so that renaming a Java field does not silently break your queries.
Room does not support storing complex objects directly. Fields must be primitive types, boxed primitives, String, or types that have a registered @TypeConverter. If you try to store a List or a custom class directly, Room will refuse to compile with an error like "Cannot figure out how to save this field into database."

Defining the DAO

The DAO is where you declare the operations your app needs. Room generates the implementation. You write method signatures and SQL (or let Room infer the SQL for common CRUD operations).

import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; import androidx.room.Update; import java.util.List; @Dao public interface TaskDao { // INSERT — Room generates the INSERT OR IGNORE (or REPLACE) SQL @Insert(onConflict = OnConflictStrategy.REPLACE) void insert(Task task); // UPDATE — matches rows by primary key @Update void update(Task task); // DELETE — matches rows by primary key @Delete void delete(Task task); // Custom SELECT — you write the SQL; Room validates it at compile time @Query("SELECT * FROM tasks ORDER BY created_at ASC") List<Task> getAllTasks(); @Query("SELECT * FROM tasks WHERE is_done = 0 ORDER BY created_at ASC") List<Task> getPendingTasks(); @Query("SELECT * FROM tasks WHERE id = :taskId") Task getById(int taskId); @Query("DELETE FROM tasks WHERE is_done = 1") void deleteCompletedTasks(); }

Notice how the @Query annotation takes a plain SQL string. Room parses this SQL at compile time and reports errors — a mistyped column name or table name will cause a build failure, not a crash at runtime. This is one of Room's most valuable safety guarantees.

Creating the Database Class

The database class is the glue. It declares which entities belong to the schema, the schema version, and exposes factory methods to get DAO instances.

import android.content.Context; import androidx.room.Database; import androidx.room.Room; import androidx.room.RoomDatabase; @Database(entities = {Task.class}, version = 1, exportSchema = false) public abstract class AppDatabase extends RoomDatabase { // Abstract method — Room generates the implementation public abstract TaskDao taskDao(); // Thread-safe singleton private static volatile AppDatabase INSTANCE; public static AppDatabase getInstance(Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder( context.getApplicationContext(), AppDatabase.class, "task_database" // the .db file name on disk ).build(); } } } return INSTANCE; } }

Two important decisions made here:

  • Singleton pattern with double-checked locking — creating a RoomDatabase is expensive (it opens the file and compiles migrations). You want exactly one instance per process.
  • context.getApplicationContext() — passing an Activity context would leak the Activity. The Application context lives for the full process lifetime and is safe to hold.

Using Room on a Background Thread

Room enforces a strict rule: database operations may not run on the main (UI) thread. Doing so would block the UI and cause ANR (Application Not Responding) errors. The simplest way to comply is to use a background thread explicitly:

// Inside an Activity or Fragment AppDatabase db = AppDatabase.getInstance(this); TaskDao dao = db.taskDao(); // Write operation on a background thread new Thread(() -> { Task t = new Task("Buy groceries"); dao.insert(t); }).start(); // Read operation on a background thread, then update the UI on the main thread new Thread(() -> { List<Task> tasks = dao.getAllTasks(); runOnUiThread(() -> { // update RecyclerView adapter, etc. adapter.submitList(tasks); }); }).start();
Room + LiveData or Kotlin Coroutines? In professional projects you will often see DAO methods return LiveData<List<Task>> instead of plain List<Task>>. Room then runs the query on a background thread automatically and posts updates to observers on the main thread whenever the data changes. This is the recommended production pattern. For now, using explicit threads clearly shows what Room actually does under the hood.

Schema Migrations

When you add a column or table you must increment the version in @Database and provide a Migration object. Without a migration, Room will throw an IllegalStateException when it detects the schema version mismatch.

import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; // Migration from version 1 to version 2 static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL( "ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0" ); } }; // Register the migration when building the database INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "task_database") .addMigrations(MIGRATION_1_2) .build();

During development you can call .fallbackToDestructiveMigration() instead to drop and recreate the database on a version mismatch. Never use this in production — it permanently destroys the user's data.

Summary

Room reduces Android database work to three annotated classes: an @Entity that describes a table row, a @Dao interface where you declare queries (verified at compile time), and an @Database singleton that binds everything together. Always access Room on a background thread, use getApplicationContext() for the singleton, and define explicit Migration objects every time the schema changes. In the next lesson you will tackle background threading in depth and learn about the modern options — ExecutorService and WorkManager — that pair naturally with Room in production apps.