← Back to books

Chapter 2: Setting Up Room

Setup chapters have a reputation for being the boring part you skim on the way to the good stuff. Resist that urge here. A surprising share of the "Room is so confusing" frustration you will see online is not about Room at all — it is about a build that was wired up slightly wrong, producing errors that look terrifying and have nothing to do with the actual database code. Get the setup right once, understand why each piece is there, and you will spend the rest of the book thinking about data instead of fighting Gradle.

In this chapter we will take a fresh Android project and add Room to it properly. Along the way you will learn where each dependency comes from, why Room needs a special build tool called KSP, how to write the @Database class, and how to turn on one setting — schema export — that costs nothing today and saves you real pain when we reach migrations. By the end, Marginalia will have a real (if empty) database that builds and runs.

The mental model of the build

Before touching any files, hold this picture in your head. Room is a code generator. You write a small amount of declarative code — an entity, a DAO, a database class, all just annotations and method signatures — and at build time Room reads those annotations and writes the actual implementation for you: the SQL, the cursor handling, the row-to-object mapping, all the tedious code we saw in Chapter 1.

That generation has to happen somewhere in the build, and that "somewhere" is an annotation processor. Modern Room uses one called KSP (Kotlin Symbol Processing). So setting up Room is really two tasks bolted together:

  1. Tell Gradle to run KSP, the tool that lets Room generate code.
  2. Add the Room libraries themselves — the runtime you call into, and the compiler KSP runs.

Everything below is just those two tasks, done carefully.

Prerequisites

You will need a recent, stable Android Studio and a fresh project created from the "Empty Activity" template (the Compose one is fine; nothing in this book depends on your UI toolkit). When you create the project, Android Studio will set it up with a modern build configuration using Kotlin DSL (.kts files) and a version catalog — a file named gradle/libs.versions.toml. If your version of Android Studio still uses the older Groovy build files, everything here still works; the syntax just differs slightly, and I will note where.

This book assumes you are comfortable reading Kotlin and have seen coroutines and Flow at least once. You do not need any prior database experience.

Pinning the toolchain

Android's build tooling moves quickly, and mismatched versions are the number one cause of setup failures for newcomers. To keep us on solid ground, this book pins to a specific, coherent set of versions from mid-2026. Use these to follow along; once everything works you can update deliberately, one piece at a time.

Tool Version What it is
Android Gradle Plugin (AGP) 9.2.0 The plugin that builds Android apps
Kotlin 2.3.21 The Kotlin compiler and language
KSP matches your Kotlin version Runs Room's code generation
Room 2.8.4 The persistence library itself

Two of these deserve a word of caution.

KSP is versioned to track Kotlin, so its version string looks unusual and must match your Kotlin version exactly. This is the single most common setup mistake. If your Kotlin is 2.3.21, you do not get to pick any KSP you like — you use the KSP release built for 2.3.21. Always copy the exact KSP version string from the KSP releases page (github.com/google/ksp/releases) that corresponds to your Kotlin version. A KSP version that is even slightly out of step with your Kotlin version produces confusing failures. When you see a build error mentioning KSP and "incompatible Kotlin version," this mismatch is almost always the cause.

AGP 9.0 and newer include built-in Kotlin support. In older projects you had to apply a separate Kotlin plugin (org.jetbrains.kotlin.android) yourself. On AGP 9.x that support is built in and enabled by default, so a freshly created project may not apply that plugin explicitly, and it does not need to. I will point out where this matters below. If your project or template does still apply the Kotlin plugin, that is fine too — just don't be surprised by its absence in a brand-new project.

Step 1: Declare versions in the catalog

The version catalog centralizes every version and dependency in one file, so you are never hunting through build scripts to change a number. Open gradle/libs.versions.toml and add Room and KSP.

[versions]
agp = "9.2.0"
kotlin = "2.3.21"
# Use the KSP release that matches your Kotlin version exactly.
# Copy the precise string from github.com/google/ksp/releases.
ksp = "2.3.21-2.0.4"
room = "2.8.4"

[libraries]
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

A quick tour of what you just declared:

  • room-runtime is the part of Room your app code actually calls into at runtime.
  • room-compiler is the code generator. It runs through KSP at build time and is not part of your shipped app.
  • room-ktx adds Kotlin niceties, including coroutine extensions. In recent Room versions much of this has been folded into the runtime, so it is optional — but including it is harmless and keeps you covered.
  • room-testing provides helpers for testing your database and, crucially, your migrations. We won't use it until later, but it belongs with the others.

A note on the Groovy build files. If your project uses build.gradle (Groovy) instead of .kts, and does not have a version catalog, you will declare these versions as plain variables inside your build files instead. The dependency coordinates (androidx.room:room-runtime:2.8.4 and so on) are identical either way. The catalog is the modern default and what new projects generate, so that is what we use here.

Step 2: Register the KSP plugin at the project root

Your top-level build.gradle.kts (the one at the root of the project, not inside the app module) declares which plugins are available to the project. Add KSP there, applied false, which makes it available without activating it at the root level.

// Root build.gradle.kts
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.ksp) apply false
}

The alias(...) syntax is how you reference an entry from the version catalog. libs.plugins.ksp points at the ksp plugin you declared in the catalog's [plugins] block.

Step 3: Apply the plugin and add dependencies in the app module

Now open the app module's build.gradle.kts — the one inside the app/ folder. First, apply the KSP plugin here, where your actual code lives:

// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.ksp)
}

Then add the Room dependencies. Note the special line: the compiler is added with ksp(...), not implementation(...), because it is a code generator that runs through KSP rather than a library you call at runtime.

// app/build.gradle.kts
dependencies {
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)      // optional, but convenient
    ksp(libs.room.compiler)            // the code generator — note: ksp(), not implementation()

    testImplementation(libs.room.testing)
}

That ksp(libs.room.compiler) line is the one people forget, and its absence produces a baffling symptom: your annotations compile, but Room never generates any code, so the class you expect (more on that in Step 5) simply does not exist. If you ever see "unresolved reference" for a Room-generated class, check that this line is present first.

Sidebar: why KSP and not KAPT?

If you read older Room tutorials you will see kapt(...) where we wrote ksp(...). KAPT (Kotlin Annotation Processing Tool) was the previous way to run annotation processors in Kotlin. It worked, but it was slow, because it generated Java stubs of all your Kotlin code just so a Java-based processor could read them.

KSP was built to replace it. It understands Kotlin directly, skips the stub-generation step, and as a result processes annotations noticeably faster. By mid-2026, KAPT is in maintenance mode, the modern Kotlin and Android toolchains push you toward KSP, and Room's newest versions are KSP-only. There is no reason to use KAPT for a new project, so this book uses KSP exclusively. If you inherit an old codebase still on KAPT, the Room code itself is unchanged — only the build wiring differs.

Step 4: Sync and confirm the build

Click Sync Project with Gradle Files in Android Studio (the elephant icon, or the banner that appears after editing build files). Gradle will download Room and KSP if it hasn't already. If the sync succeeds, your setup is wired correctly and you are ready to write the database class.

If the sync fails, jump to the troubleshooting section at the end of this chapter — the two or three errors that commonly appear at this stage are all easy to fix once you know what they mean.

Step 5: Write the @Database class

With the build wired up, we can write the piece that ties Room together: the database class. This is the container we previewed in Chapter 1. For now Marginalia has no tables yet — we add its first entity in the next chapter — so this database is deliberately minimal. But writing it now lets us confirm the whole pipeline works end to end.

Create a MarginaliaDatabase class:

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(
    entities = [],      // no tables yet — we add Book in Chapter 3
    version = 1,
    exportSchema = true
)
abstract class MarginaliaDatabase : RoomDatabase()

Three things are worth understanding here, because you will edit each of them repeatedly as the app grows.

It must be an abstract class that extends RoomDatabase. You never write the body of this class. Room generates a concrete implementation of it at build time — that is what KSP is for. Your job is only to declare its shape.

entities lists every table in the database. Right now it is empty. As we build Marginalia, every new entity gets added to this list. Forgetting to add an entity here is a common mistake: Room won't know the table exists, and queries against it will fail.

version is the schema version number, and it starts at 1. This single integer is the backbone of migrations. Every time you change the shape of the database — add a column, add a table — you will increase this number, and Room will use it to figure out how to upgrade an existing user's database. We are not migrating anything yet, but the number is here from day one because it has to be.

You will also notice exportSchema = true. That deserves its own step.

Step 6: Turn on schema export (do this now, thank yourself later)

exportSchema = true tells Room to write a JSON description of your database schema to a file every time you build. Version 1's schema goes in a file named 1.json, version 2 in 2.json, and so on. These files are a precise, machine-readable record of exactly what your database looked like at each version.

Right now that sounds like pointless bookkeeping. It is not. When we reach migrations, these schema files are what let Room verify that your migration actually produces the schema it should, and they are what power automated migrations entirely. Turning schema export on after you already have users in the wild is painful; turning it on now, while the schema is trivial, costs one line and a small Gradle setting. Every experienced Room developer has, at least once, wished they had turned this on at the start. Let's just do it.

Room needs to know where to put the schema files. Tell it in the app module's build.gradle.kts, inside the android { } block, using Room's Gradle plugin room configuration. The simplest reliable approach is to add the Room Gradle plugin and point it at a schemas directory checked into your project:

// In gradle/libs.versions.toml, add to [plugins]:
// androidx-room = { id = "androidx.room", version.ref = "room" }

// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.ksp)
    alias(libs.plugins.androidx.room)
}

room {
    schemaDirectory("$projectDir/schemas")
}

Add the same plugin, apply false, to your root build.gradle.kts alongside the others so it is available to the module.

The schemas folder that appears will contain your schema JSON files. Commit this folder to version control. It is part of your project's history — losing it means losing the ability to validate future migrations against past versions. Treat those files as important as your source code, because for migrations, they are.

If you are on an older Room setup without the Room Gradle plugin, you may instead see schema export configured through a KSP argument (ksp { arg("room.schemaLocation", "$projectDir/schemas") }). Both approaches achieve the same result. The Gradle plugin shown above is the current, recommended way and handles build flavors correctly, so prefer it.

Step 7: Provide a single database instance

Creating a RoomDatabase is expensive — it opens files, sets up connections, and does real work. You should build exactly one instance for the whole app and share it everywhere. Creating a new database object per screen, or per request, is a classic performance mistake that will quietly slow an app down and can cause data inconsistencies.

The standard way to guarantee a single instance is the singleton pattern. Here is a minimal, thread-safe version:

import android.content.Context
import androidx.room.Room

object DatabaseProvider {

    @Volatile
    private var instance: MarginaliaDatabase? = null

    fun get(context: Context): MarginaliaDatabase {
        return instance ?: synchronized(this) {
            instance ?: buildDatabase(context).also { instance = it }
        }
    }

    private fun buildDatabase(context: Context): MarginaliaDatabase {
        return Room.databaseBuilder(
            context.applicationContext,
            MarginaliaDatabase::class.java,
            "marginalia.db"
        ).build()
    }
}

Walk through the important parts:

  • context.applicationContext — we deliberately use the application context, not an Activity's context. The database outlives any single screen, so tying it to an Activity would risk a memory leak. This is a small detail juniors often get wrong; getting it right here is a good habit.
  • "marginalia.db" is the filename of the underlying SQLite database on the device. You will only ever see this name here.
  • @Volatile and synchronized ensure that even if two threads ask for the database at the same time, exactly one instance is created. You can copy this pattern; you rarely need to think about it again.

In a real app you would usually hand this off to a dependency injection framework (Hilt or Koin) rather than a hand-written singleton, so that the database is provided to your ViewModels and repositories automatically. We keep it simple and explicit here so nothing is hidden. The concept — one database instance, shared — is what matters, and it stays true no matter how you wire it.

One thing not to do: allowMainThreadQueries()

While reading the builder API you will discover a method called allowMainThreadQueries(), and in a moment of frustration you may be tempted to use it. Don't. By default, Room refuses to run database operations on the main thread, and that refusal is protecting you: database work on the main thread freezes the UI, and a frozen UI is how apps earn one-star reviews and "Application Not Responding" crashes.

The right answer is never to move work onto the main thread — it is to do database work off it, which Room makes easy through suspend functions and Flow. That is exactly how we will write every DAO from Chapter 3 onward, so you will never actually need allowMainThreadQueries(). Mentally file it under "things that exist but I will not use."

Verifying it all works

You now have a wired build, a database class, and a way to create it. Let's confirm the pipeline generates code. Trigger a build (Build > Make Project). If everything is correct, Room's compiler runs through KSP and generates the concrete implementation of MarginaliaDatabase behind the scenes, and the build succeeds with no errors.

You can prove the singleton works with a tiny bit of throwaway code — for example, in your Activity's onCreate, fetch the database and log that it exists:

val db = DatabaseProvider.get(this)
android.util.Log.d("Marginalia", "Database ready: $db")

Run the app. If you see that log line and no crash, congratulations — you have a real, empty Room database living on the device. It has no tables yet, so there is nothing to read or write. That is exactly what Chapter 3 is for. You can delete this throwaway logging afterward.

Troubleshooting common setup errors

If your build failed somewhere above, it is almost certainly one of these. Each is common, and each is quick to fix once you recognize it.

"Unresolved reference" for a Room-generated class, or Room seems to generate nothing. You are missing the ksp(libs.room.compiler) line, or you wrote it as implementation(...) instead of ksp(...). The compiler must be added through ksp() so it runs as a code generator. Fix that line and sync.

A KSP error mentioning an incompatible Kotlin version. Your KSP version does not match your Kotlin version. Go to the KSP releases page, find the release built for your exact Kotlin version, and copy that version string into the catalog. This is the mismatch we warned about; it is the most common single cause of setup pain.

"Schema export directory is not provided" (a warning or error). You set exportSchema = true but did not tell Room where to put the schema files. Add the Room Gradle plugin and the room { schemaDirectory(...) } block from Step 6. Until you configure a location, Room complains because it has nowhere to write the schema.

The build cannot find the androidx.room artifacts at all. Your project's repositories are not set up to fetch from Google's Maven repository. In a standard Android Studio project this is configured for you in settings.gradle.kts (the google() repository). If you started from an unusual template, make sure google() is present in your repositories.

Everything syncs but the app crashes on launch with a migration or schema error. You almost certainly changed the database's shape without increasing the version number, or you have an old copy of the database on the device from a previous run. During early development, before you have real users, the quickest fix is to uninstall the app from the device or emulator (which deletes the old database file) and run again. We will replace this crude "just wipe it" approach with proper migrations in Part IV — but during initial development, uninstalling to reset is perfectly normal and expected.

Sidebar: what changes for Room 3.0

If you decide to target Kotlin Multiplatform and use Room 3.0 instead, the shape of everything in this chapter stays the same — you still declare versions, apply KSP, write an abstract @Database class, export schemas, and provide a single instance. What changes is mostly names and a few builder details:

  • The package becomes androidx.room3 instead of androidx.room, and the dependency coordinates change to match (androidx.room3:room3-runtime, and so on).
  • Room 3.0 leans harder on coroutines: more operations are suspend, and you configure a database driver (from the androidx.sqlite family) when building the database. Because we already write our DAOs with suspend and Flow, our code needs little adjustment.
  • Building the database on multiplatform uses a slightly different builder that works across Android, iOS, desktop, and web, rather than the Android-only Room.databaseBuilder(context, ...) shown here.

None of this affects the concepts you are about to learn — entities, DAOs, queries, relations, migrations. It is a change of packaging, not of ideas. The dedicated Kotlin Multiplatform chapter later in the book walks through the Room 3.0 setup in full.

Recap

  • Room is a code generator. Setting it up means (1) enabling KSP, the tool that runs the generation, and (2) adding the Room libraries.
  • Pin a coherent toolchain. In this book: AGP 9.2.0, Kotlin 2.3.21, Room 2.8.4, and a KSP version that matches your Kotlin version exactly — the most common setup mistake is a KSP/Kotlin mismatch.
  • Declare versions and dependencies in the version catalog (libs.versions.toml). Add the compiler with ksp(...), not implementation(...) — forgetting this is why "generated class not found" errors happen.
  • KSP replaces KAPT. It is faster and is the modern, Room-recommended path; use it for all new projects.
  • The @Database class is an abstract class extending RoomDatabase, carrying its list of entities and a version number that starts at 1 and drives migrations later.
  • Turn on exportSchema = true and configure a schemas directory from day one, and commit that folder — it is what makes migration validation possible later.
  • Create exactly one database instance for the whole app using the singleton pattern, built with the application context. Never use allowMainThreadQueries(); do database work off the main thread with suspend and Flow instead.

Marginalia now has an empty database that builds and runs. In Chapter 3 we give it a reason to exist: we define our first entity — a Book — write a DAO to insert and read books, and put a real row into the database and pull it back out. That is Room's "hello world," and it is where all the setup pays off.