skip to content

Expo SQLite

Expo SQLite gives an app a real on-device relational database with async queries, transactions and React hooks. Interviewers raise it when data needs joins and migrations, not a few keys.

on this pageshow

explore

questions

17

In expo-sqlite, how do you open a database and choose between execAsync, runAsync, getFirstAsync and getAllAsync for a given query?

level: juniorimportance: must knowfreq 60%

answer

  1. openDatabaseAsync returns SQLiteDatabase
  2. exec: many statements, no params, no rows
  3. run: one write, lastInsertRowId and changes
  4. getFirst: one row or null
  5. getAll: array of row objects

basics

~20 s

openDatabaseAsync('ledger.db') returns a SQLiteDatabase. Use execAsync for unparameterised multi-statement scripts such as schema setup, runAsync for writes with bound values, getFirstAsync for one row (or null), and getAllAsync for every matching row as an array of objects.

solid answer

~40 s

`const db = await SQLite.openDatabaseAsync('ledger.db')` opens or creates the file and gives a `SQLiteDatabase`. Then I pick the method by what I need back. `execAsync(sql)` runs one or many statements with **no parameters and no results**, so it is for fixed scripts: `CREATE TABLE`, `PRAGMA`. `runAsync(sql, ...params)` runs one statement with bound values and returns `{ lastInsertRowId, changes }`, which is what I want for `INSERT`, `UPDATE` and `DELETE`. `getFirstAsync<T>(sql, ...params)` returns the first row as an object keyed by column name, or `null` if nothing matched. `getAllAsync<T>(...)` returns every row as an array. For large result sets there is `getEachAsync`, an async iterator that fetches rows one at a time.

code

typescript · 21 lines
typescript
import * as SQLite from "expo-sqlite";

type Expense = { id: number; category: string; amount_cents: number; spent_on: string };

export async function monthExpenses(db: SQLite.SQLiteDatabase, month: string): Promise<Expense[]> {
  // month is "YYYY-MM"; spent_on is stored as "YYYY-MM-DD"
  return db.getAllAsync<Expense>(
    "SELECT id, category, amount_cents, spent_on FROM expenses WHERE substr(spent_on, 1, 7) = ? ORDER BY spent_on",
    month
  );
}

export async function addExpense(db: SQLite.SQLiteDatabase, amountCents: number, category: string, spentOn: string): Promise<number> {
  const result = await db.runAsync(
    "INSERT INTO expenses (amount_cents, category, spent_on) VALUES (?, ?, ?)",
    amountCents,
    category,
    spentOn
  );
  return result.lastInsertRowId;
}

go deeper

for a junior

Recall openDatabaseAsync and the four query methods, and which one returns rows, which returns change metadata and which returns nothing.

for a middle

Explain why execAsync is only for fixed scripts, what lastInsertRowId and changes mean, and when getEachAsync beats getAllAsync.

for a senior

Spot unbounded getAllAsync calls and unvalidated generic row types in review, and explain the prepare-execute-finalize cycle each helper performs.

for a principal

Set conventions for a data-access layer over expo-sqlite: which methods are allowed where, how rows are validated, and how large reads are bounded.

## Opening a database **expo-sqlite** is Expo's SQLite module; it works in Expo Go and in development builds. The entry point is `openDatabaseAsync(databaseName, options?, directory?)`: ```typescript import * as SQLite from "expo-sqlite"; const db = await SQLite.openDatabaseAsync("ledger.db"); ``` - The file is created if it does not exist, in `defaultDatabaseDirectory` unless you pass a directory. - Opening the same name again reuses a **cached connection**; `useNewConnection: true` in the options forces a separate one. - The result is a `SQLiteDatabase`, the object every query method below lives on. The old callback API (`openDatabase`, `transaction`, `executeSql`) was removed in expo-sqlite 15; everything current is promise-based, with optional `*Sync` twins. ## The four everyday methods | Method | Statements | Parameters | Returns | Typical use | |---|---|---|---|---| | `execAsync(sql)` | one or many | none, not escaped | `void` | schema, `PRAGMA` | | `runAsync(sql, ...params)` | one | bound | `{ lastInsertRowId, changes }` | `INSERT`, `UPDATE`, `DELETE` | | `getFirstAsync<T>(sql, ...params)` | one | bound | `T \| null` | one record, an aggregate | | `getAllAsync<T>(sql, ...params)` | one | bound | `T[]` | a bounded list | A fifth, `getEachAsync<T>(sql, ...params)`, returns an **async iterator** instead of a Promise, stepping through the rows one at a time. The API cheatsheet recommends it for large result sets because it avoids building the whole array at once. ## Applying it to an expense ledger ```typescript await db.execAsync(` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS expenses ( id INTEGER PRIMARY KEY NOT NULL, amount_cents INTEGER NOT NULL, category TEXT NOT NULL, spent_on TEXT NOT NULL ); `); const { lastInsertRowId } = await db.runAsync( "INSERT INTO expenses (amount_cents, category, spent_on) VALUES (?, ?, ?)", 1250, "groceries", "2026-09-14" ); const total = await db.getFirstAsync<{ total: number | null }>( "SELECT SUM(amount_cents) AS total FROM expenses WHERE spent_on >= ? AND spent_on < ?", "2026-09-01", "2026-10-01" ); const rows = await db.getAllAsync<{ id: number; category: string; amount_cents: number }>( "SELECT id, category, amount_cents FROM expenses WHERE spent_on >= ? AND spent_on < ? ORDER BY spent_on", "2026-09-01", "2026-10-01" ); ``` Notes on the shapes: 1. **Rows are plain objects keyed by column name**, so aliases (`AS total`) decide the property names. 2. **The generic `T` is a promise, not a check.** TypeScript trusts you; nothing validates the row at runtime. 3. **`getFirstAsync` returns `null`** when no row matches, so handle it before reading a property. 4. **`lastInsertRowId` and `changes`** come from SQLite itself: the rowid of the last insert and the number of rows the statement changed. ## What comes back from SQLite Row values arrive as ordinary JavaScript values, converted from SQLite's storage classes: | SQLite value | JavaScript value | |---|---| | `INTEGER`, `REAL` | `number` | | `TEXT` | `string` | | `NULL` | `null` | | `BLOB` | `Uint8Array` | Two consequences for the ledger. A boolean you bound (for example `is_refund = true`) is stored as `1` and comes back as the number `1`, not `true`, so convert it when reading. And money is best kept as integer cents, as in the example, so that sums stay exact and the value is a plain `number` in JavaScript rather than a floating-point approximation of a decimal amount. ## Common mistakes - **Using `execAsync` for a query with user input.** It takes no parameters and the documentation warns that it does not escape anything, so interpolating values is an injection risk. - **Using `runAsync` for a `SELECT`.** It only returns change metadata, never the rows. - **Using `getAllAsync` on an unbounded table.** Every row crosses into JavaScript at once; add `LIMIT`, paginate, or iterate with `getEachAsync`. - **Expecting several statements to run through `runAsync`.** It prepares one statement; multi-statement scripts belong in `execAsync`. ## How the helpers work underneath `runAsync`, `getFirstAsync`, `getAllAsync` and `getEachAsync` are convenience wrappers. Each one prepares a statement, executes it with your parameters, reads what it needs and **finalizes** the statement, all in one call. That is why they are safe to call casually, and also why repeating the same statement thousands of times is better served by preparing it once yourself.

  • What happens if you call openDatabaseAsync('ledger.db') in two different modules?
    By default both calls get the same cached native connection, because expo-sqlite reuses a connection per database name. Pass `useNewConnection: true` in the options only when you really need an independent connection.
  • Your getFirstAsync call for a category total crashes with 'cannot read property of null'. Why?
    `getFirstAsync` resolves to `null` when the query returns no rows. An aggregate like `SUM` returns one row even for no matches (with a `null` value), but a plain `SELECT ... WHERE` may return none. Check for `null` before reading fields.

saying these in an interview costs you the question

  • Passing user input into execAsync through string interpolation
  • Expecting runAsync to return the rows of a SELECT
  • Assuming getFirstAsync throws when no row matches
  • Loading an unbounded table with getAllAsync
  • Reaching for openDatabase and executeSql from the removed legacy API
open as a page

In an Expo app, what do expo-sqlite's SQLiteProvider and useSQLiteContext do, and what renders before the database is ready?

level: juniorimportance: must knowfreq 45%

basics

~20 s

SQLiteProvider opens one database for its subtree, importing an assetSource first and then running onInit, and puts it in React context; useSQLiteContext returns it in any descendant. By default the provider renders nothing until that work finishes.

open as a page

In expo-sqlite, why wrap several related writes in db.withTransactionAsync, and what happens when one of them throws?

level: juniorimportance: must knowfreq 48%

basics

~10 s

withTransactionAsync runs BEGIN, awaits your callback, then COMMIT; if the callback throws it runs ROLLBACK and rethrows. Related writes land all-or-nothing, and one commit for a batch is much faster than autocommitting every statement.

open as a page

Why is building an expo-sqlite query with template-string interpolation dangerous, and how do ? and $name parameter bindings prevent SQL injection?

level: middleimportance: must knowfreq 55%

basics

~20 s

Interpolated input can become SQL code. expo-sqlite's bound parameters (? placeholders or $name keys) compile the statement first and pass values separately, so input stays data. execAsync binds nothing, so it must never receive input.

open as a page

In an Expo app, how do you make a bird-sighting log screen built on expo-sqlite update when a new sighting is saved on another screen?

level: middleimportance: must knowfreq 40%

basics

~10 s

Open the database with enableChangeListener: true, for example through SQLiteProvider's options, then subscribe with addDatabaseChangeListener and re-run the screen's query when an event names the sightings table. Remove the subscription on unmount.

open as a page

In an Expo app using expo-sqlite, how do you migrate the on-device schema when version 2 of a journaling app adds a mood column?

level: middleimportance: must knowfreq 55%

basics

~20 s

Keep a schema number in PRAGMA user_version. On startup read it and run every numbered step above it in order; for version 2 that is ALTER TABLE entries ADD COLUMN mood. Then set user_version to 2, inside the same transaction.

open as a page

When should you use expo-sqlite's prepareAsync statements instead of runAsync, and what must you do with a statement when you are finished?

level: middleimportance: should knowfreq 33%

basics

~20 s

Use prepareAsync when the same SQL runs many times, such as importing a month of expenses: it compiles once and executeAsync rebinds new values each time. Always call finalizeAsync in a finally block, because a statement holds native resources until finalized.

open as a page

In an Expo app already using expo-sqlite, what do expo-sqlite/kv-store and the expo-sqlite/localStorage/install import provide, and what are their limits?

level: middleimportance: should knowfreq 28%

basics

~20 s

expo-sqlite/kv-store is an AsyncStorage-compatible key-value store kept in a SQLite table, with extra synchronous methods such as getItemSync. The localStorage/install import sets a synchronous globalThis.localStorage on native backed by the same store, and does nothing on web.

open as a page

In expo-sqlite, how does withExclusiveTransactionAsync differ from withTransactionAsync, and when is the exclusive one worth using?

level: middleimportance: should knowfreq 38%

basics

~20 s

withTransactionAsync runs BEGIN and COMMIT on the app's shared connection, so any query awaited meanwhile joins the transaction. withExclusiveTransactionAsync opens a separate connection and passes a txn object; only queries on txn are inside it.

open as a page

An Expo app's expense screen freezes while it loads a year of rows with expo-sqlite's getAllSync; why does that happen, and what would you change?

level: seniorimportance: should knowfreq 36%

basics

~20 s

expo-sqlite's *Sync methods run on the JavaScript thread, so nothing else in JavaScript runs until the whole year of rows is built. Use the async methods, which query on a native background queue, and read less: aggregate in SQL and page the list.

open as a page

In an Expo app, screens under an expo-sqlite SQLiteProvider go blank and lose their state whenever the parent re-renders; what is the likely cause and fix?

level: seniorimportance: should knowfreq 20%

basics

~20 s

An inline onInit arrow gives SQLiteProvider a new prop on every parent render; the provider then closes the database, renders null, reopens it and reruns onInit, remounting every child. Define onInit and options at module scope or memoize them.

open as a page

After an update, some users' Expo apps fail at startup because an expo-sqlite migration stopped halfway; how do you diagnose it and make migrations safe?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Statements that ran without a transaction, or without bumping user_version in the same commit, leave a schema the rerun cannot handle. Wrap each step with its version bump in one transaction, keep untransactable work idempotent, and test from every shipped version.

open as a page

In expo-sqlite, how do you ship a prebuilt database through SQLiteProvider's assetSource, and why might a newer bundled copy never reach existing users?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Bundle the .db file and pass assetSource={{ assetId: require('./assets/catalog.db') }}; the provider copies it into the database directory before opening. The copy is skipped when a file with that name exists, because forceOverwrite defaults to false.

open as a page

What does expo-sqlite's db.sql tagged template do with interpolated values, how does it decide what to return, and what can it not interpolate?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

db.sql turns every ${} in the template into a bound ? parameter, so values are injection-safe. Awaiting it returns row objects for SELECT, PRAGMA, WITH, EXPLAIN or RETURNING queries and a lastInsertRowId/changes result for other writes. Identifiers cannot be interpolated.

open as a page

In expo-sqlite, what changes when you set SQLiteProvider's useSuspense prop, and how do you then handle a failure to open or initialise?

level: middleimportance: nice to knowfreq 14%

basics

~20 s

With useSuspense, SQLiteProvider suspends on the database promise, so the nearest React Suspense boundary shows its fallback until the database is open and onInit has run. onError cannot be combined with it; failures go to an error boundary instead.

open as a page

In an Expo app, how do you encrypt an expo-sqlite database with SQLCipher, and what does that change for builds and connections?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

Set useSQLCipher: true in expo-sqlite's config plugin and rebuild the native app, since Expo Go cannot run it. Then run PRAGMA key as the first statement on every connection, before any other query touches the file.

open as a page

After a bulk import, an expo-sqlite live-query screen stutters and refetches thousands of times; how do change events behave, and how do you tame them?

level: seniorimportance: nice to knowfreq 14%

basics

~20 s

expo-sqlite's change listener fires once per changed row, as the row changes and before commit, for every database opened with enableChangeListener. A 5,000-row import means 5,000 events; coalesce them into one reload, ideally after the import finishes.

open as a page