OP-SQLite for React Native: Install, Queries and Transactions

op-sqlite gives a React Native app a real SQLite database through a native module, with SQL queries instead of filtering AsyncStorage keys in JavaScript. In this article I’ll walk you through installing it and the calls you’ll use every day, with the SQL behind each call tested in the sqlite3 shell.

TLDR

  • Install with npm i @op-engineering/op-sqlite, then rebuild the native app. It doesn’t run in Expo Go.
  • Open one connection for the whole app: const db = open({ name: 'analytics.sqlite' }).
  • await db.execute(sql, params) returns the result rows plus metadata such as insertId.
  • Use db.executeBatch() for many writes, and db.transaction() when a failure should undo everything.
  • Avoid executeSync() for anything slow, because it blocks the JavaScript thread.

What is op-sqlite and what will we build?

op-sqlite is a SQLite library for React Native, published on npm as @op-engineering/op-sqlite. Its README lists support for iOS and Android among other platforms, plus optional builds such as SQLCipher (op-sqlite on GitHub). Its docs say every execute call runs “on a (single) separate and dedicated thread, so the JS thread is not blocked” (op-sqlite API).

To show you the main calls, I will build a small analytics store that logs screen events and counts views per screen.

Prerequisites

RequirementDetail
React Native appA bare app, or an Expo app with a development build. The docs say “You cannot use this library on a expo-go app”
op-sqlite18.2.5 is the current npm version as I write this
iOSCocoaPods. Pod install runs automatically in recent React Native versions

I didn’t run the React Native code for this guide. The JavaScript below follows op-sqlite’s API documentation, and the SQL each call sends was run in the sqlite3 shell, so the outputs show what SQLite returns.

Building the analytics store

Each step adds one piece of the store.

Step 1: Install op-sqlite

npm i -s @op-engineering/op-sqlite
cd ios && pod install

For Expo, the docs use npx expo install @op-engineering/op-sqlite and then npx expo prebuild --clean (not executed here).

Step 2: Open the database once and create a table

The docs recommend opening one connection per app session and reusing it everywhere. This module does that, and adds the helpers the app uses:

import { open } from '@op-engineering/op-sqlite';

// Open one connection for the whole app and reuse it.
export const db = open({ name: 'analytics.sqlite' });

export async function migrate() {
  await db.execute(`CREATE TABLE IF NOT EXISTS events (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    screen TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  )`);
}

export async function logEvent(name, screen) {
  const { insertId } = await db.execute(
    'INSERT INTO events (name, screen) VALUES (?, ?)',
    [name, screen],
  );
  return insertId;
}

export async function countByScreen() {
  const { rows } = await db.execute(
    'SELECT screen, count(*) AS views FROM events GROUP BY screen ORDER BY views DESC',
  );
  return rows;
}

The table it creates:

sqlite> CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, name TEXT NOT NULL, screen TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);

Step 3: Insert a row and read its id

execute returns insertId for inserts. In SQLite terms, that’s the new row’s id:

sqlite> INSERT INTO events (name, screen) VALUES ('open', 'Home'); SELECT last_insert_rowid() AS insertId;
insertId
--------
1

Step 4: Write many rows with executeBatch

executeBatch “wraps all the commands in a transaction for you and has less overhead”, according to the docs. In SQL, the inserts run between BEGIN and COMMIT:

sqlite> BEGIN; INSERT INTO events (name, screen) VALUES ('open', 'Settings'); INSERT INTO events (name, screen) VALUES ('open', 'Home'); INSERT INTO events (name, screen) VALUES ('tap', 'Home'); SELECT total_changes() AS rows_changed_on_this_connection; COMMIT;
rows_changed_on_this_connection
-------------------------------
3

Step 5: Read rows

execute resolves to an object whose rows is an array of plain objects, one per row. For countByScreen(), SQLite returns:

sqlite> SELECT screen, count(*) AS views FROM events GROUP BY screen ORDER BY views DESC;
screen    views
--------  -----
Home      3
Settings  1

Step 6: Roll back a failed transaction

Inside db.transaction(), “any error thrown inside of the transaction body function will ROLLBACK the transaction”. Here the second insert has no screen, so it fails. The first insert is undone too. The same statements in the shell, which exits without committing:

sqlite> BEGIN; INSERT INTO events (name, screen) VALUES ('open', 'Profile'); INSERT INTO events (name) VALUES ('broken'); -- fails, so the app rolls back
Error: stepping, NOT NULL constraint failed: events.screen (19)
sqlite> SELECT count(*) AS profile_rows FROM events WHERE screen = 'Profile';
profile_rows
------------
0

The whole app flow, using the module from Step 2 (not executed here):

import { db, migrate, logEvent, countByScreen } from './db.js';

await migrate();

// Step 3: insert one row and get its id
console.log('insertId:', await logEvent('open', 'Home'));

// Step 4: many inserts in one call
const batch = await db.executeBatch([
  ['INSERT INTO events (name, screen) VALUES (?, ?)', ['open', 'Settings']],
  ['INSERT INTO events (name, screen) VALUES (?, ?)', ['open', 'Home']],
  ['INSERT INTO events (name, screen) VALUES (?, ?)', ['tap', 'Home']],
]);
console.log('batch rowsAffected:', batch.rowsAffected);

// Step 5: read rows
console.log(await countByScreen());

// Step 6: a transaction that fails is rolled back
try {
  await db.transaction(async (tx) => {
    await tx.execute('INSERT INTO events (name, screen) VALUES (?, ?)', ['open', 'Profile']);
    await tx.execute('INSERT INTO events (name) VALUES (?)', ['broken']);
  });
} catch (e) {
  console.log('transaction failed:', e.message);
}
const { rows } = await db.execute("SELECT count(*) AS n FROM events WHERE screen = 'Profile'");
console.log('Profile rows after rollback:', rows[0].n);

// Step 7: a synchronous query (blocks the JS thread, so keep it small)
console.log(db.executeSync('SELECT sqlite_version() AS version').rows[0]);
db.close();

Running it

Rebuild the app after installing (npx react-native run-ios or run-android), then call migrate() once at startup before any other query. To look at the data during development, copy the database file off the simulator or emulator and open it with sqlite3 analytics.sqlite.

Common problems

These come from op-sqlite’s documentation. None were reproduced here.

  • Errors about the native module in Expo Go: op-sqlite needs a development build. Run npx expo prebuild --clean.
  • open() throws on web: the web build is async-only. Use openAsync(), which also needs OPFS.
  • Only the first statement ran: on native, execute() “currently runs only the first prepared statement” in a multi-statement string. Send one statement per call, or use executeBatch.
  • database is locked: two connections are writing to the same file. Open one connection and reuse it. See database is locked.

Conclusion

op-sqlite is a direct way to put SQLite in a React Native app. Open one connection, and use async execute with parameters for queries. Wrap related writes in executeBatch or transaction. It’s plain SQLite underneath, so you can test your queries in the sqlite3 shell first.

Further reading

Tested with SQLite 3.51.0 (the SQL only) on 7 October 2026. The React Native code follows op-sqlite 18.2.5’s documentation and wasn’t executed.