Skip to main content

Expo SQLite vs WatermelonDB vs Realm: React Native Local Databases 2026

·PkgPulse Team

Expo SQLite vs WatermelonDB vs Realm: React Native Local Databases 2026

TL;DR

Local databases in React Native go beyond key-value storage — relational data, complex queries, offline-first sync, and performance under tens of thousands of records. Expo SQLite (expo-sqlite v14+) is the simplest relational option — built-in with the Expo SDK, standard SQL queries, WAL mode for performance, and live queries that re-render React components when data changes; ideal for apps that need SQL without the complexity of an ORM. WatermelonDB is purpose-built for performance at scale — lazy evaluation means only queried records load into memory, React component integration with withObservables, and optional sync infrastructure for backend synchronization; designed for productivity apps with thousands of records. Realm (MongoDB Atlas Device Sync) is the object database with optional cloud sync — objects-not-tables mental model, reactive queries, and Atlas Device Sync for multi-device offline-first sync; strongest when cloud sync is a requirement. For Expo apps needing SQL: Expo SQLite. For high-performance local queries on large datasets: WatermelonDB. For offline-first with cloud sync: Realm.

Key Takeaways

  • Expo SQLite v14 has live queriesuseSQLiteContext re-renders on data changes
  • WatermelonDB lazy loads — only fetches records you query, not entire tables
  • Realm uses objects — no SQL; realm.write() to mutate objects directly
  • WatermelonDB requires a separate sync backend — custom sync protocol or use @nozbe/watermelondb-sync
  • Realm Atlas Device Sync — automatic multi-device sync through MongoDB Atlas (paid)
  • Expo SQLite supports WAL mode — write-ahead logging for concurrent reads + writes
  • All three are offline-first — data persists on device without network

Use Case Guide

Expo app, small-medium dataset        → Expo SQLite (zero config)
Productivity app (notes, todos)       → WatermelonDB (great DX + scale)
Large dataset (100k+ records)         → WatermelonDB (lazy evaluation)
Multi-device sync                     → Realm (Atlas Device Sync) or WatermelonDB + custom
Complex relational queries (JOINs)    → Expo SQLite (full SQL)
Reactive components                   → All three (different APIs)
TypeScript model definitions          → All three (Realm best typing)
No Expo (bare RN)                     → WatermelonDB or Realm

Expo SQLite (expo-sqlite v14+)

The standard SQL database built into the Expo SDK — no additional installation required, WAL mode, and React hooks for live queries.

Installation

npx expo install expo-sqlite

Database Setup and Schema

// lib/database.ts
import * as SQLite from "expo-sqlite";

// Open database (creates if not exists)
export const db = SQLite.openDatabaseSync("myapp.db");

// Initialize schema (run on app start)
export async function initDatabase() {
  // Enable WAL mode for better concurrent performance
  await db.execAsync("PRAGMA journal_mode = WAL;");
  await db.execAsync("PRAGMA foreign_keys = ON;");

  // Create tables
  await db.execAsync(`
    CREATE TABLE IF NOT EXISTS notes (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      content TEXT,
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at TEXT NOT NULL DEFAULT (datetime('now')),
      is_archived INTEGER NOT NULL DEFAULT 0
    );

    CREATE TABLE IF NOT EXISTS tags (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL UNIQUE
    );

    CREATE TABLE IF NOT EXISTS note_tags (
      note_id INTEGER REFERENCES notes(id) ON DELETE CASCADE,
      tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
      PRIMARY KEY (note_id, tag_id)
    );

    CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at);
    CREATE INDEX IF NOT EXISTS idx_notes_archived ON notes(is_archived);
  `);
}

CRUD Operations

import { db } from "./database";

interface Note {
  id: number;
  title: string;
  content: string | null;
  created_at: string;
  updated_at: string;
  is_archived: boolean;
}

// Insert
export async function createNote(title: string, content: string): Promise<number> {
  const result = await db.runAsync(
    "INSERT INTO notes (title, content) VALUES (?, ?)",
    [title, content]
  );
  return result.lastInsertRowId;
}

// Select all
export async function getAllNotes(): Promise<Note[]> {
  return await db.getAllAsync<Note>(
    "SELECT * FROM notes WHERE is_archived = 0 ORDER BY updated_at DESC"
  );
}

// Select one
export async function getNoteById(id: number): Promise<Note | null> {
  return await db.getFirstAsync<Note>(
    "SELECT * FROM notes WHERE id = ?",
    [id]
  );
}

// Update
export async function updateNote(id: number, title: string, content: string): Promise<void> {
  await db.runAsync(
    "UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?",
    [title, content, id]
  );
}

// Delete
export async function deleteNote(id: number): Promise<void> {
  await db.runAsync("DELETE FROM notes WHERE id = ?", [id]);
}

// Transaction — batch operations
export async function archiveAllNotes(): Promise<void> {
  await db.withTransactionAsync(async () => {
    await db.runAsync("UPDATE notes SET is_archived = 1");
    await db.runAsync("UPDATE notes SET updated_at = datetime('now')");
  });
}

Live Queries (React Hooks)

// components/NotesList.tsx
import { useSQLiteContext } from "expo-sqlite";
import { SQLiteProvider } from "expo-sqlite";

interface Note {
  id: number;
  title: string;
  updated_at: string;
}

// Wrap with SQLiteProvider in your app root
export function AppRoot() {
  return (
    <SQLiteProvider databaseName="myapp.db" onInit={initDatabase}>
      <App />
    </SQLiteProvider>
  );
}

// In any component: live query that re-renders on data changes
function NotesList() {
  const db = useSQLiteContext();

  // useSQLiteContext + useEffect for live queries
  const [notes, setNotes] = useState<Note[]>([]);

  useEffect(() => {
    async function loadNotes() {
      const result = await db.getAllAsync<Note>(
        "SELECT id, title, updated_at FROM notes WHERE is_archived = 0 ORDER BY updated_at DESC"
      );
      setNotes(result);
    }

    loadNotes();

    // Subscribe to changes (Expo SQLite v14 change listener)
    const subscription = db.addListener("change", ({ tableName }) => {
      if (tableName === "notes") {
        loadNotes();
      }
    });

    return () => subscription.remove();
  }, [db]);

  return (
    <FlatList
      data={notes}
      keyExtractor={(item) => String(item.id)}
      renderItem={({ item }) => (
        <TouchableOpacity onPress={() => navigateTo(item.id)}>
          <Text>{item.title}</Text>
          <Text>{item.updated_at}</Text>
        </TouchableOpacity>
      )}
    />
  );
}
// Enable FTS5 full-text search
await db.execAsync(`
  CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
    title, content,
    content='notes',
    content_rowid='id'
  );
`);

// Search
async function searchNotes(query: string): Promise<Note[]> {
  return await db.getAllAsync<Note>(`
    SELECT n.* FROM notes n
    JOIN notes_fts ON notes_fts.rowid = n.id
    WHERE notes_fts MATCH ?
    ORDER BY rank
  `, [query]);
}

WatermelonDB: High-Performance React Native Database

WatermelonDB uses lazy evaluation to avoid loading entire tables into memory — only records you actually render get fetched.

Installation

npm install @nozbe/watermelondb
npm install @nozbe/with-observables
npx pod-install  # iOS

Model Definitions

// models/Note.ts
import { Model, field, date, text, children } from "@nozbe/watermelondb";
import { tableSchema } from "@nozbe/watermelondb/Schema";

export class Note extends Model {
  static table = "notes";
  static associations = {
    tags: { type: "has_many" as const, foreignKey: "note_id" },
  };

  @text("title") title!: string;
  @text("content") content!: string;
  @field("is_archived") isArchived!: boolean;
  @date("created_at") createdAt!: Date;
  @date("updated_at") updatedAt!: Date;
  @children("tags") tags!: Query<Tag>;
}
// models/schema.ts
import { appSchema, tableSchema } from "@nozbe/watermelondb";

export const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: "notes",
      columns: [
        { name: "title", type: "string" },
        { name: "content", type: "string", isOptional: true },
        { name: "is_archived", type: "boolean" },
        { name: "created_at", type: "number" },
        { name: "updated_at", type: "number" },
      ],
    }),
  ],
});

Database Setup

// lib/database.ts
import { Database } from "@nozbe/watermelondb";
import SQLiteAdapter from "@nozbe/watermelondb/adapters/sqlite";
import { schema } from "../models/schema";
import { Note } from "../models/Note";

const adapter = new SQLiteAdapter({
  schema,
  migrations: [],   // Add migrations for schema changes
  jsi: true,        // JSI for better performance (requires Hermes)
  onSetUpError: (error) => {
    console.error("WatermelonDB setup error:", error);
  },
});

export const database = new Database({
  adapter,
  modelClasses: [Note],
});

CRUD Operations

// actions/noteActions.ts
import { database } from "../lib/database";
import { Note } from "../models/Note";

export async function createNote(title: string, content: string): Promise<Note> {
  return await database.write(async () => {
    return await database.get<Note>("notes").create((note) => {
      note.title = title;
      note.content = content;
      note.isArchived = false;
    });
  });
}

export async function updateNote(note: Note, updates: { title?: string; content?: string }): Promise<void> {
  await database.write(async () => {
    await note.update((n) => {
      if (updates.title !== undefined) n.title = updates.title;
      if (updates.content !== undefined) n.content = updates.content;
    });
  });
}

export async function deleteNote(note: Note): Promise<void> {
  await database.write(async () => {
    await note.markAsDeleted();  // Soft delete for sync compatibility
    // Or: await note.destroyPermanently();  // Hard delete
  });
}

Reactive Components with withObservables

import { withObservables } from "@nozbe/with-observables";
import { database } from "../lib/database";
import { Note } from "../models/Note";
import { Q } from "@nozbe/watermelondb";

// Pure component that renders a list of notes
function NoteListComponent({ notes }: { notes: Note[] }) {
  return (
    <FlatList
      data={notes}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <NoteItem note={item} />}
    />
  );
}

// Enhanced component — auto-subscribes to observable query
const NoteList = withObservables([""], () => ({
  notes: database
    .get<Note>("notes")
    .query(Q.where("is_archived", false), Q.sortBy("updated_at", Q.desc))
    .observe(),  // Returns an Observable — re-renders when data changes
}))(NoteListComponent);

// Individual note component — also reactive
function NoteItemComponent({ note }: { note: Note }) {
  return (
    <View>
      <Text>{note.title}</Text>
      <Text>{note.content}</Text>
    </View>
  );
}

const NoteItem = withObservables(["note"], ({ note }: { note: Note }) => ({
  note: note.observe(),  // Re-renders when this specific note changes
}))(NoteItemComponent);

Realm: Object Database with Optional Cloud Sync

Realm stores objects directly — no SQL, no tables-to-objects mapping. The Atlas Device Sync integration enables automatic multi-device sync.

Installation

npm install realm @realm/react
npx pod-install  # iOS

Schema Definition

// models/Note.ts
import Realm from "realm";

export class Note extends Realm.Object<Note> {
  _id!: Realm.BSON.ObjectId;
  title!: string;
  content!: string | null;
  isArchived!: boolean;
  createdAt!: Date;
  updatedAt!: Date;

  static schema: Realm.ObjectSchema = {
    name: "Note",
    primaryKey: "_id",
    properties: {
      _id: "objectId",
      title: "string",
      content: "string?",
      isArchived: { type: "bool", default: false },
      createdAt: "date",
      updatedAt: "date",
    },
  };
}

Setup (Local Mode)

// app/providers.tsx
import { RealmProvider } from "@realm/react";
import { Note } from "../models/Note";

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <RealmProvider schema={[Note]}>
      {children}
    </RealmProvider>
  );
}

CRUD Operations

import { useRealm, useQuery } from "@realm/react";
import Realm from "realm";
import { Note } from "../models/Note";

function NotesScreen() {
  const realm = useRealm();

  // Live query — re-renders when notes change
  const notes = useQuery(Note, (collection) =>
    collection.filtered("isArchived = false").sorted("updatedAt", true)
  );

  function createNote(title: string, content: string) {
    realm.write(() => {
      realm.create(Note, {
        _id: new Realm.BSON.ObjectId(),
        title,
        content,
        isArchived: false,
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    });
  }

  function deleteNote(note: Note) {
    realm.write(() => {
      realm.delete(note);
    });
  }

  function archiveNote(note: Note) {
    realm.write(() => {
      note.isArchived = true;
      note.updatedAt = new Date();
    });
  }

  return (
    <View>
      <Button title="Add Note" onPress={() => createNote("New Note", "")} />
      <FlatList
        data={notes}
        keyExtractor={(item) => item._id.toString()}
        renderItem={({ item }) => (
          <TouchableOpacity onLongPress={() => deleteNote(item)}>
            <Text>{item.title}</Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

Feature Comparison

FeatureExpo SQLite v14WatermelonDBRealm
Query languageSQLWatermelon Query (JS)RealmQuery (JS)
Lazy loading✅ (core feature)
React hooksuseSQLiteContextwithObservablesuseQuery
Change listeneraddListener✅ Observable✅ Observable
Relationships✅ SQL JOINschildren/belongs_to✅ Object links
Migrations✅ Manual SQL✅ Schema migrations✅ Schema versioning
Full-text search✅ FTS5✅ Full-text search
Cloud sync✅ (custom protocol)✅ Atlas Device Sync (paid)
Expo Go❌ (bare required)❌ (bare required)
TypeScript models✅ (generic types)✅ DecoratorsRealm.Object
Performance (100k+)⚠️ Loads all✅ Excellent✅ Excellent
npm weekly~500k~100k~150k

When to Use Each

Choose Expo SQLite if:

  • Expo managed workflow (works in Expo Go)
  • Familiar with SQL — complex queries, JOINs, aggregations
  • Small to medium datasets (< 50k records) where lazy loading isn't critical
  • No sync requirements — pure on-device storage
  • Full-text search with FTS5

Choose WatermelonDB if:

  • Large datasets (notes apps, CRMs, inventory) where lazy loading is critical
  • Reactive React components that subscribe to query changes
  • Custom sync backend — WatermelonDB's sync protocol works with any REST/GraphQL API
  • TypeScript model classes with @field, @text decorators
  • High performance is required without Atlas costs

Choose Realm if:

  • Multi-device offline-first sync via MongoDB Atlas Device Sync
  • Object-oriented data model (no SQL/tables) is preferred
  • Strong MongoDB ecosystem integration
  • Full-text search without custom SQL setup
  • Enterprise scenarios where Atlas's built-in auth and sync justify the cost

Methodology

Data sourced from Expo SQLite documentation (docs.expo.dev/versions/latest/sdk/sqlite), WatermelonDB documentation (watermelondb.dev), Realm (MongoDB Atlas Device SDK) documentation (mongodb.com/docs/realm/sdk/react-native), npm download statistics as of February 2026, GitHub star counts as of February 2026, and community discussions from the Expo Discord and r/reactnative.


Related: React Native MMKV vs AsyncStorage vs Expo SecureStore for key-value and secure storage that often complements a local database, or Expo EAS vs Fastlane vs Bitrise for CI/CD pipelines that build and test apps using these databases.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.