Local Databases with SQLite
Local Databases with SQLite
Every Android device ships with a full SQLite engine baked into the OS. When your app needs to store structured, relational data — think a list of tasks with categories, a message history with timestamps, or a product catalogue with prices — a local SQLite database is the right tool. SharedPreferences is for key-value settings; SQLite is for anything that looks like a table.
This lesson teaches you how to manage an SQLite database in Android using the SQLiteOpenHelper class and the raw android.database.sqlite API. In the next lesson you will layer the Room abstraction on top; understanding what Room hides is what lets you debug it when it misbehaves.
How Android Manages the Database File
Android stores each app's SQLite database as a file under the app's private data directory: /data/data/<package>/databases/<name>.db. Other apps cannot read it. The file is created the first time you open the database — you never create it manually.
The lifecycle entry point is SQLiteOpenHelper. You subclass it, declare your schema version, and override two methods:
onCreate(SQLiteDatabase db)— called once, the very first time the database file is created. Write yourCREATE TABLEstatements here.onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)— called whenever you bump the version number in the constructor. Migrate the schema here; never drop tables without offering data preservation.
super() constructor is persisted inside the database file. If you deploy a new APK with a higher version number, Android calls onUpgrade automatically on the user's device. If you change the schema without incrementing the version, existing devices never see the change.
Creating the Helper Class
Below is a complete helper for a simple task-management database. It has one table, tasks, with an auto-increment primary key, a title, a priority level, and a boolean completion flag.
TaskDbHelper.COL_TITLE throughout your code means a typo is a compile error, not a runtime crash. Never write column names as raw string literals in multiple places.
Inserting Data
To write a row you obtain a writable database handle, populate a ContentValues map, and call insert(). The return value is the new row's _id, or -1 on failure.
db.close() after every operation. SQLiteOpenHelper caches the database handle internally. Closing it after each call forces an expensive reopen on the next one. Instead, let the helper live as a long-lived singleton (Application scope or a dependency-injected repository) and only close it in Application.onTerminate() — which on real devices is almost never called. The OS reclaims the file handle when your process dies.
Querying Data
db.query() is a structured helper method that builds a SELECT statement for you. It returns a Cursor — a result-set pointer that you iterate row by row. Always close the cursor in a finally block or try-with-resources.
? placeholder and pass values in selectionArgs. Concatenating user input directly into a selection string opens your app to SQL injection — even in a local database, malformed input can corrupt or delete data.
Updating and Deleting Rows
Both operations follow the same pattern as insert: get a writable handle, describe what to change, and pass selection criteria via ? placeholders.
Running Raw SQL
For complex queries — multi-table joins, subqueries, aggregates — use db.rawQuery(). It still accepts ? placeholders for safety and returns a Cursor like query().
Schema Migrations in Practice
Suppose version 2 of your app adds a due_date column. You bump the version to 2 and handle both upgrade paths:
if (oldVersion < N) pattern, not switch. A user upgrading from version 1 to version 3 must pass through all intermediate migrations. Each if block is additive, so they all execute in sequence for that user.
Threading Considerations
getWritableDatabase() and getReadableDatabase() are thread-safe on their own, but the returned SQLiteDatabase object is not thread-safe if you share it across threads without synchronisation. The safest rule for raw SQLite: run all database operations off the main thread. Use AsyncTask (deprecated but still functional), an Executor, or — better — the LiveData / coroutine patterns you will meet when you learn Room. Blocking the main thread with a database call causes ANR (Application Not Responding) errors under load.
Summary
SQLite on Android gives you a full relational database in a single file. Subclass SQLiteOpenHelper, define your schema in onCreate, migrate it in onUpgrade, and perform CRUD through the ContentValues + Cursor API. Always use ? placeholders, always close cursors, and always move database work off the main thread. In the next lesson you will see how Room wraps this same engine with type safety, compile-time query verification, and LiveData integration.