Introduction
In Expo SQLite, executeSql runs inside a transaction callback and is inherently asynchronous, but it uses callbacks rather than Promises. To await SQL execution, wrap it in a Promise. In the newer expo-sqlite API (SDK 50+), the execAsync and runAsync methods are natively async and return Promises directly. For older versions, the pattern is new Promise((resolve, reject) => db.transaction(tx => tx.executeSql(sql, args, (_, result) => resolve(result), (_, error) => reject(error)))).
The Problem: Callback-Based API
1import * as SQLite from 'expo-sqlite';
2
3const db = SQLite.openDatabase('mydb.db');
4
5// executeSql uses callbacks — you can't directly await it
6db.transaction(tx => {
7 tx.executeSql(
8 'SELECT * FROM users',
9 [],
10 (_, result) => {
11 console.log(result.rows._array); // Success callback
12 },
13 (_, error) => {
14 console.log(error); // Error callback
15 return false;
16 }
17 );
18});
19
20// Code here runs BEFORE the query completes
21console.log("This runs before the SQL result!");
The transaction and executeSql methods use callbacks, not Promises. Code after the transaction block executes immediately without waiting for the SQL operation.
Fix 1: Wrap in a Promise
1import * as SQLite from 'expo-sqlite';
2
3const db = SQLite.openDatabase('mydb.db');
4
5function executeSqlAsync(sql, params = []) {
6 return new Promise((resolve, reject) => {
7 db.transaction(tx => {
8 tx.executeSql(
9 sql,
10 params,
11 (_, result) => resolve(result),
12 (_, error) => {
13 reject(error);
14 return false;
15 }
16 );
17 });
18 });
19}
20
21// Now you can await it
22async function getUsers() {
23 const result = await executeSqlAsync('SELECT * FROM users');
24 console.log(result.rows._array);
25 return result.rows._array;
26}
Wrapping executeSql in a Promise converts the callback-based API into an async/await compatible function.
Fix 2: Promise-Based Transaction Wrapper
1import * as SQLite from 'expo-sqlite';
2
3const db = SQLite.openDatabase('mydb.db');
4
5function transactionAsync(callback) {
6 return new Promise((resolve, reject) => {
7 db.transaction(
8 callback,
9 (error) => reject(error), // Transaction error
10 () => resolve() // Transaction success
11 );
12 });
13}
14
15// Execute multiple statements in one transaction
16async function setupDatabase() {
17 await transactionAsync(tx => {
18 tx.executeSql(
19 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'
20 );
21 tx.executeSql(
22 'CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT)'
23 );
24 });
25 console.log("Tables created");
26}
This wrapper resolves when the entire transaction succeeds or rejects if any statement fails. Use it for DDL statements and batch inserts where you need to know when all statements complete.
Fix 3: Full Async Helper Class
1import * as SQLite from 'expo-sqlite';
2
3class Database {
4 constructor(name) {
5 this.db = SQLite.openDatabase(name);
6 }
7
8 async executeSql(sql, params = []) {
9 return new Promise((resolve, reject) => {
10 this.db.transaction(tx => {
11 tx.executeSql(
12 sql,
13 params,
14 (_, result) => resolve(result),
15 (_, error) => {
16 reject(error);
17 return false;
18 }
19 );
20 });
21 });
22 }
23
24 async getAll(sql, params = []) {
25 const result = await this.executeSql(sql, params);
26 return result.rows._array;
27 }
28
29 async getFirst(sql, params = []) {
30 const result = await this.executeSql(sql, params);
31 return result.rows._array[0] || null;
32 }
33
34 async run(sql, params = []) {
35 const result = await this.executeSql(sql, params);
36 return { insertId: result.insertId, rowsAffected: result.rowsAffected };
37 }
38}
39
40// Usage
41const db = new Database('myapp.db');
42
43async function example() {
44 await db.run(
45 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'
46 );
47 await db.run('INSERT INTO users (name) VALUES (?)', ['Alice']);
48 await db.run('INSERT INTO users (name) VALUES (?)', ['Bob']);
49
50 const users = await db.getAll('SELECT * FROM users');
51 console.log(users); // [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]
52
53 const alice = await db.getFirst('SELECT * FROM users WHERE name = ?', ['Alice']);
54 console.log(alice); // {id: 1, name: 'Alice'}
55}
Expo SQLite SDK 50+ (Modern API)
1import * as SQLite from 'expo-sqlite';
2
3// SDK 50+ provides native async methods
4const db = await SQLite.openDatabaseAsync('mydb.db');
5
6// execAsync — run SQL that doesn't return rows
7await db.execAsync(`
8 CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);
9`);
10
11// runAsync — run SQL with parameters, get metadata
12const result = await db.runAsync(
13 'INSERT INTO users (name) VALUES (?)',
14 ['Alice']
15);
16console.log(result.lastInsertRowId); // 1
17console.log(result.changes); // 1
18
19// getFirstAsync — get one row
20const user = await db.getFirstAsync(
21 'SELECT * FROM users WHERE id = ?',
22 [1]
23);
24console.log(user); // { id: 1, name: 'Alice' }
25
26// getAllAsync — get all rows
27const users = await db.getAllAsync('SELECT * FROM users');
28console.log(users); // [{ id: 1, name: 'Alice' }]
The modern Expo SQLite API (SDK 50+) is fully Promise-based. No wrapping needed — runAsync, getFirstAsync, and getAllAsync return Promises natively.
Using with React Hooks
1import { useState, useEffect } from 'react';
2import * as SQLite from 'expo-sqlite';
3
4const db = SQLite.openDatabase('mydb.db');
5
6function useDatabase(sql, params = []) {
7 const [data, setData] = useState([]);
8 const [loading, setLoading] = useState(true);
9 const [error, setError] = useState(null);
10
11 useEffect(() => {
12 db.transaction(tx => {
13 tx.executeSql(
14 sql,
15 params,
16 (_, result) => {
17 setData(result.rows._array);
18 setLoading(false);
19 },
20 (_, err) => {
21 setError(err);
22 setLoading(false);
23 return false;
24 }
25 );
26 });
27 }, [sql, JSON.stringify(params)]);
28
29 return { data, loading, error };
30}
31
32// Usage in component
33function UserList() {
34 const { data: users, loading } = useDatabase('SELECT * FROM users');
35
36 if (loading) return <Text>Loading...</Text>;
37 return users.map(u => <Text key={u.id}>{u.name}</Text>);
38}
Common Pitfalls
Not returning false from the error callback: In the legacy API, the executeSql error callback must return false to signal that the error was not handled, which rolls back the transaction. Returning true or nothing silently swallows the error, leaving the database in an inconsistent state.
Accessing rows._array on an empty result: result.rows._array is always an array, but accessing _array[0].name on an empty result throws TypeError: Cannot read property 'name' of undefined. Always check length first or use optional chaining.
Running async operations inside transaction callback: The transaction callback is synchronous — you cannot await inside it. All executeSql calls must be made synchronously within the callback. For sequential async operations, use the Promise wrapper pattern and chain await calls outside the transaction.
Not using parameterized queries: tx.executeSql('SELECT * FROM users WHERE name = "' + name + '"') is vulnerable to SQL injection. Always use parameterized queries: tx.executeSql('SELECT * FROM users WHERE name = ?', [name]).
Mixing legacy and modern APIs: Projects that upgrade Expo SDK may have both openDatabase (legacy callback API) and openDatabaseAsync (modern Promise API) in the same codebase. Pick one pattern and use it consistently to avoid confusion and subtle bugs.
Summary
Legacy executeSql uses callbacks — wrap in new Promise() to enable await
Create a reusable Database helper class with getAll, getFirst, and run async methods
Expo SQLite SDK 50+ provides native async methods (runAsync, getAllAsync, getFirstAsync)
Always use parameterized queries (? placeholders) to prevent SQL injection
Return false from error callbacks in the legacy API to properly roll back failed transactions
For React components, wrap database queries in custom hooks with loading and error states