Salta al contenuto principale

Come usare SQLite in un'app Capacitor (Guida 2026)

Scopri come usare SQLite in un'app Capacitor: scegli un plugin, apri un database, esegui migrazioni e transazioni, gestisci crittografia e supporto web, con code funzionanti.

Logo Capacitor per una guida all'uso di SQLite in un'app Capacitor

Per usare SQLite in un'app Capacitor, installa un plugin SQLite nativo come @capgo/capacitor-fast-sql, apri un database una volta all'avvio, crea lo schema con migrazioni versionate, poi leggi e scrivi con SQL parametrizzato. Questa guida illustra l'intero setup su Capacitor 8 per iOS, Android e web, incluse transazioni, crittografia, posizioni dei file e gli errori che incontrerai più spesso.

Se ti servono solo poche impostazioni, SQLite è eccessivo. Se invece devi salvare liste, record o qualsiasi cosa da filtrare, ordinare o sincronizzare, è lo strumento giusto.

Quando SQLite è la scelta giusta per lo storage

Le app Capacitor hanno diversi posti dove salvare i dati. Ognuno risolve problemi diversi:

Storage Ideale per Limiti
localStorage / IndexedDB Cache temporanea Risiede nella WebView, l'OS può eliminarla, niente query reali in localStorage
@capacitor/preferences Poche piccole impostazioni (tema, lingua) Solo valori stringa, non adatto a grandi dati, non criptato
Storage Keychain / Keystore Token, chiavi di cifratura Solo piccoli segreti
File (@capacitor/filesystem) Immagini, PDF, export Nessuna query
SQLite Record, dati offline, code di sync, ricerca Gestisci tu schema e migrazioni

Scegli SQLite se ti servono: approccio offline-first, più di qualche centinaio di record, filtri e ordinamento, relazioni tra tabelle, scritture atomiche multi-step o cifratura a riposo per dati strutturati.

Scegliere un plugin SQLite Capacitor nel 2026

Ci sono tre opzioni realistiche per SQLite nativo su Capacitor 8:

@capgo/capacitor-fast-sql @capacitor-community/sqlite @capgo/capacitor-data-storage-sqlite
Scopo Database SQL generico Database SQL generico Store chiave-valore su SQLite
Piattaforme iOS, Android, Web iOS, Android, Web, Electron iOS, Android, Web, Electron
Motore web SQLite Wasm + OPFS ufficiale jeep-sqlite (sql.js + IndexedDB) IndexedDB
Trasporto dati Canale HTTP locale, bypassa il bridge bridge Capacitor bridge Capacitor
Crittografia SQLCipher opzionale (iOS, Android) SQLCipher sempre incluso nel bundle SQLCipher, passphrase nella configurazione
Formato riga Oggetti con chiavi per colonna Oggetti con chiavi per colonna Stringhe
Supporto ORM Driver personalizzati (Kysely, Drizzle) Driver integrato di TypeORM Nessuna

Questa guida utilizza Fast SQL. È mantenuto da Capgo, segue Capacitor versioni major (plugin v8 per Capacitor 8), mantiene SQLCipher opzionale ed evita la serializzazione di grandi set di risultati tramite il bridge. Se utilizzi già il plugin della community, leggi la nostra guida alla comparazione e migrazione prima.

Installa Fast SQL

bun add @capgo/capacitor-fast-sql
bunx cap sync

Fast SQL avvia un piccolo server HTTP collegato a localhost e invia le query SQL tramite un bearer token per connessione. Entrambe le piattaforme bloccano l'HTTP in chiaro di default, quindi abilitalo solo per il loopback.

iOS: abilita la rete locale

Aggiungi questo a ios/App/App/Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

NSAllowsLocalNetworking copre solo gli indirizzi loopback e locali. Non disabilita l'App Transport Security per le tue chiamate API reali.

Android: abilita cleartext per localhost

Crea 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>

Richiamalo in android/app/src/main/AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

Se hai già una network security config (per l'SSL pinning, ad esempio), aggiungi il domain-config blocco invece di creare un secondo file.

Apri il database una sola volta

Crea un unico modulo che gestisca la connessione. Aprire ripetutamente lo stesso database è inefficiente, e avrai bisogno di un unico punto per eseguire le migrazioni.

// 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 e performancePresets sono disponibili dalla versione 8.0.49 del plugin. performancePresets attiva anche foreign_keys, che SQLite lascia disattivato di default, quindi ON DELETE CASCADE funziona davvero.

Chiama getDb() ovunque ti serva il database. La prima chiamata apre e migra, le chiamate successive riutilizzano la stessa promise. Se l'apertura o la migrazione falliscono, la chiamata successiva riprova.

Migrazioni dello schema con PRAGMA user_version

Fast SQL ti dà SQL raw, non un framework di migrazione, e va bene così perché SQLite ha già uno slot per la versione nell'header del file: PRAGMA user_version. Mantieni un array ordinato di step e applica solo quelli mancanti.

// 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}`);
    });
  }
}

Regole per tenerlo al sicuro:

  • Una sola istruzione SQL per ogni execute() chiamata. Entrambe le implementazioni native preparano solo la prima istruzione di una stringa, quindi "CREATE ...; CREATE ..." ne esegue una in silenzio.
  • Ogni versione viene eseguita in una transazione, quindi un crash a metà lascia il database alla versione precedente e lo step viene rieseguito al prossimo avvio.
  • Preferisci modifiche additive (nuove tabelle, nuove colonne nullable). SQLite supporta ALTER TABLE ... DROP COLUMN e RENAME COLUMN nelle versioni recenti, ma il SQLite di sistema su vecchie release Android potrebbe essere datato; il modo più portabile per modificare una tabella è creare una nuova tabella, copiare i dati, eliminare la vecchia e rinominare.

Migrazioni e live updates

Se distribuisci modifiche JavaScript con Live updates di Capgo, la tua migrazione code viene distribuita con il bundle. È comodo, perché un cambio di schema non richiede un nuovo rilascio sullo store. Significa anche che un rollback può applicare un bundle vecchio su uno schema più recente. Mantieni le migrazioni backward compatible (aggiungi colonne, non rinominarle o eliminarle nello stesso rilascio in cui smetti di usarle) e il vecchio bundle continuerà a funzionare.

Leggere e scrivere dati

SQLConnection ti offre quattro metodi che userai continuamente:

Metodo Restituisce Usa per
query(sql, params) SQLRow[] letture SELECT e PRAGMA
run(sql, params) { rowsAffected, insertId } INSERT, UPDATE, DELETE
execute(sql, params) { rows, rowsAffected, insertId } Qualsiasi cosa, risultato completo
executeBatch(ops) SQLResult[] Molte istruzioni in un unico round trip

Associa sempre i valori tramite ? placeholder. Non costruire mai SQL concatenando stringhe dall'input dell'utente.

// 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;
}

Le righe tornano come oggetti semplici indicizzati per nome di colonna, quindi non c'è nessuna fase di mapping columns/values. SQLite non ha un tipo boolean, quindi salva 1 e 0 e converti al confine del tuo data layer, come setPinned fa.

Transazioni e scritture batch

Racchiudi ogni scrittura multi-step in una transazione. Mantiene i dati coerenti ed è molto più veloce, perché SQLite sincronizza su disco una volta per commit invece che a ogni istruzione.

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() fa commit quando la callback si risolve e rollback se lancia un errore. Se ti serve un controllo manuale, c'è anche beginTransaction(), commit() e rollback() sulla connessione.

Tutte le chiamate condividono un'unica connessione nativa per database. Finché una transazione è aperta, ogni altro code che scrive sullo stesso database verrà eseguito al suo interno. Mantieni le transazioni brevi ed evita di attendere richieste di rete al loro interno.

Memorizzazione di dati binari

Passa un Uint8Array e otterrai un Uint8Array in risposta. Il plugin effettua la base64-encoding per il trasporto e memorizza un vero 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;

Conserva i file multimediali pesanti (foto, video) come file e salva solo il percorso in SQLite. I BLOB vanno bene per miniature, piccoli allegati ed embedding.

Crittografare il database

Fast SQL supporta SQLCipher su iOS e Android. È opzionale, quindi le app che non ne hanno bisogno non lo includono:

const db = await FastSQL.connect({
  database: 'vault',
  encrypted: true,
  encryptionKey: keyFromSecureStorage,
});

Su Android aggiungi net.zetetic:sqlcipher-android alla tua app build.gradledella tua app. Su iOS, passa il pod alla CapgoCapacitorFastSql/SQLCipher subspec. La generazione della chiave, la memorizzazione nel Keychain o nel Keystore, la crittografia di un database esistente in chiaro e la rotazione delle chiavi sono trattate passo dopo passo in Come crittografare un database SQLite in Capacitor. La crittografia non è disponibile sul web.

Supporto web

Sul web, Fast SQL usa la build ufficiale @sqlite.org/sqlite-wasm e salva il file nell'Origin Private File System (OPFS). È un vero file SQLite, non una copia tenuta in memoria e riversata su IndexedDB.

L'OPFS richiede l'isolamento cross-origin, quindi il tuo dev server e l'host di produzione devono inviare due header:

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

Con 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'],
  },
});

Se gli header mancano, il plugin logga un warning e passa a un database non persistente: tutto funziona finché non ricarichi. CapgoCapacitorFastSql.configureWeb({ useOpfs, worker }) ti permette di cambiare i default prima della prima connect(), ed è un no-op su native.

require-corp blocca anche immagini e script cross-origin che non inviano header CORP o CORS, quindi testa gli embed di terze parti quando lo attivi.

Dove risiede il file del database

Piattaforma Posizione Note
iOS Documents/<name>.db Incluso in iCloud e nei backup del dispositivo di default
Android files/<name>.db nello storage interno dell'app Soggetto alle regole di Android Auto Backup
Web OPFS per l'origine della pagina Eliminato se l'utente cancella i dati del sito

Due conseguenze pratiche. Primo: disinstallare l'app elimina il file su entrambe le piattaforme mobile. Secondo: i backup possono ripristinare il file del database su un nuovo dispositivo. Di solito è quello che vuoi, ma se il database è cifrato con una chiave che non è inclusa nel backup, il file ripristinato non può essere aperto. La guida alla crittografia spiega come gestirlo.

Checklist per le performance

  • Attiva walMode. I lettori non bloccano più lo scrittore.
  • Esegui scritture batch in una transazione con executeBatch. Questo è il vantaggio principale per import e sync.
  • Aggiungi indici per le colonne su cui filtri o ordini, poi verifica con EXPLAIN QUERY PLAN.
  • Seleziona solo le colonne necessarie. Colonne TEXT e BLOB grandi aumentano i tempi di trasferimento anche via HTTP.
  • Usa la paginazione con WHERE created_at < ? ORDER BY created_at DESC LIMIT 50 invece di grandi OFFSET valori.
  • Dopo aver eliminato molti dati, esegui VACUUM ogni tanto per ridurre le dimensioni del file. Riscrive l'intero database, quindi fallo quando il dispositivo è inattivo.

Risoluzione problemi

SQL execution failed su iOS subito dopo la connessione. La richiesta HTTP locale è stata bloccata. Verifica che NSAllowsLocalNetworking sia nel corretto Info.plist e ricostruisci l'app nativa; un live reload non è sufficiente.

CLEARTEXT communication to localhost not permitted su Android. La configurazione di network security è mancante o non è referenziata nel manifest.

È stata eseguita solo la prima istruzione. Hai passato diverse istruzioni in una singola stringa. Dividile o usa executeBatch.

no such table dopo un aggiornamento. La migrazione non è stata eseguita o è stata applicata a un database con un nome diverso. Log PRAGMA user_version all'avvio e confrontali con il tuo array di step.

Un filtro su un valore booleano o calcolato non funziona mai su Android. Per le query di lettura, l'implementazione Android lega i parametri come testo. I confronti con una colonna con affinity INTEGER funzionano ancora perché SQLite converte il testo, ma i confronti con espressioni (ad esempio json_extract(...) = ? o count(*) > ?) confrontano testo con un numero. Passa 1/0 invece di true/false, e usa CAST(? AS INTEGER) quando confronti con un'espressione.

INSERT ... RETURNING non restituisce righe su Android. Nell'attuale versione del plugin, Android restituisce righe solo per le istruzioni che iniziano con SELECT, PRAGMA o EXPLAIN. Usa insertId da run() e selezionare la riga in seguito. Lo stesso vale per le query che iniziano con WITH: racchiudile in modo che inizino con SELECT, o testale subito su Android.

I dati scompaiono al ricaricamento nel browser. OPFS non è disponibile, quasi sempre perché mancano gli header COOP/COEP sull'host di deployment.

Encryption is not available in this build su iOS. Hai passato encrypted: true senza passare alla subspec di SQLCipher.

Approfondimenti

SQL raw va bene per app piccole. Quando hai più di qualche tabella, un layer tipizzato aiuta:

Il riferimento al plugin si trova nella Docs Fast SQL e il pagina del pluginSQL veloce richiede un Native Build quando lo aggiungi o ne cambi la configurazione. Se non vuoi gestire un Mac per questo, Build Capgo puoi generare i binari firmati per iOS e Android nel cloud. Da aprile 2026 gli upload su App Store dovranno usare Xcode 26 o successivi, quindi controlla la tua build machine prima del prossimo rilascio.

Live updates per le app Capacitor

Quando un bug nel web-layer è live, rilascia la fix tramite Capgo invece di aspettare giorni l'approvazione degli store. Gli utenti ricevono l'update in background, mentre le modifiche native restano nel normale processo di review.

Ultimi articoli dal blog

Capgo ti offre gli insight migliori che ti servono per creare un'app mobile davvero professionale.