---
title: How to Use SQLite in a Capacitor App
description: A practical Capacitor SQLite integration guide — open a database, run migrations and transactions, encrypt data, and store the key securely.
date:
  created: 2026-08-02
  updated: 2026-08-02
authors:
  - djabif
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor SQLite: sdks/capacitor/sqlite.md
faq: true
---

# How to Use SQLite in a Capacitor App

To use SQLite in a Capacitor app, install the [Capacitor SQLite plugin](../../sdks/capacitor/sqlite.md), open a database with `open()`, and then read and write rows with `query()` and `execute()`. The plugin gives you one API across Android, iOS, Web, and Electron, so the same code runs everywhere without touching platform-specific database code.

This is the integration guide: we start from an empty project and build up to a production-ready setup — schema migrations with versioning, transactions, an encrypted database, and a secure place to keep the encryption key. Every snippet is copy-paste ready.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Build and deploy your Capacitor app with Capawesome Cloud" src="https://capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.png?t=1" />
  </a>
</div>

## Key Takeaways

- **One API, four platforms.** The Capacitor SQLite plugin runs on Android, iOS, Web (via SQLite WASM), and Electron (via `node:sqlite`) with the same TypeScript methods.
- **`query()` returns `columns` and `rows`**, not objects. Rows come back as arrays of values, so you map them to objects yourself (there's a helper below).
- **Schema migrations are declarative.** You pass `upgradeStatements` with a `version` per schema step, and the plugin applies only what's missing based on `PRAGMA user_version`.
- **Encryption is 256-bit AES via SQLCipher, Android and iOS only.** It's opt-in and needs one build-config change per platform.
- **Never hard-code the encryption key.** Generate it once and store it with the [Capacitor Secure Preferences plugin](../../sdks/capacitor/secure-preferences.md) or, for user-gated access, the [Capacitor Vault plugin](../../sdks/capacitor/vault.md).

## Prefer to watch? Video walkthrough

If you'd rather see it built, this video walks through a working Capacitor SQLite setup end to end — opening a database, CRUD operations, and transactions — with the same patterns we cover below.

<div style="margin-top: 2rem;">
  <iframe
    width="100%"
    height="450px"
    src="https://www.youtube-nocookie.com/embed/JJg2r1UIxlk?rel=0&modestbranding=1"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen
  ></iframe>
</div>

Want a full project to reference? Two complete demo apps show the same patterns in different frameworks:

- **[Angular Capacitor SQLite Demo](https://github.com/capawesome-team/capacitor-sqlite-angular-demo){:target="_blank"}** — an Angular app with SQLite CRUD and transaction usage.
- **[React Capacitor SQLite Demo](https://github.com/capawesome-team/capacitor-sqlite-react-demo){:target="_blank"}** — the same patterns implemented in React.

## When to use SQLite in a Capacitor app

SQLite earns its place whenever an app needs a real local database rather than a handful of stored values. Common use cases:

- **Offline-first apps.** Store structured records on the device so the app stays fully usable with no network, then sync them when connectivity returns — the classic case for a notes app, a field-service tool, or a CRM.
- **Large or relational datasets.** When you need to filter, join, sort, or aggregate, SQL does it in the database instead of you loading everything into memory and looping in JavaScript. This scales to tens of thousands of rows without the UI stalling.
- **Encrypted local storage.** Protect sensitive records — health data, financial history, personal notes — with 256-bit AES encryption on Android and iOS (see [Encrypting the database](#encrypting-the-database)).
- **Full-text search.** Search large amounts of text quickly with SQLite's FTS5 extension instead of scanning strings by hand.
- **Settings and session data.** For small key/value data, the plugin's built-in key-value store persists preferences and session state reliably, without the risk of the WebView clearing `localStorage`.

If all you need is a few encrypted key/value pairs — an auth token, an API key — a full database is more than you need; the [Secure Preferences plugin](../../sdks/capacitor/secure-preferences.md) is the lighter fit. Reach for SQLite once the shape of your data calls for queries, relations, or larger record sets.

## Installing the plugin

To install the Capacitor SQLite plugin, follow the [Installation](../../sdks/capacitor/sqlite.md/#installation) section in the plugin documentation. It covers the npm package, the SQLite WASM dependency for the web, and the per-platform build settings. The [Platform notes](#platform-notes) section further down summarizes the setup gotchas that trip people up most often.

The plugin is available to [Capawesome Insiders](https://capawesome.io/insiders/){:target="_blank"}, and it works in any Capacitor app regardless of framework — Angular, React, Vue, or plain JavaScript.

## Opening a database

Everything starts with a database connection. The [`open(...)`](../../sdks/capacitor/sqlite.md#open) method opens an existing database file or creates a new one, and returns a `databaseId` you pass to every other call:

```ts
import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const { databaseId } = await Sqlite.open({
  path: 'notes.sqlite3',
  version: 1,
  upgradeStatements: [
    {
      version: 1,
      statements: [
        'CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT, created_at INTEGER)',
      ],
    },
  ],
});
```

A few things worth knowing:

- **`path`** is a simple filename on Android, iOS, and Web. The plugin stores it in each platform's default database directory. On Electron you can also pass a subfolder or absolute path.
- **Omitting `path` opens an in-memory database** — handy for tests or short-lived scratch data that doesn't need to survive a restart.
- **`version` and `upgradeStatements`** are how you manage your schema over time. More on that next.

Keep the `databaseId` around (in a service or module-level variable) rather than re-opening the database on every call.

## Reading and writing data

Use [`execute(...)`](../../sdks/capacitor/sqlite.md#execute) for anything that changes data — `INSERT`, `UPDATE`, `DELETE` — and always bind values with `?` placeholders instead of string concatenation. That's your protection against SQL injection:

```ts
const insertNote = async (databaseId: string, title: string, body: string) => {
  const { rowId } = await Sqlite.execute({
    databaseId,
    statement: 'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
    values: [title, body, Date.now()],
  });
  return rowId; // The id of the row you just inserted
};

const deleteNote = async (databaseId: string, id: number) => {
  const { changes } = await Sqlite.execute({
    databaseId,
    statement: 'DELETE FROM notes WHERE id = ?',
    values: [id],
  });
  return changes; // How many rows were removed
};
```

To read data, use [`query(...)`](../../sdks/capacitor/sqlite.md#query). This is the part that surprises people coming from other libraries: the result is not an array of objects. It's a `columns` array plus a `rows` array, where each row is an array of values in column order:

```ts
const result = await Sqlite.query({
  databaseId,
  statement: 'SELECT id, title, body FROM notes WHERE created_at > ?',
  values: [Date.now() - 86_400_000],
});

console.log(result.columns); // ['id', 'title', 'body']
console.log(result.rows);    // [[1, 'First note', 'Hello'], [2, 'Second', null]]
```

That shape is compact and fast, but most of the time you want objects. A small helper turns the result into typed records:

```ts
import type { Sqlite } from '@capawesome-team/capacitor-sqlite';

type QueryResult = Awaited<ReturnType<typeof Sqlite.query>>;

const toObjects = <T = Record<string, unknown>>(result: QueryResult): T[] =>
  result.rows.map(
    (row) => Object.fromEntries(row.map((value, i) => [result.columns[i], value])) as T,
  );

// Usage
interface Note {
  id: number;
  title: string;
  body: string | null;
}

const notes = toObjects<Note>(result);
```

Reach for this helper whenever you query, and the rest of your app can work with plain objects.

## Schema migrations with versioning

A real app's schema changes over time — you add a column, create a new table, add an index. The plugin handles this declaratively through `upgradeStatements`. Each entry has a `version` and the statements that bring the schema *to* that version. On open, the plugin checks the database's current `PRAGMA user_version` and runs only the statements newer than it, in order:

```ts
const { databaseId } = await Sqlite.open({
  path: 'notes.sqlite3',
  version: 3,
  upgradeStatements: [
    {
      version: 1,
      statements: [
        'CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT, created_at INTEGER)',
      ],
    },
    {
      version: 2,
      statements: ['ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'],
    },
    {
      version: 3,
      statements: [
        'CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE)',
        'CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes (created_at)',
      ],
    },
  ],
});
```

The rules that keep migrations predictable:

- **Never edit a shipped version.** Once `version: 1` is in the wild, its statements have already run on users' devices. To change the schema, add a *new* version. Rewriting an old one won't re-run it.
- **Bump `version` to the highest schema you define.** If you omit `version`, the plugin uses the latest entry in `upgradeStatements`. Setting it explicitly documents intent and guards against half-finished migrations.
- **Keep each version's statements idempotent where you can** (`IF NOT EXISTS`), so a partially-applied upgrade can be re-run safely.

You can read the current schema version at any time with a plain query — `PRAGMA user_version` — which is useful when debugging a migration that didn't apply.

## Transactions

When several statements have to succeed or fail as a unit — inserting a note and its tags, transferring a value between rows — wrap them in a transaction. [`beginTransaction(...)`](../../sdks/capacitor/sqlite.md#begintransaction) opens one, [`commitTransaction(...)`](../../sdks/capacitor/sqlite.md#committransaction) makes the changes permanent, and [`rollbackTransaction(...)`](../../sdks/capacitor/sqlite.md#rollbacktransaction) undoes everything if a step throws:

```ts
const createNoteWithTags = async (databaseId: string, title: string, tags: string[]) => {
  await Sqlite.beginTransaction({ databaseId });
  try {
    const { rowId } = await Sqlite.execute({
      databaseId,
      statement: 'INSERT INTO notes (title, created_at) VALUES (?, ?)',
      values: [title, Date.now()],
    });
    for (const tag of tags) {
      await Sqlite.execute({
        databaseId,
        statement: 'INSERT OR IGNORE INTO tags (name) VALUES (?)',
        values: [tag],
      });
    }
    await Sqlite.commitTransaction({ databaseId });
    return rowId;
  } catch (error) {
    await Sqlite.rollbackTransaction({ databaseId });
    throw error;
  }
};
```

The `try/catch/rollback` pattern is the important part: if any statement fails midway, the rollback restores the database to exactly where it was before `beginTransaction()`. Single, independent statements don't need this wrapping — a lone `INSERT` is already atomic.

## Encrypting the database

For sensitive data, the plugin supports **256-bit AES encryption via SQLCipher on Android and iOS**. You opt in by passing an `encryptionKey` when you open the database:

```ts
const { databaseId } = await Sqlite.open({
  path: 'secure-notes.sqlite3',
  encryptionKey: key, // See the next section for where this comes from
  version: 1,
  upgradeStatements: [
    {
      version: 1,
      statements: ['CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)'],
    },
  ],
});
```

Two things to note:

- **Encryption is Android/iOS only.** It's not available on Web or Electron, and it requires one build-config change per native platform to pull in SQLCipher (see [Platform notes](#platform-notes) and [SQLCipher compatibility](#sqlcipher-compatibility-notes)).
- **You can rotate the key** on an already-open encrypted database with [`changeEncryptionKey(...)`](../../sdks/capacitor/sqlite.md#changeencryptionkey), without recreating the file:

```ts
await Sqlite.changeEncryptionKey({ databaseId, encryptionKey: newKey });
```

## Storing the encryption key securely

The encryption is only as strong as the secrecy of the key. A key hard-coded in your JavaScript bundle offers no real protection — anyone can extract it from the installed app. The right pattern is: **generate a random key once, store it in platform-backed secure storage, and read it back on every launch.**

### Option 1: Secure Preferences (background access)

The [Capacitor Secure Preferences plugin](../../sdks/capacitor/secure-preferences.md) stores values encrypted at rest using the Android Keystore and iOS Keychain, and the app can read them at any time without prompting the user. That makes it the natural fit for a database key your app needs on every startup:

```ts
import { SecurePreferences } from '@capawesome-team/capacitor-secure-preferences';
import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const KEY_NAME = 'notes-db-encryption-key';

const generateKey = (): string => {
  const bytes = new Uint8Array(32); // 256 bits
  crypto.getRandomValues(bytes);
  return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
};

const getOrCreateKey = async (): Promise<string> => {
  const { value } = await SecurePreferences.get({ key: KEY_NAME });
  if (value) {
    return value;
  }
  const key = generateKey();
  await SecurePreferences.set({ key: KEY_NAME, value: key });
  return key;
};

const openSecureDatabase = async () => {
  const encryptionKey = await getOrCreateKey();
  return Sqlite.open({ path: 'secure-notes.sqlite3', encryptionKey });
};
```

The first launch generates and persists the key; every launch after that reads the same key back, so the database opens cleanly. Note that on Web, Secure Preferences falls back to unencrypted `localStorage` — fine for development, but don't rely on it for production secrets (and encryption isn't available on Web anyway).

### Option 2: Vault (user-gated access)

If access to the data should require an explicit user action — a biometric prompt or the device passcode — use the [Capacitor Vault plugin](../../sdks/capacitor/vault.md) instead. The vault has to be unlocked before you can read the key, which means the database can't be opened until the user authenticates:

```ts
import { Vault, VaultType } from '@capawesome-team/capacitor-vault';
import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const KEY_NAME = 'notes-db-encryption-key';

await Vault.initialize({
  type: VaultType.Biometric,
  title: 'Unlock your notes',
  lockAfterBackgrounded: 30_000,
});

const getKeyFromVault = async (): Promise<string> => {
  await Vault.unlock(); // Prompts for biometrics or the device passcode
  const { value } = await Vault.getValue({ key: KEY_NAME });
  if (value) {
    return value;
  }
  const key = generateKey();
  await Vault.setValue({ key: KEY_NAME, value: key });
  return key;
};
```

Use Secure Preferences when the app needs the key silently in the background, and Vault when you want the data locked behind an authentication prompt. The two can coexist — Secure Preferences for the database key, Vault for a master secret that gates the whole app.

## SQLCipher compatibility notes

The plugin's encryption is built on [SQLCipher](https://www.zetetic.net/sqlcipher/){:target="_blank"}, and there are a few things to keep in mind before you ship it:

- **It's opt-in per platform.** On Android you set `capawesomeCapacitorSqliteIncludeSqlcipher = true` in `variables.gradle`; on iOS you pick the `SQLCipher` CocoaPods subspec or enable the `SQLCipher` Swift Package Manager trait. Without that, the plugin builds against plain SQLite and encryption is unavailable.
- **Export compliance is your responsibility.** Shipping an app that bundles SQLCipher means shipping cryptography — you're responsible for any export, re-export, and import regulations that apply in the countries you distribute to.
- **The license requires attribution.** SQLCipher's Community Edition uses a BSD-style license that requires its copyright notice and license text to appear in a **user-accessible location** in your app — for example, an "About" or "Licensing" screen. See the [SQLCipher license](https://www.zetetic.net/sqlcipher/license/){:target="_blank"} for the exact text.
- **It's compatible with Ionic Secure Storage databases.** Ionic Secure Storage is also SQLCipher-based, so an existing encrypted database can generally be opened with the same key — see the [migration FAQ](#is-this-a-drop-in-replacement-for-ionic-secure-storage) below.

## Platform notes

The API is identical across platforms, but the one-time setup differs. Here's what each platform needs on top of the base [installation](../../sdks/capacitor/sqlite.md/#installation).

### Android

- **Encryption:** set `capawesomeCapacitorSqliteIncludeSqlcipher = true` in your app's `variables.gradle` to bundle SQLCipher.
- **Newer SQLite version:** to bundle a consistent SQLite across devices instead of the system one, set `capawesomeCapacitorSqliteIncludeRequery = true` and add the JitPack repository to `build.gradle`. This is also required to load custom extensions.
- **Proguard:** if you use Proguard, keep the plugin classes with `-keep class io.capawesome.capacitorjs.plugins.** { *; }`.

### iOS

- **CocoaPods:** add the `CapawesomeTeamCapacitorSqlite/Plain` pod, or `.../SQLCipher` if you want encryption.
- **Swift Package Manager:** no extra setup for the plain build. For encryption, enable the `SQLCipher` package trait in your Capacitor config (requires Capacitor CLI 8.3.0+ and Xcode 16.3+). This is newer than a lot of older tutorials assume — SPM does support encryption now.

### Web

- **Dependency and headers:** the web build uses SQLite WebAssembly and needs the `@sqlite.org/sqlite-wasm` package plus two response headers on your dev and production servers:

```
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
```

- **Initialize the worker:** on web, call `Sqlite.initialize({ worker })` with the SQLite WASM worker before opening a database. Angular and Vite each need a small build-config tweak, both covered in the docs.
- **No encryption, limited BLOBs:** database encryption isn't available on Web, and BLOB values (arrays of numbers) aren't supported there.

### Electron

- **Native SQLite:** Electron uses the Node.js `node:sqlite` module, which requires Node.js 22.5.0 or later (Electron 33+).
- **Storage location:** databases live in the app's `userData` directory by default; pass a subfolder or absolute path to organize them.
- **No encryption:** database encryption isn't supported on Electron.

## Common errors and how to fix them

A handful of errors come up often enough to call out:

- **`no such vfs: opfs`** (Web) — the browser can't instantiate the Origin Private File System because the COOP/COEP headers are missing. Add the two `Cross-Origin-*` headers shown above.
- **`Sqlite.open()` never resolves in production** (Web) — same root cause. It works in dev but hangs once deployed because your host (Netlify, Vercel, Nginx, …) isn't sending the COOP/COEP headers. Check the response headers on your HTML document in DevTools → Network.
- **`No such module 'SQLite'`** (iOS) — the `Plain` or `SQLCipher` pod isn't in your `Podfile`. Add it under the `# Add your Pods here` comment and run `pod install`.
- **SQLite result codes** — errors that come from SQLite expose the numeric result code on `error.data.sqliteCode`, so you can branch on specific failures instead of parsing messages:

```ts
try {
  await Sqlite.open({ path: '/invalid/path/to.db' });
} catch (error) {
  if (error.data?.sqliteCode === 14) {
    // SQLITE_CANTOPEN: the database file could not be opened
  }
}
```

## Performance considerations

A few habits keep a local SQLite database fast as it grows:

- **Reclaim space with `VACUUM`.** After deleting large amounts of data, the file doesn't shrink on its own. Run [`vacuum(...)`](../../sdks/capacitor/sqlite.md#vacuum) periodically to rebuild the file and defragment it.
- **Use in-memory databases for throwaway work.** Opening without a `path` gives you a fast in-memory database that never touches disk — ideal for tests, caching, or transient computation.
- **Skip return values you don't need.** `execute()` returns the change count and last insert id by default. For bulk inserts where you don't use them, set `returnChanges: false` and `returnRowId: false` to save work.
- **One statement per call.** On Android and Electron, each `execute()` or `query()` runs a single statement — statements joined by `;` won't all run. Call once per statement, wrapped in a transaction for bulk work. Statements that return rows (like `PRAGMA journal_mode = WAL`) must go through `query()`, not `execute()`.
- **Batch writes in a transaction.** Wrapping many inserts in a single transaction is dramatically faster than committing each one individually, because SQLite only flushes to disk once.

## Beyond raw SQL

You don't always have to write SQL by hand:

- **Key-value storage:** the plugin ships a built-in `SqliteKeyValueStore` for simple `get`/`set`/`remove` persistence without any SQL. See [Key-Value Storage Made Simple with the SQLite Plugin](./key-value-storage-made-simple-with-the-sqlite-plugin.md).
- **ORMs:** if you prefer a typed query builder or entity models, the plugin works with [Drizzle](./how-to-use-drizzle-orm-with-capacitor-and-sqlite.md), [Kysely](./how-to-use-kysely-with-capacitor-and-sqlite.md), and [TypeORM](./how-to-use-typeorm-with-capacitor-and-sqlite.md).
- **Full-text search and extensions:** for FTS5 and custom SQLite extensions, see [How to Use Custom SQLite Extensions in Capacitor](./how-to-use-custom-sqlite-extensions-with-capacitor.md).

For a method-by-method reference of the entire API — including `closeAll()`, `getVersion()`, and every option — read [Exploring the Capacitor SQLite API](./exploring-the-capacitor-sqlite-api.md).

## Frequently asked questions

### How do I use SQLite in a Capacitor app?

Install the Capacitor SQLite plugin, call `open()` to get a `databaseId`, then use `execute()` for writes and `query()` for reads, passing the `databaseId` to each. Manage your schema with `upgradeStatements` and versioning, wrap multi-step writes in `beginTransaction()`/`commitTransaction()`, and — if you need encryption — open the database with an `encryptionKey` on Android or iOS. The same code runs on Android, iOS, Web, and Electron.

### Why does `query()` not return objects?

By design, for speed and size. `query()` returns a `columns` array and a `rows` array, where each row is an array of values in column order. Convert them to objects with a small mapper (see [Reading and writing data](#reading-and-writing-data)) when your app needs record-shaped data.

### Is database encryption available on every platform?

No. Encryption uses SQLCipher and is available only on Android and iOS, where it provides 256-bit AES. It's opt-in and needs a per-platform build setting. Web and Electron don't support database encryption.

### Where should I store the encryption key?

Not in your source code. Generate a random key once and keep it in platform-backed secure storage — the [Secure Preferences plugin](../../sdks/capacitor/secure-preferences.md) for background access, or the [Vault plugin](../../sdks/capacitor/vault.md) when access should require biometrics or a passcode.

### Is this a drop-in replacement for Ionic Secure Storage?

Largely, yes. [Ionic Secure Storage](https://ionic.io/products/secure-storage){:target="_blank"} sunsets on December 31, 2027, and it's built on an encrypted SQLCipher database — the same foundation this plugin uses. Because both apply SQLCipher's default configuration, an existing database can generally be opened with the same encryption key, though you should verify against your own data before migrating. The step-by-step walkthrough is in [Alternative to the Ionic Secure Storage plugin](./alternative-to-ionic-secure-storage-plugin.md).

## Try Capawesome

The fastest way to learn the plugin is to open a database and run a query in your own app. Subscribe below to get new Capacitor and SQLite guides as they land.

[Subscribe to the Capawesome Newsletter](https://capawesome.io/newsletter/){ .md-button .md-button--primary }

## Conclusion

Using SQLite in a Capacitor app comes down to a short list of moves: open a database, manage the schema with versioned `upgradeStatements`, read and write with `query()` and `execute()`, group related writes in transactions, and — when the data is sensitive — encrypt the file and keep the key in secure storage rather than your bundle. Once that foundation is in place, everything else (ORMs, full-text search, key-value storage) builds on top of it.

For the complete method reference, continue with [Exploring the Capacitor SQLite API](./exploring-the-capacitor-sqlite-api.md). If you have questions or want to share what you're building, join the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}, and subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"} to keep up with new plugin releases.
