Expo SQLite: How to Use expo-sqlite (openDatabaseAsync, Transactions, Migrations)

Older expo-sqlite tutorials use SQLite.openDatabase(), a function Expo removed in expo-sqlite 15, so code copied from them fails before it runs a query. The current API is openDatabaseAsync, and in this article I’ll walk you through it by building a small to-do list data layer.

TLDR

  • Install with npx expo install expo-sqlite. It works in Expo Go, and on web as an alpha.
  • Open a database with await SQLite.openDatabaseAsync('todos.db'). The file is created on first launch.
  • Use runAsync() for writes and the get…Async() methods for reads. Run schema changes with execAsync().
  • Wrap related writes in withTransactionAsync(), and version your schema with PRAGMA user_version.
  • openDatabase() and executeSql() belong to the old API, removed in expo-sqlite 15.0.0.

What is expo-sqlite and what will we build?

expo-sqlite is Expo’s library for using an SQLite database from a React Native app. Expo’s docs describe it as giving “your app access to a database that can be queried through a SQLite API”, where “the database is persisted across restarts of your app” (Expo SQLite docs). It runs on Android and iOS, with web support in alpha.

To show you how it works, I will build the data layer for a to-do list app. It covers the everyday reads and writes, plus a schema migration.

Prerequisites

RequirementDetail
An Expo projectAny current Expo SDK project; npx create-expo-app makes one
expo-sqliteVersion 57.0.4 was the current npm release when I checked (below)
A device, simulator or Expo GoTo run the app code
Web (optional)Alpha; needs Metro configured for wasm files and extra HTTP headers (Expo SQLite docs: Web setup)
$ npm view expo-sqlite version
57.0.4

Building a to-do list data layer with expo-sqlite

The app code below follows the API in Expo’s docs and is not executed here, because it needs a phone or simulator. I did check two things. The published package exports every function the code uses, and the old openDatabase() function is gone:

$ grep -ho 'openDatabaseAsync\|openDatabaseSync\|withTransactionAsync\|withExclusiveTransactionAsync\|getAllAsync\|getFirstAsync\|runAsync\|execAsync' package/build/*.d.ts package/build/**/*.d.ts 2>/dev/null | sort -u
execAsync
getAllAsync
getFirstAsync
openDatabaseAsync
openDatabaseSync
runAsync
withExclusiveTransactionAsync
withTransactionAsync
$ grep -rl 'export declare function openDatabase(' package/build | wc -l | tr -d ' '
0

Every SQL statement in the code also ran in the sqlite3 shell, so the results you see under each step are real.

Step 1: Install expo-sqlite

In your project folder (not executed here):

npx expo install expo-sqlite

Step 2: Write the data layer

Save this as db.ts. Each function does one job:

import * as SQLite from 'expo-sqlite';

export type Todo = { id: number; text: string; done: number };

// Opens todos.db, creating it on first launch, and migrates it to version 1.
export async function openTodoDb() {
  const db = await SQLite.openDatabaseAsync('todos.db');
  const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
  if ((row?.user_version ?? 0) < 1) {
    await db.execAsync(`
      PRAGMA journal_mode = WAL;
      CREATE TABLE IF NOT EXISTS todos (
        id INTEGER PRIMARY KEY NOT NULL,
        text TEXT NOT NULL,
        done INTEGER NOT NULL DEFAULT 0
      );
      PRAGMA user_version = 1;
    `);
  }
  return db;
}

export async function addTodo(db: SQLite.SQLiteDatabase, text: string) {
  const result = await db.runAsync('INSERT INTO todos (text) VALUES (?)', text);
  return result.lastInsertRowId;
}

export async function openTodos(db: SQLite.SQLiteDatabase) {
  return db.getAllAsync<Todo>('SELECT id, text, done FROM todos WHERE done = 0 ORDER BY id');
}

export async function completeTodo(db: SQLite.SQLiteDatabase, id: number) {
  await db.runAsync('UPDATE todos SET done = 1 WHERE id = ?', id);
}

export async function clearDone(db: SQLite.SQLiteDatabase) {
  await db.withTransactionAsync(async () => {
    await db.runAsync('DELETE FROM todos WHERE done = 1');
  });
}

The rest of this section runs that SQL, step by step.

Step 3: Open the database and migrate it

openTodoDb() reads PRAGMA user_version, which is 0 for a new database. It then creates the table in WAL mode and sets the version to 1. Expo’s docs recommend WAL mode “when you create a new database to improve performance in general”. On the next launch, the version is already 1, so it skips the setup:

sqlite> PRAGMA user_version;
user_version
------------
0
sqlite> PRAGMA journal_mode = WAL;
journal_mode
------------
wal
sqlite> CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY NOT NULL, text TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0); PRAGMA user_version = 1;
sqlite> PRAGMA user_version;
user_version
------------
1

Step 4: Add tasks

runAsync() returns lastInsertRowId and changes. For the first task, lastInsertRowId is 1:

sqlite> INSERT INTO todos (text) VALUES ('Buy groceries'); SELECT last_insert_rowid() AS lastInsertRowId;
lastInsertRowId
---------------
1
sqlite> INSERT INTO todos (text) VALUES ('Walk the dog'); INSERT INTO todos (text) VALUES ('Call the plumber');

Step 5: List open tasks

getAllAsync() returns every row as an array of objects:

sqlite> SELECT id, text, done FROM todos WHERE done = 0 ORDER BY id;
id  text              done
--  ----------------  ----
1   Buy groceries     0
2   Walk the dog      0
3   Call the plumber  0

Step 6: Mark a task done

sqlite> UPDATE todos SET done = 1 WHERE id = 1; SELECT changes() AS changes;
changes
-------
1
sqlite> SELECT id, text, done FROM todos WHERE done = 0 ORDER BY id;
id  text              done
--  ----------------  ----
2   Walk the dog      0
3   Call the plumber  0

Step 7: Clear finished tasks in a transaction

Expo’s docs say withTransactionAsync() commits when your function succeeds and rolls back when it throws. In SQL, that’s a BEGIN … COMMIT around the delete:

sqlite> BEGIN; DELETE FROM todos WHERE done = 1; COMMIT; SELECT id, text, done FROM todos ORDER BY id;
id  text              done
--  ----------------  ----
2   Walk the dog      0
3   Call the plumber  0

Expo’s docs add a warning: while that transaction is open, any other query your app runs also lands inside it, even from outside the function. Use withExclusiveTransactionAsync() when that matters. It isn’t supported on web.

Step 8: Show the tasks in a screen

To use the data layer in a screen, open the database once and call the functions from your components. Expo’s docs also offer SQLiteProvider, which opens the database for your whole app and runs a migration function through its onInit prop. Components then get the database with the useSQLiteContext() hook (Expo SQLite docs). A minimal screen (not executed here):

import { useEffect, useState } from 'react';
import { Text, View } from 'react-native';
import { openTodoDb, openTodos, type Todo } from './db';

export default function TodoScreen() {
  const [todos, setTodos] = useState<Todo[]>([]);
  useEffect(() => {
    openTodoDb().then(openTodos).then(setTodos);
  }, []);
  return (
    <View>
      {todos.map(t => <Text key={t.id}>{t.text}</Text>)}
    </View>
  );
}

Once the database holds open tasks, such as the two left after Step 6, the screen lists them.

Running it

Start the app with npx expo start and open it in Expo Go or on a device (not executed here).

Common problems

  • openDatabase or transaction is undefined: that’s the old API. The expo-sqlite changelog for 15.0.0 (22 October 2024) says “Removed deprecated legacy expo-sqlite” (expo-sqlite changelog). Rewrite tx.executeSql() calls as runAsync() or getAllAsync().
  • expo-sqlite/next or expo-sqlite/legacy can’t be found: the same release removed the next export. Import from expo-sqlite.
  • SQL injection through execAsync(): Expo’s docs note that execAsync() “does not escape parameters”. Pass user input to runAsync() or getAllAsync() with ? placeholders, as db.ts does.
  • It fails on web: web support is alpha and needs the Metro and header setup from the Prerequisites table.
  • You need to ship a ready-made database: pass assetSource to SQLiteProvider with your bundled .db file (Expo SQLite docs: Import an existing database).

Conclusion

Current expo-sqlite comes down to a few async methods, starting with openDatabaseAsync(). Version your schema with PRAGMA user_version and keep user input in placeholders. Replace any openDatabase() code from older tutorials with the new calls.

Further reading

Tested with SQLite 3.51.0 (the SQL) and the expo-sqlite 57.0.4 npm package (exports) on 7 October 2026.