Local relational data, migrations, and typed queries. expo-sqlite is the default relational offline store for Expo SDK 57 - inspections, job checklists, draft forms with foreign keys, and large cached datasets that do not fit AsyncStorage JSON blobs.
// src/db/migrate.tsimport type { SQLiteDatabase } from "expo-sqlite";const DATABASE_VERSION = 1;export async function migrateDbIfNeeded(db: SQLiteDatabase) { const { user_version: current } = await db.getFirstAsync<{ user_version: number }>( "PRAGMA user_version" ); if (current >= DATABASE_VERSION) return; if (current === 0) { await db.execAsync(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE inspections ( id TEXT PRIMARY KEY NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', updated_at INTEGER NOT NULL ); `); } await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);}
// app/_layout.tsx (excerpt)import { SQLiteProvider } from "expo-sqlite";import { migrateDbIfNeeded } from "@/db/migrate";<SQLiteProvider databaseName="field.db" onInit={migrateDbIfNeeded}> <Stack /></SQLiteProvider>
// src/db/inspections.tsimport type { SQLiteDatabase } from "expo-sqlite";export type Inspection = { id: string; title: string; status: "draft" | "submitted"; updated_at: number;};export async function listInspections(db: SQLiteDatabase): Promise<Inspection[]> { return db.getAllAsync<Inspection>( "SELECT id, title, status, updated_at FROM inspections ORDER BY updated_at DESC" );}export async function upsertInspection(db: SQLiteDatabase, row: Inspection) { await db.runAsync( `INSERT INTO inspections (id, title, status, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, status = excluded.status, updated_at = excluded.updated_at`, row.id, row.title, row.status, row.updated_at );}
When to reach for this:
Relational models with filters, sorts, and joins (inspections → photos → answers)
Offline-first field apps with thousands of rows
Replacing multi-megabyte AsyncStorage JSON arrays
Typed local cache when TanStack Query persistence outgrows KV limits
// app/_layout.tsximport { Stack } from "expo-router";import { SQLiteProvider, useSQLiteContext } from "expo-sqlite";import { migrateDbIfNeeded } from "@/db/migrate";export default function RootLayout() { return ( <SQLiteProvider databaseName="field.db" onInit={migrateDbIfNeeded}> <Stack /> </SQLiteProvider> );}
// src/db/migrate.tsimport type { SQLiteDatabase } from "expo-sqlite";const DATABASE_VERSION = 2;export async function migrateDbIfNeeded(db: SQLiteDatabase) { let { user_version: version } = await db.getFirstAsync<{ user_version: number }>( "PRAGMA user_version" ); if (version >= DATABASE_VERSION) return; if (version === 0) { await db.execAsync(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE inspections ( id TEXT PRIMARY KEY NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', updated_at INTEGER NOT NULL ); CREATE TABLE answers ( id TEXT PRIMARY KEY NOT NULL, inspection_id TEXT NOT NULL REFERENCES inspections(id) ON DELETE CASCADE, prompt TEXT NOT NULL, value TEXT, updated_at INTEGER NOT NULL ); CREATE INDEX idx_answers_inspection ON answers(inspection_id); `); version = 1; await db.execAsync("PRAGMA user_version = 1"); } if (version === 1) { await db.execAsync(` ALTER TABLE inspections ADD COLUMN synced INTEGER NOT NULL DEFAULT 0; `); version = 2; await db.execAsync("PRAGMA user_version = 2"); }}
// src/db/inspections.ts - typed queries with tagged template APIimport type { SQLiteDatabase } from "expo-sqlite";export type Inspection = { id: string; title: string; status: string; synced: number; updated_at: number;};export async function listUnsynced(db: SQLiteDatabase) { const sql = db.sql; return sql<Inspection>`SELECT * FROM inspections WHERE synced = 0 ORDER BY updated_at ASC`;}export async function markSynced(db: SQLiteDatabase, id: string) { await db.runAsync("UPDATE inspections SET synced = 1 WHERE id = ?", id);}
SQLite stores a monotonic integer user_version in the database file. Expo's recommended pattern mirrors AsyncStorage schema versioning:
onInit (SQLiteProvider) or first openDatabaseAsync:1. PRAGMA user_version → current2. if current < TARGET: run SQL for current → current+1 PRAGMA user_version = current+13. repeat until current === TARGET
journal_mode = WAL improves concurrent read/write - set on v0 creation
foreign_keys = ON must be enabled per connection - include in every fresh DB bootstrap
Destructive migrations (drop column) often need table rebuild - plan downtime banner for field users
Interleaved transactions - withTransactionAsync includes any concurrent query until the scope finishes. Fix: Use withExclusiveTransactionAsync for multi-statement writes.
Running migrations in every screen - Race conditions on parallel onInit. Fix: Single SQLiteProvider at root; migrations only in onInit.
Storing blobs in SQLite without need - Large photos belong on disk (expo-file-system); store paths in SQLite. Fix: BLOB column only for small binary payloads.
Assuming Expo Go covers custom SQLCipher - useSQLCipher needs a dev build and config plugin. Fix: Default SQLite for most apps; encrypt only when compliance requires it.
Web alpha limitations - expo-sqlite on web needs Metro wasm + COOP/COEP headers. Fix: Use AsyncStorage or server cache on web until web SQLite is in scope.
No indexes on filter columns - WHERE synced = 0 scans full tables at scale. Fix: Index foreign keys and sync flags early.
Works in Expo Go for default SQLite. SQLCipher and custom build flags require a development build with the config plugin.
Where should the database file live?
SQLiteProvider and openDatabaseAsync use the app's default database directory automatically. On iOS TV, files land in caches per platform guidelines - do not assume documents directory on all targets.
How do I inspect the database during development?
Press Shift + M in the Expo CLI terminal → Open expo-sqlite to browse tables, run SQL, and export the DB from your browser.
Can I share a database with an iOS extension?
Yes - configure an App Group in app.config.ts, then pass directory from Paths.appleSharedContainers to SQLiteProvider. See Expo SQLite docs for entitlements.
How do I type query results?
Use generics on getAllAsync<Inspection>, db.sql<Inspection>, or prepared statement executeAsync<Inspection>. Define row types in src/db/types.ts shared with API mappers.
Should I use Drizzle ORM?
Drizzle + expo-sqlite is a popular combo for typed schemas and migrations. Raw SQL is fine for small apps - adopt ORM when table count exceeds ~8 or multiple engineers touch schema.
How does SQLite interact with TanStack Query?
Use SQLite as the source of truth for offline-created rows; Query caches server snapshots. On reconnect, flush SQLite synced = 0 rows then invalidateQueries. See ../state-management/tanstack-query/tanstack-query.md.
What about corruption recovery?
On migration failure, quarantine the DB file (rename with timestamp), create a fresh database, and trigger a server re-sync. Log user_version and schema hash to telemetry for support.