Expo SQLite vs WatermelonDB vs Realm: React Native Local Databases 2026
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 queries —
useSQLiteContextre-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>
)}
/>
);
}
Full-Text Search
// 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
| Feature | Expo SQLite v14 | WatermelonDB | Realm |
|---|---|---|---|
| Query language | SQL | Watermelon Query (JS) | RealmQuery (JS) |
| Lazy loading | ❌ | ✅ (core feature) | ✅ |
| React hooks | ✅ useSQLiteContext | ✅ withObservables | ✅ useQuery |
| Change listener | ✅ addListener | ✅ Observable | ✅ Observable |
| Relationships | ✅ SQL JOINs | ✅ children/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) | ✅ Decorators | ✅ Realm.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,@textdecorators - 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.