Um SQLite in einer Capacitor App zu nutzen, installiere ein natives SQLite-Plugin wie @capgo/capacitor-fast-sql, öffne beim Start einmal eine Datenbank, erstelle dein Schema mit versionierten Migrationen und lies und schreibe mit parametrisiertem SQL. Dieser Guide zeigt das komplette Setup auf Capacitor 8 für iOS, Android und Web – inklusive Transaktionen, Verschlüsselung, Dateispeicherorten und den häufigsten Fehlern.
Wenn du nur ein paar Einstellungen brauchst, ist SQLite Overkill. Wenn du Listen, Datensätze oder alles andere speichern willst, das du filtern, sortieren oder synchronisieren möchtest, ist es das richtige Tool.
Wann SQLite die richtige Speicherwahl ist
Capacitor Apps haben mehrere Orte, um Daten abzulegen. Sie lösen unterschiedliche Probleme:
| Speicher | Ideal für | Limits |
|---|---|---|
localStorage / IndexedDB |
Temporärer Cache | Lebt in der WebView, das OS kann es verwerfen, keine echten Queries in localStorage |
@capacitor/preferences |
Ein paar kleine Einstellungen (Theme, Sprache) | Nur String-Werte, nicht für große Datenmengen gedacht, nicht verschlüsselt |
| Keychain-/Keystore-Speicher | Tokens, Verschlüsselungsschlüssel | Nur kleine Secrets |
Dateien (@capacitor/filesystem) |
Bilder, PDFs, Exporte | Keine Abfragen |
| SQLite | Datensätze, Offline-Daten, Sync-Queues, Suche | Du verwaltest Schema und Migrationen |
Nutze SQLite, wenn du: Offline-First-Daten, mehr als ein paar hundert Datensätze, Filter und Sortierung, Tabellenbeziehungen, atomare Multi-Step-Writes oder Verschlüsselung für strukturierte Daten benötigst.
Die Wahl des richtigen Capacitor SQLite-Plugins im Jahr 2026
Es gibt drei realistische Optionen für natives SQLite auf Capacitor 8:
@capgo/capacitor-fast-sql |
@capacitor-community/sqlite |
@capgo/capacitor-data-storage-sqlite |
|
|---|---|---|---|
| Zweck | Allgemeine SQL-Datenbank | Allgemeine SQL-Datenbank | String Key-Value Store auf SQLite |
| Plattformen | iOS, Android, Web | iOS, Android, Web, Electron | iOS, Android, Web, Electron |
| Web-Engine | Offizielles SQLite Wasm + OPFS | jeep-sqlite (sql.js + IndexedDB) | IndexedDB |
| Datentransport | Lokaler HTTP-Channel, umgeht die Bridge | Capacitor Bridge | Capacitor Bridge |
| Verschlüsselung | Optionales SQLCipher (iOS, Android) | SQLCipher immer im Bundle enthalten | SQLCipher, Passphrase in der Config |
| Zeilenformat | Objekte mit Spalten-Keys | Objekte mit Spalten-Keys | Strings |
| ORM-Unterstützung | Custom Driver (Kysely, Drizzle) | TypeORM Built-in Driver | Keine |
Dieser Guide nutzt Fast SQL. Es wird von Capgo gepflegt, verfolgt Capacitor Major-Versionen (Plugin v8 für Capacitor 8), hält SQLCipher optional und vermeidet die Serialisierung großer Result-Sets über die Bridge. Falls du bereits das Community-Plugin nutzt, lies unseren Vergleichs- und Migrationsguide zuerst.
Fast SQL installieren
bun add @capgo/capacitor-fast-sql
bunx cap sync
Fast SQL startet einen kleinen HTTP-Server auf localhost und sendet SQL per Bearer-Token pro Verbindung dorthin. Beide Plattformen blockieren standardmäßig unverschlüsseltes HTTP; erlauben Sie es daher nur für Loopback.
iOS: lokales Netzwerk erlauben
Fügen Sie dies zu ios/App/App/Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
NSAllowsLocalNetworking deckt nur Loopback- und lokale Adressen ab. Die App Transport Security wird dadurch für Ihre echten API Aufrufe nicht gelockert.
Android: Cleartext für localhost erlauben
Erstellen android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
Referenzieren Sie es in android/app/src/main/AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Falls Sie bereits eine Network Security Config nutzen (z. B. für SSL Pinning), fügen Sie den domain-config Block einfach dort hinzu, statt eine zweite Datei zu erstellen.
Datenbank einmalig öffnen
Erstellen Sie ein einzelnes Modul, das die Verbindung verwaltet. Die Datenbank ständig neu zu öffnen ist ineffizient, zudem benötigen Sie einen zentralen Ort für Migrationen.
// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { migrate } from './migrate';
let dbPromise: Promise<SQLConnection> | null = null;
export function getDb(): Promise<SQLConnection> {
if (!dbPromise) {
dbPromise = (async () => {
const db = await FastSQL.connect({
database: 'notes',
walMode: true, // PRAGMA journal_mode = WAL
performancePresets: true, // synchronous=NORMAL, busy_timeout, foreign_keys=ON
});
await migrate(db);
return db;
})().catch((error) => {
dbPromise = null; // let the next call retry instead of reusing the failure
throw error;
});
}
return dbPromise;
}
walMode und performancePresets sind ab Plugin-Version 8.0.49 verfügbar. performancePresets wird ebenfalls aktiviert foreign_keys, was SQLite standardmäßig nicht tut, damit ON DELETE CASCADE tatsächlich funktioniert.
Rufe getDb() wo immer du die Datenbank benötigst. Der erste Aufruf öffnet und migriert, spätere Aufrufe nutzen dasselbe Promise. Schlägt das Öffnen oder Migrieren fehl, versucht der nächste Aufruf es erneut.
Schema-Migrationen mit PRAGMA user_version
Fast SQL bietet dir rohes SQL statt eines Migration-Frameworks. Das ist völlig okay, da SQLite bereits ein Version-Feld im File-Header besitzt: PRAGMA user_version. Behalte ein geordnetes Array an Schritten und wende nur das an, was noch fehlt.
// src/db/migrate.ts
import type { SQLConnection } from '@capgo/capacitor-fast-sql';
// Index 0 = version 1, index 1 = version 2, ...
// Never edit a step after it shipped. Add a new one.
const steps: string[][] = [
[
`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at INTEGER NOT NULL
)`,
],
[
`ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`,
`CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes (created_at)`,
],
];
export async function migrate(db: SQLConnection): Promise<void> {
const rows = await db.query('PRAGMA user_version');
const current = Number(rows[0]?.user_version ?? 0);
for (let version = current; version < steps.length; version++) {
await db.transaction(async (tx) => {
for (const statement of steps[version]) {
await tx.execute(statement);
}
// PRAGMA does not accept bound parameters, the value is a trusted integer
await tx.execute(`PRAGMA user_version = ${version + 1}`);
});
}
}
Regeln, um die Sicherheit zu gewährleisten:
- Ein SQL-Statement pro
execute()aufrufen. Beide nativen Implementierungen bereiten nur das erste Statement eines Strings vor, daher"CREATE ...; CREATE ..."wird einer davon stillschweigend ausgeführt. - Jede Version läuft in einer Transaktion; ein Absturz auf halbem Weg lässt die Datenbank auf dem vorherigen Stand, und der Schritt wird beim nächsten Start wiederholt.
- Bevorzuge additive Änderungen (neue Tabellen, neue nullable Spalten). SQLite unterstützt dies
ALTER TABLE ... DROP COLUMNundRENAME COLUMNin neueren Versionen, aber das System-SQLite auf älteren Android-Releases kann veraltet sein. Der portabelste Weg, eine Tabelle umzustrukturieren, ist: neue Tabelle erstellen, kopieren, alte löschen, umbenennen.
Migrationen und Live Updates
Wenn du JavaScript-Änderungen mit Capgo Live Updatesauslieferst, wird deine code Migration mit dem Bundle mitgeliefert. Das ist praktisch, da ein Schema-Change kein Store-Release erfordert. Es bedeutet auch, dass ein Rollback ein älteres Bundle über ein neueres Schema legen kann. Halte Migrationen abwärtskompatibel (Spalten hinzufügen, aber nicht umbenennen oder löschen im selben Release, in dem sie nicht mehr genutzt werden), damit ältere Bundles weiterhin funktionieren.
Daten lesen und schreiben
SQLConnection bietet dir vier Methoden, die du ständig nutzen wirst:
| Methode | Rückgabewert | Verwenden für |
|---|---|---|
query(sql, params) |
SQLRow[] |
SELECT- und PRAGMA-Reads |
run(sql, params) |
{ rowsAffected, insertId } |
INSERT, UPDATE, DELETE |
execute(sql, params) |
{ rows, rowsAffected, insertId } |
Alles, vollständiges Ergebnis |
executeBatch(ops) |
SQLResult[] |
Mehrere Statements in einem Roundtrip |
Binde Werte immer mit ? Placeholdern. Erstelle SQL niemals per String-Konkatenation aus User-Input.
// src/db/notes.ts
import { getDb } from './index';
export interface Note {
id: number;
title: string;
body: string | null;
created_at: number;
pinned: number;
}
export async function addNote(title: string, body: string | null): Promise<number> {
const db = await getDb();
const { insertId } = await db.run(
'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
[title, body, Date.now()],
);
if (insertId == null) throw new Error('Insert did not return an id');
return insertId;
}
export async function listNotes(search = ''): Promise<Note[]> {
const db = await getDb();
const rows = await db.query(
'SELECT id, title, body, created_at, pinned FROM notes WHERE title LIKE ? ORDER BY pinned DESC, created_at DESC',
[`%${search}%`],
);
return rows as unknown as Note[];
}
export async function setPinned(id: number, pinned: boolean): Promise<void> {
const db = await getDb();
await db.run('UPDATE notes SET pinned = ? WHERE id = ?', [pinned ? 1 : 0, id]);
}
export async function deleteNote(id: number): Promise<number> {
const db = await getDb();
const { rowsAffected } = await db.run('DELETE FROM notes WHERE id = ?', [id]);
return rowsAffected;
}
Rows werden als Plain Objects mit Spaltennamen als Keys zurückgegeben, sodass kein Mapping-Schritt nötig ist. SQLite hat keinen Boolean-Typ, also speichere 1 und 0 und konvertiere sie an der Grenze deiner Data Layer, da setPinned tut.
Transaktionen und Batch-Writes
Umschließe jeden mehrstufigen Schreibvorgang mit einer Transaction. Das hält die Daten konsistent und ist viel schneller, da SQLite beim Commit nur einmal statt bei jedem einzelnen Statement auf die Disk synchronisiert.
import { getDb } from './index';
export async function importNotes(items: { title: string; body: string }[]) {
const db = await getDb();
const now = Date.now();
await db.transaction(async (tx) => {
await tx.executeBatch(
items.map((item) => ({
statement: 'INSERT INTO notes (title, body, created_at) VALUES (?, ?, ?)',
params: [item.title, item.body, now],
})),
);
});
}
transaction() committet, sobald der Callback aufgelöst wird, und führt einen Rollback aus, falls ein Fehler geworfen wird. Für manuelle Kontrolle gibt es auch beginTransaction(), commit() und rollback() auf der Verbindung.
Alle Aufrufe nutzen pro Datenbank eine einzige native Verbindung. Solange eine Transaktion offen ist, werden andere code, die auf dieselbe Datenbank schreiben, darin ausgeführt. Halte Transaktionen kurz und warte darin nicht auf Netzwerk-Requests.
Binäre Daten speichern
Übergib einen Uint8Array und du erhältst einen Uint8Array zurück. Das Plugin kodiert es für den Transport als Base64 und speichert einen echten BLOB.
const db = await getDb();
await db.execute('CREATE TABLE IF NOT EXISTS thumbnails (note_id INTEGER PRIMARY KEY, data BLOB)');
await db.run('INSERT OR REPLACE INTO thumbnails (note_id, data) VALUES (?, ?)', [noteId, bytes]);
const [row] = await db.query('SELECT data FROM thumbnails WHERE note_id = ?', [noteId]);
const image = row?.data as Uint8Array | undefined;
Speichere große Medien (Fotos, Videos) als Dateien und lege in SQLite nur den Pfad ab. BLOBs eignen sich gut für Thumbnails, kleine Anhänge und Embeddings.
Datenbank verschlüsseln
Fast SQL unterstützt SQLCipher auf iOS und Android. Es ist Opt-in, sodass Apps, die es nicht brauchen, es auch nicht mitliefern:
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: keyFromSecureStorage,
});
Unter Android fügst du net.zetetic:sqlcipher-android zu deiner App build.gradlehinzu. Unter iOS stellst du den Pod auf den CapgoCapacitorFastSql/SQLCipher Das Generieren des Schlüssels, das Speichern im Keychain oder Keystore, das Verschlüsseln einer bestehenden Plaintext-Datenbank sowie das Rotieren von Keys werden Schritt für Schritt in So verschlüsselst du eine SQLite-Datenbank in Capacitorerklärt. Verschlüsselung ist im Web nicht verfügbar.
Web-Support
Im Web nutzt Fast SQL den offiziellen @sqlite.org/sqlite-wasm Build und speichert die Datei im Origin Private File System (OPFS). Es handelt sich um eine echte SQLite-Datei, nicht um eine Kopie im Speicher, die in IndexedDB geschrieben wird.
OPFS benötigt Cross-Origin-Isolation, daher müssen dein Dev-Server und dein Production-Host zwei Header senden:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Mit Vite:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm'],
},
});
Fehlen die Header, loggt das Plugin eine Warnung und nutzt eine nicht-persistente Datenbank: Alles funktioniert, bis du die App neu lädst. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) ermöglicht es dir, die Defaults vor dem ersten connect(), und auf Native hat es keine Auswirkung.
require-corp blockiert auch Cross-Origin-Bilder und Skripte ohne CORP- oder CORS-Header; teste also Third-Party-Embeds, wenn du es aktivierst.
Speicherort der Datenbankdatei
| Plattform | Ort | Notizen |
|---|---|---|
| iOS | Documents/<name>.db |
Standardmäßig in iCloud- und Geräte-Backups enthalten |
| Android | files/<name>.db im internen Speicher der App |
Unterliegt den Android Auto Backup-Regeln |
| Web | OPFS für den Page-Origin | Wird gelöscht, wenn der Nutzer die Websitedaten löscht |
Zwei praktische Konsequenzen: Erstens löscht das Deinstallieren der App die Datei auf beiden mobilen Plattformen. Zweitens können Backups die Datenbankdatei auf ein neues Gerät wiederherstellen. Das ist meistens gewünscht, aber wenn die Datenbank mit einem Schlüssel verschlüsselt ist, der nicht im Backup enthalten ist, kann die wiederhergestellte Datei nicht geöffnet werden. Der Encryption-Guide erklärt, wie man damit umgeht.
Performance-Checkliste
- Einschalten
walMode. Leser blockieren den Writer nicht mehr. - Batch-Writes in einer Transaktion mit
executeBatch. Das ist der größte Performance-Gewinn für Imports und Sync. - Füge Indizes für Spalten hinzu, nach denen du filterst oder sortierst, und prüfe dies mit
EXPLAIN QUERY PLAN. - Wähle nur die benötigten Spalten aus. Große
TEXTundBLOBSpalten kosten Übertragungszeit, selbst über HTTP. - Nutze Pagination mit
WHERE created_at < ? ORDER BY created_at DESC LIMIT 50anstatt riesigerOFFSETWerte. - Nach dem Löschen vieler Daten solltest du
VACUUMoccasionally to shrink the file. It rewrites the whole database, so do it during idle time.
Fehlerbehebung
SQL execution failed auf iOS direkt nach dem Connect. Der lokale HTTP-Request wurde blockiert. Prüfe, ob NSAllowsLocalNetworking im richtigen Info.plist und die native App neu bauen; ein Live Reload reicht nicht aus.
CLEARTEXT communication to localhost not permitted auf Android. Die Network Security Config fehlt oder wird im Manifest nicht referenziert.
Nur das erste Statement wurde ausgeführt. Du hast mehrere Statements in einem String übergeben. Trenne sie oder nutze executeBatch.
no such table nach einem Update. Die Migration wurde nicht ausgeführt oder gegen einen anderen Datenbanknamen. Logge PRAGMA user_version beim Startup und vergleiche es mit deinem steps-Array.
Ein Filter auf einen Boolean- oder berechneten Wert schlägt unter Android immer fehl. Bei Read-Queries bindet die Android-Implementierung Parameter als Text. Vergleiche mit Spalten mit INTEGER-Affinität funktionieren weiterhin, da SQLite den Text konvertiert, aber Vergleiche mit Ausdrücken (z. B. json_extract(...) = ? oder count(*) > ?) vergleichen Text mit einer Zahl. Übergib 1/0 anstatt true/false, und verwende CAST(? AS INTEGER) beim Vergleich mit einem Ausdruck.
INSERT ... RETURNING gibt auf Android keine Zeilen zurück. In der aktuellen Plugin-Version gibt Android nur Zeilen für Statements zurück, die mit SELECT, PRAGMA oder EXPLAIN. Nutze insertId von run() und wählt die Zeile danach aus. Das Gleiche gilt für Queries, die mit beginnen, WITH: umschließe sie so, dass sie mit beginnen, SELECT, oder teste sie frühzeitig auf Android.
Daten verschwinden beim Reload im Browser. OPFS ist nicht verfügbar, was fast immer an fehlenden COOP/COEP-Headern auf dem Host liegt.
Encryption is not available in this build auf iOS. Du hast bestanden encrypted: true ohne auf die SQLCipher-Subspec zu wechseln.
Weiter geht's
Raw SQL reicht für kleine Apps. Sobald du mehr als ein paar Tabellen hast, hilft ein typed Layer:
- Kysely mit Capacitor und SQLite: ein typed Query Builder mit einem kleinen Custom Driver für schnelles SQL.
- Drizzle ORM mit Capacitor und SQLite: Schema in TypeScript, generierte Migrations.
- TypeORM mit Capacitor und SQLite: Decorators und Repositories.
- Key-Value-Storage-Optionen in Capacitor: Wann Preferences oder eine SQLite-KV-Tabelle ausreichen.
Die Plugin-Referenz findest du im Fast SQL Docs und Plugin-Seite. Schnelles SQL erfordert einen Native Build bei Installation oder Konfigurationsänderung. Wenn du dafür keinen Mac warten möchtest, Capgo Build can produce signed iOS and Android binaries in the cloud. Since April 2026 App Store uploads must be built with Xcode 26 or later, so check your build machine before the next release.