Back to Blog

Using SQLite with Expo: React Native Guide

JayJay

SQLite is the default choice for local data storage in React Native apps. It's fast, requires no server, and persists data across app restarts. Expo's built-in expo-sqlite package makes it straightforward to set up.

This guide covers expo-sqlite from basic setup through Drizzle ORM integration. By the end, you'll have a working local database with type-safe queries.

Why SQLite for React Native

React Native apps often need local data storage for:

  • Offline-first functionality. Users expect apps to work without an internet connection. SQLite stores data locally and syncs when connectivity returns.
  • Fast reads. Querying a local SQLite database is orders of magnitude faster than making a network request.
  • Structured data. AsyncStorage works for key-value pairs, but once you need relationships, filtering, or sorting, you need a real database.
  • Data that stays on device. Health data, financial records, or notes that shouldn't leave the user's phone.

SQLite handles all of these without adding a server dependency.

Getting started with expo-sqlite

Installation

BASH
npx expo install expo-sqlite

That's it. No native module linking required. expo-sqlite works in Expo Go and in custom dev builds.

Setting up the provider

Wrap your app with SQLiteProvider to make the database available throughout your component tree:

TSX
import { SQLiteProvider } from 'expo-sqlite';

export default function App() {
  return (
    <SQLiteProvider
      databaseName="app.db"
      onInit={initializeDatabase}
    >
      <MainApp />
    </SQLiteProvider>
  );
}

async function initializeDatabase(db) {
  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS contacts (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      email TEXT,
      phone TEXT,
      created_at TEXT DEFAULT (datetime('now'))
    );
  `);
}

The onInit callback runs when the database is first opened. Use it to create tables and run migrations.

Accessing the database

Use the useSQLiteContext hook in any component:

TSX
import { useSQLiteContext } from 'expo-sqlite';

function ContactList() {
  const db = useSQLiteContext();
  const [contacts, setContacts] = useState([]);

  useEffect(() => {
    loadContacts();
  }, []);

  async function loadContacts() {
    const result = await db.getAllAsync(
      'SELECT * FROM contacts ORDER BY name'
    );
    setContacts(result);
  }

  return (
    <FlatList
      data={contacts}
      renderItem={({ item }) => (
        <Text>{item.name} - {item.email}</Text>
      )}
      keyExtractor={(item) => item.id.toString()}
    />
  );
}

CRUD operations

Insert

TSX
async function addContact(db, name, email, phone) {
  const result = await db.runAsync(
    'INSERT INTO contacts (name, email, phone) VALUES (?, ?, ?)',
    [name, email, phone]
  );
  return result.lastInsertRowId;
}

Always use parameterized queries (the ? placeholders). Never interpolate user input into SQL strings. This prevents SQL injection and handles escaping automatically.

Read

TSX
// Get all rows
const contacts = await db.getAllAsync(
  'SELECT * FROM contacts WHERE name LIKE ?',
  [`%${searchTerm}%`]
);

// Get a single row
const contact = await db.getFirstAsync(
  'SELECT * FROM contacts WHERE id = ?',
  [contactId]
);

getAllAsync returns an array of objects. getFirstAsync returns a single object or null.

Update

TSX
async function updateContact(db, id, name, email, phone) {
  await db.runAsync(
    'UPDATE contacts SET name = ?, email = ?, phone = ? WHERE id = ?',
    [name, email, phone, id]
  );
}

Delete

TSX
async function deleteContact(db, id) {
  await db.runAsync(
    'DELETE FROM contacts WHERE id = ?',
    [id]
  );
}

Transactions

When you need to run multiple operations atomically, use transactions:

TSX
async function transferData(db) {
  await db.withTransactionAsync(async () => {
    await db.runAsync(
      'INSERT INTO archive (name, email) SELECT name, email FROM contacts WHERE archived = 1'
    );
    await db.runAsync(
      'DELETE FROM contacts WHERE archived = 1'
    );
  });
}

If any statement in the transaction fails, all changes are rolled back. This is critical for operations like moving data between tables or updating related records.

Migrations

As your app evolves, you'll need to change the database schema. A simple versioning approach works well:

TSX
async function initializeDatabase(db) {
  // Create a version tracking table
  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS db_version (
      version INTEGER PRIMARY KEY
    );
  `);

  const row = await db.getFirstAsync(
    'SELECT version FROM db_version LIMIT 1'
  );
  const currentVersion = row?.version ?? 0;

  if (currentVersion < 1) {
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS contacts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT,
        created_at TEXT DEFAULT (datetime('now'))
      );
      INSERT OR REPLACE INTO db_version (version) VALUES (1);
    `);
  }

  if (currentVersion < 2) {
    await db.execAsync(`
      ALTER TABLE contacts ADD COLUMN phone TEXT;
      ALTER TABLE contacts ADD COLUMN notes TEXT;
      UPDATE db_version SET version = 2;
    `);
  }

  if (currentVersion < 3) {
    await db.execAsync(`
      CREATE INDEX IF NOT EXISTS idx_contacts_name
      ON contacts (name);
      UPDATE db_version SET version = 3;
    `);
  }
}

Each migration block runs only once. New users get all migrations in sequence. Existing users skip the ones they've already applied.

Using Drizzle ORM with expo-sqlite

Writing raw SQL works, but as your app grows, you'll want type safety and a better developer experience. Drizzle ORM integrates with expo-sqlite and gives you type-safe queries with minimal overhead.

Installation

BASH
npm install drizzle-orm
npm install -D drizzle-kit

Define your schema

Create a schema file that defines your tables:

TSX
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const contacts = sqliteTable('contacts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email'),
  phone: text('phone'),
  notes: text('notes'),
  createdAt: text('created_at').default('(datetime(\'now\'))'),
});

export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;

Set up Drizzle with expo-sqlite

TSX
// src/db/index.ts
import { drizzle } from 'drizzle-orm/expo-sqlite';
import { useSQLiteContext } from 'expo-sqlite';
import * as schema from './schema';

export function useDatabase() {
  const expoDb = useSQLiteContext();
  return drizzle(expoDb, { schema });
}

Type-safe queries

TSX
import { useDatabase } from '@/src/db';
import { contacts } from '@/src/db/schema';
import { eq, like } from 'drizzle-orm';

function ContactList() {
  const db = useDatabase();
  const [data, setData] = useState<Contact[]>([]);

  async function loadContacts() {
    const result = await db.select().from(contacts);
    setData(result);
  }

  async function searchContacts(term: string) {
    const result = await db
      .select()
      .from(contacts)
      .where(like(contacts.name, `%${term}%`));
    setData(result);
  }

  async function addContact(newContact: NewContact) {
    await db.insert(contacts).values(newContact);
    loadContacts();
  }

  async function removeContact(id: number) {
    await db.delete(contacts).where(eq(contacts.id, id));
    loadContacts();
  }

  // ...
}

Every query is fully typed. If you rename a column in your schema, TypeScript catches every place that references it.

Live queries

Drizzle's useLiveQuery hook re-runs queries when data changes:

TSX
import { useLiveQuery } from 'drizzle-orm/expo-sqlite';

function ContactList() {
  const db = useDatabase();

  const { data: contactList } = useLiveQuery(
    db.select().from(contacts)
  );

  return (
    <FlatList
      data={contactList}
      renderItem={({ item }) => <ContactRow contact={item} />}
      keyExtractor={(item) => item.id.toString()}
    />
  );
}

When you insert, update, or delete a record, any useLiveQuery watching that table updates automatically. No manual refresh needed.

For this to work, enable change listeners in your SQLite provider:

TSX
<SQLiteProvider
  databaseName="app.db"
  options={{ enableChangeListener: true }}
  onInit={initializeDatabase}
>

Offline-first patterns

Sync queue

For apps that need to sync with a server, a common pattern is to queue changes locally and sync when online:

TSX
// Schema addition
export const syncQueue = sqliteTable('sync_queue', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  tableName: text('table_name').notNull(),
  recordId: integer('record_id').notNull(),
  action: text('action').notNull(), // 'insert', 'update', 'delete'
  payload: text('payload').notNull(), // JSON string
  createdAt: text('created_at').default('(datetime(\'now\'))'),
  synced: integer('synced').default(0),
});
TSX
async function addContactOfflineFirst(db, contact) {
  // 1. Save locally
  const result = await db.insert(contacts).values(contact);

  // 2. Queue for sync
  await db.insert(syncQueue).values({
    tableName: 'contacts',
    recordId: result.lastInsertRowId,
    action: 'insert',
    payload: JSON.stringify(contact),
  });

  // 3. Try to sync immediately if online
  syncIfOnline();
}

Checking connectivity

TSX
import NetInfo from '@react-native-community/netinfo';

async function syncIfOnline() {
  const state = await NetInfo.fetch();
  if (!state.isConnected) return;

  const pending = await db
    .select()
    .from(syncQueue)
    .where(eq(syncQueue.synced, 0));

  for (const item of pending) {
    try {
      await sendToServer(item);
      await db
        .update(syncQueue)
        .set({ synced: 1 })
        .where(eq(syncQueue.id, item.id));
    } catch (error) {
      // Will retry on next sync attempt
      break;
    }
  }
}

Performance tips

Index your queries

If you're searching or filtering on a column, add an index:

SQL
CREATE INDEX idx_contacts_name ON contacts (name);
CREATE INDEX idx_contacts_email ON contacts (email);

Without indexes, SQLite scans every row. With indexes on a table of 10,000 contacts, lookups go from milliseconds to microseconds.

Batch inserts

Inserting rows one at a time is slow. Wrap bulk inserts in a transaction:

TSX
async function importContacts(db, contactList) {
  await db.withTransactionAsync(async () => {
    for (const contact of contactList) {
      await db.runAsync(
        'INSERT INTO contacts (name, email, phone) VALUES (?, ?, ?)',
        [contact.name, contact.email, contact.phone]
      );
    }
  });
}

A transaction turns 1,000 individual disk writes into a single write. The difference is dramatic: 1,000 individual inserts might take 5 seconds. The same 1,000 inserts in a transaction take under 100 milliseconds.

Keep the database small

SQLite on mobile devices should stay reasonable in size. A few hundred megabytes is fine. Gigabytes will cause performance issues and storage warnings.

For large datasets, consider storing only what the user needs locally and fetching the rest from your API.

Use WAL mode

WAL (Write-Ahead Logging) mode improves concurrent read/write performance:

TSX
async function initializeDatabase(db) {
  await db.execAsync('PRAGMA journal_mode = WAL;');
  // ... create tables
}

WAL mode lets reads and writes happen simultaneously, which matters in React Native where background operations might write data while the UI is reading it.

Common mistakes

Forgetting to close the database. If your app opens multiple database connections without closing them, you'll hit file locking issues. The SQLiteProvider pattern handles this for you.

Storing images in SQLite. Store file paths in SQLite and keep actual images in the file system. BLOBs in SQLite bloat the database and slow down queries that don't even need the image data.

Not handling schema changes. If you ship a new version with a changed schema and don't have migrations, existing users will crash. Always use the migration pattern described above.

Running heavy queries on the UI thread. Large queries should be async. The expo-sqlite async methods (getAllAsync, runAsync) handle this, but be aware of it if you're doing complex processing after the query returns.

Debugging your SQLite database

During development, you can inspect your SQLite database in several ways:

  • Drizzle Studio provides a visual interface for browsing tables and running queries. Import useDrizzleStudio from expo-drizzle-studio-plugin and pass your database instance.
  • Export the database file from your simulator/emulator and open it in a desktop tool like DB Pro for more advanced querying and schema inspection.
  • Use console.log with raw SQL queries during development to verify data.

When to use something else

SQLite isn't the right choice for every scenario:

  • Server-side data only. If your app is purely online with no offline needs, fetching from an API is simpler than maintaining a local database.
  • Real-time collaboration. Multiple users editing the same data simultaneously needs a server-side database. SQLite is single-user.
  • Sensitive data with compliance requirements. SQLite databases on device can be extracted. For highly sensitive data, consider SQLCipher (encrypted SQLite) or server-side storage.

Bottom line

expo-sqlite gives React Native apps a local database with minimal setup. Start with raw SQL queries using useSQLiteContext, and add Drizzle ORM when you want type safety and a better developer experience.

For offline-first apps, the sync queue pattern keeps data flowing between the local database and your server. For simpler apps, SQLite as a local cache for API data improves perceived performance and lets the app work without connectivity.

The combination of Expo, SQLite, and Drizzle covers most local data needs in React Native. Start simple, add complexity as your app requires it.

Keep Reading