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 asinsertId.- Use
db.executeBatch()for many writes, anddb.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
| Requirement | Detail |
|---|---|
| React Native app | A bare app, or an Expo app with a development build. The docs say “You cannot use this library on a expo-go app” |
| op-sqlite | 18.2.5 is the current npm version as I write this |
| iOS | CocoaPods. 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. UseopenAsync(), 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 useexecuteBatch. 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
- React Native SQLite
- Expo SQLite
- SQLite on iOS
- SQLite CREATE TABLE
- SQLite database is locked
- op-sqlite: Documentation, GitHub
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.
