Chapter 1: Why Local Data, and Why Room
Open almost any app you love and turn on airplane mode. The good ones barely flinch. Your notes are still there. Your reading list still loads. The messages you sent while offline sit patiently in a queue, waiting for a signal. That resilience is not luck, and it is not magic. It is the result of a deliberate decision the developers made early: keep a copy of the important data on the device itself.
This book is about how to make that decision well on Android, using a library called Room. By the end of it you will be able to design a local database, read and write to it safely, model real relationships between your data, evolve that database over time without losing anyone's information, and build an app that treats the network as a bonus rather than a requirement.
We are going to build all of this around one running example: a personal reading tracker called Marginalia. You will meet it properly at the end of this chapter. First, though, we need to answer two questions that a lot of tutorials skip straight past. Why store data locally at all? And once you have decided to, why Room and not one of the half-dozen other ways Android offers?
If you are eager to write code, I understand the itch — but give this chapter fifteen minutes. Understanding the landscape now will save you from a very common junior mistake: reaching for a database when a simpler tool would do, or reaching for a simpler tool when you genuinely needed a database. Choosing the right storage tool is a real engineering skill, and it starts here.
What "local data" actually means
When we say an app stores data locally, we mean the data lives in storage that belongs to the app, on the user's device, and survives after the app is closed. That last part is the important one. Anything your app holds in memory — variables, objects, the contents of a list on screen — vanishes the moment the process is killed. And on Android, the process gets killed constantly. The system reclaims memory aggressively. A user switches to another app, the OS needs the RAM, and your app is quietly terminated in the background. When the user returns, Android recreates it from scratch.
If the only copy of something important lived in memory, it is now gone. The note the user typed, the item they favorited, the progress they made — gone. Persisting data locally is how you make information outlive the process that created it.
There are three broad reasons apps keep data on the device:
- Persistence. The data needs to still be there next time the app opens. A to-do list that forgets your tasks every launch is not a to-do list.
- Offline access. The user should be able to see and use their data without a network connection. This is the difference between an app that is pleasant to use on a subway and one that shows a spinner in a tunnel.
- Speed. Reading from a local database is dramatically faster than a round trip to a server. Even when the network is available, showing local data instantly and refreshing in the background feels far better than making the user wait.
Marginalia will lean on all three. A reader's library, their notes, their reading sessions — all of it needs to persist, all of it needs to work on a plane, and all of it needs to appear the instant the app opens.
The Android storage landscape
Android does not give you one way to store data. It gives you several, each tuned for a different job. A big part of becoming a competent Android developer is knowing which one to reach for. Let's walk through them from smallest to largest.
SharedPreferences
SharedPreferences is the oldest and simplest option. It stores small key–value pairs — a boolean here, a string there — in an XML file. Think of it as a tiny dictionary that survives restarts.
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
prefs.edit().putBoolean("dark_mode", true).apply()
val darkMode = prefs.getBoolean("dark_mode", false)
This is perfect for user settings: the app's theme, whether onboarding has been shown, the last selected tab. It is terrible for anything structured or large. There is no querying, no relationships, no notion of "give me all the books published after 2010 sorted by title." If you find yourself storing a list of complex objects in SharedPreferences by serializing them to a string, that is a signal you have outgrown it.
DataStore
DataStore is the modern replacement for SharedPreferences. It does the same key–value job but does it asynchronously (using Kotlin coroutines and Flow), which means it never blocks the main thread, and it avoids a class of subtle bugs that SharedPreferences was prone to. There is also a typed variant, Proto DataStore, for structured settings.
DataStore is the right choice for preferences and small pieces of app state today. But notice what it still is not: it is not a database. It has no tables, no queries, no relationships. It is for settings, not for your app's core content.
Files
You can always drop down to reading and writing raw files — a JSON document, an image, a cached PDF, a downloaded audio clip. Files are the right tool for large binary blobs and for data that is naturally file-shaped.
But storing structured data as files means you become responsible for everything: parsing, searching, keeping the format consistent as your app evolves, handling half-written files if the app is killed mid-save. For a photo, a file is perfect. For "all of a user's books and notes and reading sessions," hand-rolling a file format is a slow road to pain.
Raw SQLite
Every Android device ships with SQLite, a small, fast, rock-solid relational database engine built right into the platform. This is the real workhorse for structured local data. It gives you tables, columns, rows, relationships, and a powerful query language (SQL) for asking precise questions of your data.
Android exposes SQLite directly through a set of APIs (SQLiteOpenHelper, SQLiteDatabase, Cursor). They work — apps were built on them for years — but they are unpleasant and dangerous to use by hand:
// The old, raw way. We are NOT going to write code like this.
val db = helper.writableDatabase
val cursor = db.rawQuery("SELECT id, title FROM books WHERE year > ?", arrayOf("2010"))
val books = mutableListOf<Book>()
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow("id"))
val title = cursor.getString(cursor.getColumnIndexOrThrow("title"))
books.add(Book(id, title))
}
cursor.close()
Look closely at everything that can go wrong here. The SQL is a plain string, so a typo like SELCT is not caught until the query runs and crashes at runtime, in front of a user. The column names are strings too, so renaming a column silently breaks reads. You have to manually map each column into your object, remembering the exact type and order. And you have to remember to close the cursor, every time, or you leak resources. This is tedious, repetitive, and easy to get subtly wrong. Multiply it across dozens of queries in a real app and you have a maintenance nightmare.
The power of SQLite is exactly what we want. The raw API for accessing it is exactly what we want to avoid. That gap is the reason Room exists.
Where Room fits
Room is a persistence library from Google's Jetpack suite that sits on top of SQLite. It is not a different database — under the hood, it is still the same SQLite engine that ships with Android. What Room provides is a much safer, much more pleasant layer over that engine, so you get all of SQLite's power without hand-writing cursor code.
Room's headline benefit, and the one you should remember, is compile-time verification of your SQL. When you write a query in Room, the library checks it while your project compiles, before the app ever runs. If you misspell a table name, reference a column that does not exist, or ask for a result that does not match the object you want back, the build fails with a clear error message pointing at the exact line. Bugs that raw SQLite would have handed to your users as crashes, Room catches on your machine, in seconds. For a junior developer, this single feature is worth the price of admission — it turns a category of scary runtime crashes into ordinary, fixable compiler errors.
On top of that, Room:
- Maps rows to objects for you. You define a Kotlin class; Room converts database rows into instances of it and back again. No more manual cursor reading.
- Integrates with coroutines and
Flow. Database work happens off the main thread, and you can observe a query so your UI automatically updates whenever the underlying data changes. We will lean on this heavily. - Handles schema changes. When your database needs to evolve — a new column, a new table — Room gives you a structured way to migrate existing users' data instead of wiping it. This is a whole part of the book later on.
- Is the officially recommended approach. Google recommends Room as the default way to store structured data on Android. That means abundant documentation, community support, and long-term maintenance.
Here is the same query from before, written with Room:
@Query("SELECT * FROM books WHERE year > :minYear")
fun booksPublishedAfter(minYear: Int): Flow<List<Book>>
That is the whole thing. The SQL is checked at compile time, the rows are mapped into Book objects automatically, the result is returned as an observable Flow, and there is not a single cursor to close. This is the difference Room makes, and it is why the rest of this book is built on it.
A quick decision guide
Before we move on, here is a mental model for choosing a storage tool. Keep it handy; you will use it in real projects.
| You need to store… | Reach for… |
|---|---|
| A few settings or flags (theme, onboarding shown) | DataStore (or SharedPreferences on older code) |
| A large binary blob (image, audio, downloaded document) | A file |
| Structured data you need to query, sort, or relate | Room |
| Structured data that also must work offline and sync | Room, as the single source of truth |
Notice that Room is the answer whenever your data is structured and you need to ask questions of it. Marginalia's books, authors, shelves, notes, and reading sessions are exactly that kind of data. So Room it is.
The three building blocks of Room
Every Room database, no matter how large, is built from three kinds of pieces. You will meet each of them in depth over the next several chapters, but let's get the mental model in place now, because everything else hangs off it.
1. Entities — your tables. An entity is a Kotlin class annotated with @Entity. Each entity describes one table in the database, and each property of the class becomes a column. An instance of the class is one row. If you have a table of books, you have a Book entity.
@Entity
data class Book(
@PrimaryKey val id: Long,
val title: String,
val year: Int
)
2. DAOs — your operations. A DAO, or Data Access Object, is an interface annotated with @Dao. It is where you declare everything you want to do with a table: insert a book, delete a book, fetch all books, search books by title. You describe what you want using annotations and SQL; Room writes the actual implementation for you.
@Dao
interface BookDao {
@Insert
suspend fun insert(book: Book)
@Query("SELECT * FROM Book ORDER BY title")
fun observeAll(): Flow<List<Book>>
}
3. The database — the container. Finally, an abstract class annotated with @Database ties everything together. It lists which entities belong to the database, carries a version number, and exposes your DAOs.
@Database(entities = [Book::class], version = 1)
abstract class MarginaliaDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
}
That is the whole architecture in miniature: entities define the shape of your data, DAOs define what you can do with it, and the database class holds it all together and hands you the DAOs. Three ingredients. Every Room app, from a weekend project to a product used by millions, is some arrangement of these three. Keep this trio in your head as we go — whenever a new concept appears, it will attach to one of these three places.
Sidebar: Room 2.x or Room 3.0?
As you read about Room online in 2026, you will bump into two versions, and it is worth understanding the split so you are not confused.
Room 2.x (the latest is 2.8.4) is the mature, mainstream version that essentially every Android job, tutorial, and existing codebase uses. It lives in the
androidx.roompackage. It is in "maintenance mode," which sounds alarming but simply means it still receives bug fixes while new feature work moves to the next major version. It is stable, battle-tested, and exactly what you will encounter in real teams today.Room 3.0 shipped in July 2026 as a major rewrite focused on Kotlin Multiplatform — the ability to share your database code across Android, iOS, desktop, and web. It lives in a new package,
androidx.room3, is Kotlin-only, and makes more of its operations asynchronous.Here is the good news, and the reason this book does not have to pick a side dogmatically: the core annotations are the same in both.
@Entity,@Dao,@Database,@Query,@Insert— everything you learn here transfers. This book uses Room 2.8.4 as its concrete foundation, because that is what you will use in your first Android jobs, and it writes the code in a forward-compatible style (usingsuspendfunctions andFlowthroughout) so that moving to Room 3.0 later is a small step, not a rewrite. When a Room 3.0 detail matters, a sidebar like this one will point it out. Near the end of the book, a dedicated chapter shows how the same Marginalia data layer runs on Kotlin Multiplatform with Room 3.0.For now: learn the concepts, don't worry about the version. They are 90% the same thing.
Meet Marginalia, our running example
Reading about databases in the abstract is a good way to fall asleep. So everything in this book is anchored to one app that we build up chapter by chapter: Marginalia, a personal reading tracker. (Rename it to whatever you like in your own project — the point is the shape of the data, not the branding.)
The idea is simple and familiar. A reader wants to keep track of the books in their life: what they own, what they have read, what they thought about each one. Marginalia lets them do that entirely on their device, with an optional sync to fill in book details from a public catalog on the internet.
We chose this example on purpose, because a reading tracker forces every concept in this book to show up naturally, rather than being contrived. Look at how the data relates:
- A Book has one or more Authors, and an author writes many books. That is a many-to-many relationship — the exact situation that makes junction tables necessary, which we cover in Part III.
- A book can sit on several Shelves ("Favorites," "To Read," "Sci-Fi"), and a shelf holds many books. Another many-to-many relationship, from a different angle.
- A book accumulates Notes and ReadingSessions over time. Each of those belongs to exactly one book, while a book has many of each. That is a one-to-many relationship, the first kind of relation we will model.
Because these relationships are real and intuitive, you will never have to squint and wonder "why would anyone structure data this way?" You already know why — you can picture your own bookshelf.
Marginalia will also give honest reasons for the harder topics later in the book:
- Offline-first falls out naturally, because Marginalia pulls book details from a public catalog on the internet but must keep working with no connection. The database becomes the single source of truth, and the screen always reads from the database — never directly from the network.
- Migrations have a genuine motivation, because the app grows. In an early version there is no way to rate a book; later we add a rating. Later still we add reading goals, then fast search over notes. Each of those is a schema change that real users must survive without losing their library. That is exactly what migrations are for, and we will do each one for real.
By the time you finish this book, Marginalia will be a complete, offline-first Android app with a well-designed local database at its heart — and, more importantly, you will understand every decision that went into it.
What you'll be able to do by the end
To set expectations concretely, here is what you are building toward. After this book you will be able to:
- Set up Room in a fresh Android project and understand every line of the configuration.
- Design entities and DAOs for real, structured data — not just toy examples.
- Write queries that Room verifies at compile time, and observe them so your UI updates itself.
- Model one-to-one, one-to-many, and many-to-many relationships, and query across them.
- Evolve your schema safely with both manual and automated migrations.
- Build an offline-first architecture where Room is the single source of truth and the network is a background helper.
- Test your data layer with confidence, including your migrations.
None of that requires prior database experience. It does require a working comfort with Kotlin — classes, data classes, interfaces, and a passing familiarity with coroutines and Flow, which we will reinforce as we go. If you can read the code snippets in this chapter without feeling lost, you are ready.
Recap
- Apps store data locally for three reasons: persistence (data survives restarts and process death), offline access, and speed.
- Android offers several storage tools. DataStore and SharedPreferences are for small key–value settings; files are for large binary blobs; SQLite is for structured, queryable data.
- Raw SQLite is powerful but painful and error-prone to use by hand — string queries checked only at runtime, manual row-to-object mapping, easy resource leaks.
- Room is a Jetpack library layered over SQLite that keeps the power and removes the pain. Its standout feature is compile-time verification of your SQL, turning a class of runtime crashes into ordinary build errors. It also maps rows to objects, integrates with coroutines and
Flow, and gives you a structured path for schema changes. - Every Room database is built from three pieces: entities (tables), DAOs (operations), and a database class (the container that exposes the DAOs).
- Two versions of Room exist in 2026: 2.x (mainstream,
androidx.room, what this book uses) and 3.0 (androidx.room3, Kotlin Multiplatform focused). Their core annotations are identical, so what you learn transfers. - Our running example is Marginalia, a personal reading tracker, whose natural relationships and gradual growth give honest motivation for every topic ahead.
In the next chapter we stop talking and start building. We will set up a fresh Android project with Room wired in correctly — dependencies, the KSP plugin, the @Database class, and the one configuration setting that will save you enormous pain when we reach migrations. By the end of Chapter 2, Marginalia will have a real, empty database ready for its first table.