Using SQLite with Expo: React Native Guide
SQLite is the default choice for local data storage in React Native apps. It's fast, requires no server, and persists data across app restarts. Expo's built-in expo-sqlite package makes it straightforward to set up.
This guide covers expo-sqlite from basic setup through Drizzle ORM integration. By the end, you'll have a working local database with type-safe queries.
Why SQLite for React Native
React Native apps often need local data storage for:
- Offline-first functionality. Users expect apps to work without an internet connection. SQLite stores data locally and syncs when connectivity returns.
- Fast reads. Querying a local SQLite database is orders of magnitude faster than making a network request.
- Structured data. AsyncStorage works for key-value pairs, but once you need relationships, filtering, or sorting, you need a real database.
- Data that stays on device. Health data, financial records, or notes that shouldn't leave the user's phone.
SQLite handles all of these without adding a server dependency.
Getting started with expo-sqlite
Installation
That's it. No native module linking required. expo-sqlite works in Expo Go and in custom dev builds.
Setting up the provider
Wrap your app with SQLiteProvider to make the database available throughout your component tree:
The onInit callback runs when the database is first opened. Use it to create tables and run migrations.
Accessing the database
Use the useSQLiteContext hook in any component:
CRUD operations
Insert
Always use parameterized queries (the ? placeholders). Never interpolate user input into SQL strings. This prevents SQL injection and handles escaping automatically.
Read
getAllAsync returns an array of objects. getFirstAsync returns a single object or null.
Update
Delete
Transactions
When you need to run multiple operations atomically, use transactions:
If any statement in the transaction fails, all changes are rolled back. This is critical for operations like moving data between tables or updating related records.
Migrations
As your app evolves, you'll need to change the database schema. A simple versioning approach works well:
Each migration block runs only once. New users get all migrations in sequence. Existing users skip the ones they've already applied.
Using Drizzle ORM with expo-sqlite
Writing raw SQL works, but as your app grows, you'll want type safety and a better developer experience. Drizzle ORM integrates with expo-sqlite and gives you type-safe queries with minimal overhead.
Installation
Define your schema
Create a schema file that defines your tables:
Set up Drizzle with expo-sqlite
Type-safe queries
Every query is fully typed. If you rename a column in your schema, TypeScript catches every place that references it.
Live queries
Drizzle's useLiveQuery hook re-runs queries when data changes:
When you insert, update, or delete a record, any useLiveQuery watching that table updates automatically. No manual refresh needed.
For this to work, enable change listeners in your SQLite provider:
Offline-first patterns
Sync queue
For apps that need to sync with a server, a common pattern is to queue changes locally and sync when online:
Checking connectivity
Performance tips
Index your queries
If you're searching or filtering on a column, add an index:
Without indexes, SQLite scans every row. With indexes on a table of 10,000 contacts, lookups go from milliseconds to microseconds.
Batch inserts
Inserting rows one at a time is slow. Wrap bulk inserts in a transaction:
A transaction turns 1,000 individual disk writes into a single write. The difference is dramatic: 1,000 individual inserts might take 5 seconds. The same 1,000 inserts in a transaction take under 100 milliseconds.
Keep the database small
SQLite on mobile devices should stay reasonable in size. A few hundred megabytes is fine. Gigabytes will cause performance issues and storage warnings.
For large datasets, consider storing only what the user needs locally and fetching the rest from your API.
Use WAL mode
WAL (Write-Ahead Logging) mode improves concurrent read/write performance:
WAL mode lets reads and writes happen simultaneously, which matters in React Native where background operations might write data while the UI is reading it.
Common mistakes
Forgetting to close the database. If your app opens multiple database connections without closing them, you'll hit file locking issues. The SQLiteProvider pattern handles this for you.
Storing images in SQLite. Store file paths in SQLite and keep actual images in the file system. BLOBs in SQLite bloat the database and slow down queries that don't even need the image data.
Not handling schema changes. If you ship a new version with a changed schema and don't have migrations, existing users will crash. Always use the migration pattern described above.
Running heavy queries on the UI thread. Large queries should be async. The expo-sqlite async methods (getAllAsync, runAsync) handle this, but be aware of it if you're doing complex processing after the query returns.
Debugging your SQLite database
During development, you can inspect your SQLite database in several ways:
- Drizzle Studio provides a visual interface for browsing tables and running queries. Import
useDrizzleStudiofromexpo-drizzle-studio-pluginand pass your database instance. - Export the database file from your simulator/emulator and open it in a desktop tool like DB Pro for more advanced querying and schema inspection.
- Use
console.logwith raw SQL queries during development to verify data.
When to use something else
SQLite isn't the right choice for every scenario:
- Server-side data only. If your app is purely online with no offline needs, fetching from an API is simpler than maintaining a local database.
- Real-time collaboration. Multiple users editing the same data simultaneously needs a server-side database. SQLite is single-user.
- Sensitive data with compliance requirements. SQLite databases on device can be extracted. For highly sensitive data, consider SQLCipher (encrypted SQLite) or server-side storage.
Bottom line
expo-sqlite gives React Native apps a local database with minimal setup. Start with raw SQL queries using useSQLiteContext, and add Drizzle ORM when you want type safety and a better developer experience.
For offline-first apps, the sync queue pattern keeps data flowing between the local database and your server. For simpler apps, SQLite as a local cache for API data improves perceived performance and lets the app work without connectivity.
The combination of Expo, SQLite, and Drizzle covers most local data needs in React Native. Start simple, add complexity as your app requires it.
Keep Reading
SQLite Alternatives: When to Choose Something Else
SQLite is great, but it's not for everything. Here's when to consider alternatives and what to choose.
Using SQLite with Bun: A Complete Guide
Bun's built-in SQLite driver is fast and simple. Here's how to use it effectively.
SQLite JSON Superpower: Virtual Columns + Indexing
One of the coolest patterns in SQLite—store raw JSON documents, create virtual generated columns using json_extract, add indexes, and query JSON at full B-tree index speed.