SQLite in React Native: Local Data Storage in Practice
AI generated
RN
native
React Native · SQLite · Local Data Storage · Mobile Apps
SQLite: Local Data Storage in Practice
from expo-sqlite to a tested migration

Any React Native app that needs to make larger amounts of data available offline eventually needs a real relational database. SQLite, through expo-sqlite, delivers fast, native local data storage without ORM overhead: hand written SQL, versioned migrations and parameterized queries that give full control over schema and performance.

17 min read expo-sqlite · PRAGMA user_version · withTransactionAsync React Native · SQLite 3 · Expo SDK 51+

1. Why SQLite in React Native apps and where AsyncStorage hits its limits

AsyncStorage only stores key value pairs as strings. That is fine for a handful of settings or an auth token, but as soon as an app needs to keep hundreds or thousands of related records available offline, such as orders, product catalogs or chat histories, every single query becomes a problem. There is no filtering, no sorting and no joins at the database level: every read means loading the entire JSON string, parsing it, and then filtering it in JavaScript. As the data volume grows, deserialization time grows linearly with it, while the main thread stays blocked.

SQLite solves this by providing a full relational database directly on the device. Through expo-sqlite or react-native-sqlite-storage, the app talks to a native SQLite engine connected via JSI, so without the classic asynchronous bridge serialization. For local data storage with real queries, indexes and transaction guarantees, that is the only practical path: SQL queries filter and sort directly inside the database engine, not on the JavaScript thread.

This article deliberately covers the direct SQL approach without an ORM abstraction layer. Unlike WatermelonDB, which maps reactivity and a built in sync engine through its own model system, this piece uses hand written SQL with parameterized queries and a custom migration system. That means more control over every single query, but also more responsibility for schema changes and query optimization.

2. Setup with expo-sqlite

Installing expo-sqlite is done through the Expo CLI, which automatically picks the matching native version for the current Expo SDK. In a bare React Native project without the Expo managed workflow, react-native-sqlite-storage is used instead, which requires manual native linking through CocoaPods and Gradle. Since Expo SDK 51, expo-sqlite offers a fully promise based API with openDatabaseAsync, execAsync, runAsync, getAllAsync and getFirstAsync, which fully replaces the older callback based approach.

The actual SQLite database file lives in the app's sandboxed documents directory and is isolated from other apps, an important aspect for local data storage without any cloud dependency. On the first call to openDatabaseAsync("app.db"), the file is created automatically if it does not exist yet. In app.json, the Expo config plugin can set additional native options, for example whether SQLCipher should be enabled for encrypted local data storage.


# Install expo-sqlite for local SQLite-based data storage
npx expo install expo-sqlite

# For bare React Native projects (no Expo managed workflow)
npm install react-native-sqlite-storage
cd ios && pod install

{
  "expo": {
    "name": "InventoryApp",
    "plugins": [
      [
        "expo-sqlite",
        {
          "enableFTS": true,
          "useSQLCipher": true,
          "android": {
            "enableFTS": true,
            "useSQLCipher": true
          }
        }
      ]
    ]
  }
}

3. Schema design and migrations

A relational schema for local data storage starts with clear tables, primary keys and foreign key relationships, just like on a server database. The difference lies in versioning: SQLite provides PRAGMA user_version, a built in integer counter stored directly inside the database file, which is a great fit as a schema version. On app start, this value is read, compared against the target version in code, and every missing migration is run one after another.

The migration runner is therefore not an external framework, but a simple loop over numbered SQL scripts. Each migration gets its own function that maps exactly one version step, such as adding a column or creating a new index. After a migration runs successfully, PRAGMA user_version is set to the new number. This guarantees that a user updating directly from version 3 to version 7 goes through all four intermediate steps in the correct order, without the app ever having to check that explicitly.

What matters for stable local data storage is that migrations never start out destructive. ALTER TABLE ... ADD COLUMN is straightforward in SQLite, but removing a column has, until recently, required the detour of creating a new table, copying the data, and renaming it. Wrapping this flow in a transaction prevents an aborted migration from leaving the database in an inconsistent intermediate state.


// db.js: database initialization and versioned migrations
import { openDatabaseAsync } from "expo-sqlite";

const TARGET_VERSION = 3;

// Each migration maps exactly one version step
const migrations = {
  1: async (db) => {
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS notes (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        body TEXT NOT NULL DEFAULT '',
        created_at INTEGER NOT NULL
      );
    `);
  },
  2: async (db) => {
    await db.execAsync(`ALTER TABLE notes ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0;`);
  },
  3: async (db) => {
    await db.execAsync(`CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);`);
  },
};

export async function openDatabase() {
  const db = await openDatabaseAsync("app.db");
  const result = await db.getFirstAsync("PRAGMA user_version;");
  let currentVersion = result.user_version;

  while (currentVersion < TARGET_VERSION) {
    currentVersion += 1;
    await migrations[currentVersion](db);
    await db.execAsync(`PRAGMA user_version = ${currentVersion};`);
  }

  return db;
}

4. CRUD operations in practice

CRUD on a SQLite based local data storage layer follows the same basic rules as on any other relational database: values never belong inside a query through string concatenation, they always go in as bound parameters. runAsync handles INSERT, UPDATE and DELETE and returns, among other things, lastInsertRowId and changes, so the caller knows how many rows were affected without an extra query. For SELECT, getAllAsync is available for lists and getFirstAsync for a single record or null.

Parameterized queries with placeholders like ? not only protect against SQL injection, they also let SQLite reuse the same prepared statement, which is noticeably faster on repeated queries with different values than parsing a fresh query on every call. In a React Native component, this access is typically wrapped in a dedicated hook that pulls the database handle from a context and manages loading and error states.

For local data storage with frequent changes coming from multiple screens, a simple event system or a global store that reloads the affected lists after every write is also worth adding. Unlike WatermelonDB, there are no built in observables that propagate changes automatically here, that has to be wired up deliberately with direct SQLite.


// useNotes.js: custom hook for CRUD access to local SQLite data
import { useCallback, useEffect, useState } from "react";
import { useDatabase } from "./DatabaseProvider";

export function useNotes() {
  const db = useDatabase();
  const [notes, setNotes] = useState([]);
  const [loading, setLoading] = useState(true);

  const loadNotes = useCallback(async () => {
    setLoading(true);
    const rows = await db.getAllAsync(
      "SELECT * FROM notes WHERE is_archived = ? ORDER BY created_at DESC;",
      [0]
    );
    setNotes(rows);
    setLoading(false);
  }, [db]);

  const createNote = useCallback(
    async (title, body) => {
      const result = await db.runAsync(
        "INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?);",
        [title, body, Date.now()]
      );
      await loadNotes();
      return result.lastInsertRowId;
    },
    [db, loadNotes]
  );

  const archiveNote = useCallback(
    async (id) => {
      await db.runAsync("UPDATE notes SET is_archived = 1 WHERE id = ?;", [id]);
      await loadNotes();
    },
    [db, loadNotes]
  );

  const deleteNote = useCallback(
    async (id) => {
      await db.runAsync("DELETE FROM notes WHERE id = ?;", [id]);
      await loadNotes();
    },
    [db, loadNotes]
  );

  useEffect(() => {
    loadNotes();
  }, [loadNotes]);

  return { notes, loading, createNote, archiveNote, deleteNote, reload: loadNotes };
}

5. Transactions and batch operations

When several related writes must either all succeed or none at all, an explicit transaction is mandatory. expo-sqlite provides withTransactionAsync for this, which takes a callback and automatically issues a COMMIT once the callback completes successfully, or a ROLLBACK as soon as an exception is thrown. A typical example for local data storage is creating an order together with its line items: either all rows land in the database, or none of them do.

For bulk operations like importing an entire product catalog, a transaction matters not only for consistency but also for performance. Every single INSERT outside a transaction forces its own disk sync by default, which causes noticeable delays with thousands of rows. Wrapping the same import into a single transaction reduces the number of fsync calls to just one at the end, which often speeds up the import by an order of magnitude.

SQLite supports nested transactions through savepoints, which is rarely needed in practice but useful when one sub-step inside a larger transaction needs to be rolled back in isolation without aborting the outer transaction. For most use cases in a React Native app, however, a single flat transaction per logical operation is entirely sufficient.

6. Indexes and query performance

Without a matching index, SQLite scans the entire table on every filtered or sorted query, a so called full table scan. On small tables with a few hundred rows this hardly shows, but on tables with tens of thousands of entries, such as a local cache of product data, every query gets noticeably slower the more rows the app keeps offline. CREATE INDEX idx_name ON table(column) builds a B-tree index on exactly the columns that are frequently filtered or sorted by.

To understand whether an index is actually being used, EXPLAIN QUERY PLAN run before the real query is the tool of choice. Its output shows whether SQLite performs an index scan or a full table scan, and for joins, in what order the tables get combined. A common mistake in local data storage is placing an index on a column that gets transformed by a function in the WHERE clause, for example WHERE lower(name) = ?, since a regular index does not help here. An explicit expression index on exactly that expression fixes it.

Composite indexes across multiple columns pay off when a query regularly filters or sorts by the same two or three columns together, for example CREATE INDEX idx_notes_status_date ON notes(is_archived, created_at). Too many indexes, on the other hand, slow down every write, because each index has to be maintained on every INSERT or UPDATE. For local data storage with frequent writes, a deliberate trade off between read and write performance is needed.

7. Encryption and security of local data

An unencrypted SQLite file sits as a regular file in the device's file system. On a rooted Android device, or with physical access to an iPhone backup, this file can be read directly with common tools. For sensitive local data storage, such as health data, payment information or private messages, the operating system's sandbox isolation alone is not enough.

SQLCipher transparently encrypts the entire database file with AES-256 and can be enabled through the useSQLCipher option of the Expo config plugin. When opening the database, a passphrase parameter is passed in as well, which ideally should not be hard coded but instead come from the device's secure key store, the Keychain on iOS, the Android Keystore on Android, both reachable through expo-secure-store.

One important point for encrypted local data storage: the encryption overhead applies to every single page that SQLite reads or writes, not just to the initial connection. On very large databases with heavy write loads, benchmarking before and after enabling SQLCipher is worth doing, to verify that performance stays acceptable across the target range of supported devices.

8. Testing SQLite code

Code that writes directly against SQLite is most reliably tested with a real, but temporary, database instead of fully mocking the database layer. expo-sqlite supports the special database name :memory:, which creates a complete SQLite instance purely in memory. Every test opens a fresh in memory database, runs the same migrations as the production app, and then verifies the actual behavior of queries, constraints and transactions.

This approach catches bugs that a plain mock would never find, such as a forgotten NOT NULL constraint, a broken foreign key, or a migration that fails at a particular intermediate state. For Jest tests, that means: in a beforeEach block, the in memory database is reopened and migrated, in afterEach it gets closed, so tests run fully isolated from one another and do not influence each other.

For components that get the database handle through context, tests can substitute the same provider with an in memory database instead of the real file. That allows testing the full chain from UI interaction down to the actual SQL query with React Testing Library, without mocking a single line of production logic.


// notes.test.js: testing SQLite-backed logic with an in-memory database
import { openDatabaseAsync } from "expo-sqlite";
import { runMigrations } from "../db/migrations";
import { createNote, archiveNote } from "../db/notesRepository";

describe("notesRepository", () => {
  let db;

  beforeEach(async () => {
    // A fresh in-memory database for every test, fully isolated
    db = await openDatabaseAsync(":memory:");
    await runMigrations(db);
  });

  afterEach(async () => {
    await db.closeAsync();
  });

  it("creates a note and returns its generated id", async () => {
    const id = await createNote(db, "Shopping list", "Milk, eggs, bread");
    const row = await db.getFirstAsync("SELECT * FROM notes WHERE id = ?;", [id]);

    expect(row.title).toBe("Shopping list");
    expect(row.is_archived).toBe(0);
  });

  it("archives a note without deleting it", async () => {
    const id = await createNote(db, "Old note", "");
    await archiveNote(db, id);
    const row = await db.getFirstAsync("SELECT * FROM notes WHERE id = ?;", [id]);

    expect(row.is_archived).toBe(1);
  });
});

9. SQLite compared to WatermelonDB and MMKV for different use cases

Direct SQLite, WatermelonDB and MMKV all solve the problem of local data storage, but with very different trade offs between control, reactivity and setup effort. The right choice depends less on personal preference and more on the actual data model and the reactivity the app requires.

Dimension SQLite (direct) WatermelonDB MMKV
Query capability Full SQL feature set, joins, aggregations Limited query builder API on top of a SQLite backend Key value access only, no queries
Reactivity Manual, needs its own event system Built in observables per record Manual, no built in reactivity
Sync support Self implemented Built in sync engine (synchronize()) None, pure local storage
Best use case Relational data with complex queries, custom migration scheme Offline-first apps with server sync and many UI bindings Settings, flags, small caches
Setup complexity Medium, needs its own schema and migrations High, define models, schema and sync protocol Very low, ready to use immediately

For an app that primarily needs to keep structured, relational data available offline and does not need automatic server synchronization, direct SQLite is usually the most pragmatic choice: fewer abstraction layers, full control over schema and query plan. Once reactivity across many screens and a built in sync engine are required, WatermelonDB offsets that extra effort with less boilerplate code. MMKV remains the right choice for simple key value data with no relational requirements at all.

Mironsoft

React Native development and local data storage for mobile apps

Need SQLite based local data storage for your app?

We design the schema, migrations and access layer for your React Native app: from the first expo-sqlite integration through transactions and indexes to encrypted and fully tested local data storage.

Schema & migrations

Versioned SQLite schema with PRAGMA user_version and safe migration paths

Performance tuning

Indexes, query analysis with EXPLAIN QUERY PLAN and transaction batching

Security & tests

SQLCipher encryption and in memory test suites for reliable code

10. Summary

SQLite remains the most robust choice for local data storage in React Native apps once AsyncStorage hits its limits: real SQL queries instead of string parsing, ACID transactions instead of ad hoc consistency, and a built in version counter via PRAGMA user_version for clean migrations. expo-sqlite provides a modern, promise based API for this that directly supports parameterized queries, batch transactions and in memory databases for testing, with no additional ORM abstraction at all.

Choosing the direct SQL route means taking on responsibility for migrations, indexing and reactivity yourself, instead of delegating them to a framework like WatermelonDB. For relational data with complex queries and a genuine need for control over schema and query plan, that is the right trade off. For simple key value data, MMKV remains the lighter weight alternative, and for apps with heavy server sync needs, WatermelonDB is the more reactive option.

SQLite: Local Data Storage in Practice, the key points at a glance

Setup

expo-sqlite with openDatabaseAsync, promise based API since Expo SDK 51, native JSI bindings with no bridge serialization.

Migrations

PRAGMA user_version as a built in schema counter, numbered migration functions for every version step.

Transactions & performance

withTransactionAsync for atomic batch operations, indexes and EXPLAIN QUERY PLAN against full table scans.

Security & testing

SQLCipher for encrypted local data storage, :memory: databases for isolated, realistic tests.

11. FAQ: SQLite and local data storage in React Native

1What is SQLite and why does it fit local data storage?
A full relational database as a single file on the device, with real SQL queries, transactions and indexes instead of plain key value storage.
2When to use expo-sqlite instead of AsyncStorage?
As soon as hundreds or thousands of records need to be filtered, sorted or joined. SQLite filters directly inside the database engine, not on the JavaScript thread.
3How do migrations work with PRAGMA user_version?
An integer counter stored in the database file. On startup, every missing migration function runs in order up to the target version and the counter is increased.
4What are parameterized queries?
Values are bound through placeholders like ? instead of being inserted into the SQL string. Protects against SQL injection and allows query plan reuse.
5How do transactions work with withTransactionAsync?
A callback runs inside a transaction, commits automatically on success and rolls back completely if an exception is thrown.
6How do you improve query performance with indexes?
Create an index on frequently filtered columns and check with EXPLAIN QUERY PLAN whether it is actually used instead of a full table scan.
7How do you encrypt local SQLite data?
SQLCipher transparently encrypts the database file with AES-256. Enabled via useSQLCipher, with the passphrase ideally sourced from Keychain or Android Keystore.
8How do you test code that uses SQLite?
With a real in memory database via the name :memory:. Every test gets a fresh, migrated instance and tests real behavior instead of mocks.
9When WatermelonDB instead of direct SQLite?
When built in reactivity across many screens and a ready made sync engine are needed. Direct SQLite gives full control over schema and queries instead.
10When MMKV instead of SQLite?
For simple key value data like settings or feature flags with no relational structure. MMKV is lighter, but offers no queries or transactions.