Expo SQLite vs WatermelonDB vs Realm 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 | No | Yes (core feature) | Yes |
| React hooks | Yes (useSQLiteContext) | Yes (withObservables) | Yes (useQuery) |
| Change listener | Yes (addListener) | Yes (Observable) | Yes (Observable) |
| Relationships | Yes (SQL JOINs) | Yes (children/belongs_to) | Yes (Object links) |
| Migrations | Yes (Manual SQL) | Yes (Schema migrations) | Yes (Schema versioning) |
| Full-text search | Yes (FTS5) | No | Yes |
| Cloud sync | No | Yes (custom protocol) | Yes (Atlas Device Sync, paid) |
| Expo Go | Yes | No (bare required) | No (bare required) |
| TypeScript models | Yes (generic types) | Yes (Decorators) | Yes (Realm.Object) |
| Performance (100k+) | Loads all | Excellent | Excellent |
| npm weekly | ~500k | ~100k | ~150k |
Ecosystem and Community
Expo SQLite benefits from being part of the official Expo ecosystem. The documentation is maintained by the Expo team and stays current with every Expo SDK release. When breaking changes occur in React Native or the underlying SQLite implementation, the Expo team handles the compatibility layer. The Expo Discord is the best place for support, and issues on the expo/expo GitHub repository receive quick responses. The v14 release with live queries and WAL mode support made it a first-class database option rather than just a convenient utility.
WatermelonDB has a dedicated community built around the Nozbe company, which uses it to power their own productivity apps in production. The GitHub repository is actively maintained, and the documentation covers advanced topics like sync protocol design, performance optimization, and schema migrations. The key community resource is the WatermelonDB GitHub Discussions board, where the maintainer Radek Pietruszewski has written extensively about database architecture patterns for React Native. The library's maturity is evident — it has been in production for years and handles large datasets at scale without surprises.
Realm's community was historically large and active, driven by MongoDB's resources after their acquisition. The @realm/react hooks package brought a modern developer experience, and the Atlas Device Sync integration made Realm the most capable cloud sync solution for React Native. The community is somewhat smaller in 2026 following MongoDB's shift in focus toward Atlas as the primary product, but the library itself is stable, well-documented, and actively maintained.
Real-World Adoption
Expo SQLite is the most commonly used local database in Expo-managed workflow projects. For apps in the Expo ecosystem building anything from a local workout tracker to a travel journal, expo-sqlite provides everything needed without leaving the managed workflow or dealing with native module installation. The live query feature in v14 makes it viable for apps that previously needed WatermelonDB for reactive UI updates. For React Native navigation patterns that work alongside these databases, see Expo Router vs React Navigation vs Solito.
WatermelonDB has strong adoption in productivity apps — note-taking apps, CRM tools, project management utilities — where the dataset grows over time and performance under hundreds of thousands of records matters. The Nozbe apps (Nozbe Personal, Nozbe Teams) are the primary showcase, demonstrating that WatermelonDB handles serious production workloads. The lazy evaluation model means that an app with 50,000 notes only loads the notes visible on screen, not the entire dataset.
Realm sees its strongest adoption in consumer apps with multi-device sync requirements and in enterprise apps connected to MongoDB Atlas. Mobile apps where users expect their data to appear on all their devices — a recipe app, a budget tracker, a habit builder — and where building custom sync infrastructure isn't viable, find Atlas Device Sync's turnkey approach valuable. The cost tradeoff is Atlas pricing, which is consumption-based and can add up at scale.
Performance Benchmarks
Performance differences between these libraries matter primarily at scale. For typical mobile apps with fewer than 10,000 records, all three perform acceptably and the query execution time is well under 50ms. The differences emerge at larger scales and with more complex queries.
WatermelonDB's lazy evaluation architecture means querying a table with 500,000 records where you're displaying 20 on screen loads only those 20 records. Expo SQLite would execute the SQL query, process all matching rows in SQLite, and return them to JavaScript — loading all 500,000 records into memory would crash the app, but a well-indexed query returning 20 rows is fast. Realm's object-oriented model has a similar lazy-loading characteristic to WatermelonDB.
For write operations, WatermelonDB batches writes through a database.write() transaction, which amortizes the transaction overhead. Expo SQLite offers the same via withTransactionAsync. For bulk inserts, both perform similarly — the bottleneck is usually the JavaScript bridge, not SQLite itself. Realm's write performance is excellent because it bypasses the bridge with JSI in newer versions.
Migration and Getting Started
Starting with Expo SQLite is the lowest-friction path for a new Expo app. The setup is essentially a single npx expo install expo-sqlite and a schema initialization function. Teams coming from web development with SQLite or PostgreSQL experience will feel at home with the SQL API.
Migrating from Expo SQLite to WatermelonDB makes sense when queries start to feel slow (typically around 50,000+ records with complex filtering), when reactive UI updates require significant boilerplate with the change listener pattern, or when you need to implement sync with a backend. The migration requires rewriting models as WatermelonDB classes and converting SQL queries to WatermelonDB's query API, which takes meaningful developer time.
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.
Schema Migrations in Practice
All three databases support schema evolution, but the approaches differ significantly in how disruptive upgrades are for users.
Expo SQLite migrations are manual SQL scripts. You maintain a version number in a user_version PRAGMA and run sequential migration scripts on app startup. This is the same pattern used in server-side database migrations and is well-understood by developers familiar with tools like Flyway or Drizzle. The risk is that a bad migration on a user's device is difficult to roll back — the migration runs once, and if it fails partway through, you need careful error handling to avoid leaving the database in an inconsistent state. Always wrap migrations in transactions.
WatermelonDB's migration system is more structured. You define a migrations array that specifies each schema change as a named migration with explicit addTable, addColumns, and similar operations. WatermelonDB handles running the correct migrations for the current schema version and validates that migrations are complete. The downside is that WatermelonDB's migration DSL is not as flexible as raw SQL — complex data transformations require additional migration steps or writing custom migration functions.
Realm's schema versioning is the most automated of the three. You increment the schema version number, define a onMigration function if needed, and Realm handles running the migration on device. For simple additive changes (adding properties with default values), Realm can auto-migrate without any migration code. For property renames or type changes, you provide a migration callback. The schema validation at startup catches mismatches before any data corruption can occur.
Testing Local Databases in React Native
Testing code that relies on a local database requires special consideration in React Native. All three databases run on native SQLite under the hood, which means Jest's default jsdom environment doesn't support them.
For Expo SQLite, the expo-sqlite/build/ExpoSQLiteNext.web module can be used in tests with a web-compatible SQLite implementation. The recommended approach is to use Expo's testing utilities and run tests on a real device or simulator via expo-test-runner, or to mock the database module in Jest with a SQLite in-memory database using better-sqlite3 for faster unit tests.
WatermelonDB provides a @nozbe/watermelondb/adapters/memory adapter specifically for testing. This in-memory adapter runs entirely in JavaScript without native SQLite, making it compatible with Jest in a standard Node.js environment. Tests run quickly, and the adapter accurately reflects WatermelonDB's behavior for most operations.
Realm's testing story has improved with the @realm/testing package, which provides a way to run Realm in memory for Jest tests. The Realm JavaScript SDK includes a RealmProvider mock for component tests using React Testing Library.
Related: Best JavaScript Testing Frameworks 2026, Best Node.js Background Job Libraries 2026, Supabase vs Firebase vs Appwrite BaaS 2026
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.