
Pglite
- 101 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
pglite is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- pglite
- AI & Agent Building
- AI-coding skill
Pglite by the numbers
- 101 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,322 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill pgliteAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 101 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
PGlite
Overview
PGlite is a lightweight WASM build of PostgreSQL 17.4 that runs directly in the browser, Node.js, and Bun with no external dependencies. It provides a full Postgres query engine with extensions, transactions, COPY support, and listen/notify in under 3MB gzipped.
When to use: Local-first apps needing a real SQL engine, browser-based analytics, offline-capable PWAs, embedded Postgres for testing, prototyping without a server.
When NOT to use: High-concurrency server workloads (use native Postgres), apps requiring full Postgres replication, Safari OPFS storage (not supported), write-heavy multi-tab scenarios without leader election.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Create instance | PGlite.create(dataDir?, options?) | Awaits ready internally |
| In-memory DB | PGlite.create() | Default, ephemeral storage |
| IndexedDB storage | PGlite.create('idb://dbname') | Persists in browser |
| OPFS storage | PGlite.create('opfs-ahp://dbname') | Worker only, no Safari |
| Parameterized query | db.query<T>(sql, params) | Returns QueryResult<T> |
| Tagged template | db.sql\SELECT ...\`` | Auto-parameterized |
| Multi-statement exec | db.exec(sqlString) | No params, returns void |
| Transaction | db.transaction(async (tx) => {}) | Full ACID, use tx.query |
| Load extension | extensions: { vector } in create | Loaded at construction |
| Live query | db.live.query(sql, params, cb) | Re-runs on data change |
| Incremental query | db.live.incrementalQuery(sql, params, key, cb) | Diffs large result sets |
| Live changes | db.live.changes(sql, params, key, cb) | Raw insert/update/delete ops |
| React hook | useLiveQuery(sql, params) | Returns { rows } reactively |
| Multi-tab worker | PGliteWorker + leader election | Single writer, multi reader |
| Electric sync | syncShapeToTable(db, shape, table) | One-way read-only sync |
| Dump data | db.dumpDataDir('gzip') | Returns File blob |
| Load data | PGlite.create({ loadDataDir: blob }) | Restore from dump |
| Listen/notify | db.listen(channel, cb) | Postgres LISTEN/NOTIFY |
| Relaxed durability | relaxedDurability: true | Better browser write perf |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using new PGlite() without awaiting ready | Use PGlite.create() which awaits internally |
| Using OPFS in main thread | OPFS-AHP requires a Web Worker context |
| Using OPFS on Safari | Safari lacks required OPFS APIs; use idb:// |
| Querying outside transaction callback | Pass tx from transaction callback, use tx.query() |
| Forgetting extension in create options | Extensions must be declared at PGlite.create({ extensions }) |
| Multiple tabs writing without leader election | Use PGliteWorker with leader election for multi-tab |
| Expecting Electric sync to be bidirectional | Electric sync is read-only, one-way from server |
Using live.query() for large datasets | Use live.incrementalQuery() for large result sets |
Not providing key to incremental query | Incremental and changes queries require a unique key column |
| Importing extensions without bundler support | Extensions are WASM; ensure bundler handles .wasm assets |
Delegation
If the electricsql skill is available, delegate Electric sync patterns, Shape configuration, and real-time replication to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s electricsql -a claude-code -y>
If the drizzle-orm skill is available, delegate Drizzle + PGlite driver setup and schema management to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s drizzle-orm -a claude-code -y>
If the local-first skill is available, delegate local-first architecture decisions and sync engine selection to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s local-first -a claude-code -yReferences
- Installation, storage backends, and configuration
- Queries, transactions, COPY, listen/notify, and data import/export
- Extensions: pgvector, pg_trgm, pgcrypto, full-text search, and more
- Live queries: reactive, incremental, and change-tracking
- React integration: providers, hooks, and typed patterns
- Multi-tab worker architecture and leader election
- Electric sync: shapes, transactional sync, and persistence
Electric Sync
Overview
The @electric-sql/pglite-sync package enables one-way, read-only sync from an Electric server to a local PGlite database. Data flows from the server Postgres to PGlite via Electric Shapes. Local writes to synced tables are not replicated back to the server.
Installation
npm install @electric-sql/pglite @electric-sql/pglite-syncSetup
Register the sync extension at construction time.
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: {
electric: electricSync(),
},
});syncShapeToTable
Sync a single Electric Shape into a local PGlite table.
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: {
electric: electricSync(),
},
});
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY,
task TEXT NOT NULL,
done BOOLEAN DEFAULT false
)
`);
const shape = await db.electric.syncShapeToTable({
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: {
table: 'todos',
},
},
table: 'todos',
primaryKey: ['id'],
});The shape object returned provides control over the sync:
shape.unsubscribe();
shape.isUpToDate;
shape.subscribe(() => {
console.log('Sync state changed, up to date:', shape.isUpToDate);
});Shape Options
| Option | Type | Description |
|---|---|---|
shape.url | string | Electric server shape endpoint URL |
shape.params.table | string | Source table name on the server |
shape.params.where | string | SQL WHERE clause to filter rows |
shape.params.columns | string[] | Subset of columns to sync |
table | string | Local PGlite table to sync into |
primaryKey | string[] | Primary key columns of the local table |
shapeKey | string | Persistence key for resuming sync |
Filtered Sync
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: { electric: electricSync() },
});
await db.electric.syncShapeToTable({
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: {
table: 'todos',
where: "user_id = '123'",
columns: ['id', 'task', 'done'],
},
},
table: 'todos',
primaryKey: ['id'],
});syncShapesToTables (Transactional)
Sync multiple shapes atomically. Changes across tables are applied in a single transaction.
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: { electric: electricSync() },
});
await db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY,
project_id UUID REFERENCES projects(id),
title TEXT NOT NULL
);
`);
const sync = await db.electric.syncShapesToTables({
shapes: {
projects: {
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: { table: 'projects' },
},
table: 'projects',
primaryKey: ['id'],
},
tasks: {
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: { table: 'tasks' },
},
table: 'tasks',
primaryKey: ['id'],
},
},
});Transactional sync ensures referential integrity across related tables.
shapeKey for Persistence
The shapeKey option enables sync resumption after page reloads. Without it, sync starts from scratch each time.
import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: { electric: electricSync() },
});
await db.electric.syncShapeToTable({
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: { table: 'todos' },
},
table: 'todos',
primaryKey: ['id'],
shapeKey: 'todos-sync',
});PGlite stores the sync cursor internally. On reload, it resumes from the last known position instead of re-fetching all data.
Limitations
- Read-only sync: Data flows one way from server to PGlite. Local writes are not replicated back.
- No conflict resolution: Since sync is one-way, server data overwrites local data.
- Schema must match: The local PGlite table schema must be compatible with the synced Shape columns.
- No DDL sync: Schema changes on the server are not automatically applied locally.
- Shape constraints: Shapes follow Electric's Shape API constraints (single table, optional WHERE filter).
Combining Sync with Live Queries
Synced data triggers live query updates automatically.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
import { electricSync } from '@electric-sql/pglite-sync';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: {
live,
electric: electricSync(),
},
});
await db.electric.syncShapeToTable({
shape: {
url: 'https://my-electric-server.com/v1/shape',
params: { table: 'todos' },
},
table: 'todos',
primaryKey: ['id'],
shapeKey: 'todos-sync',
});
const { unsubscribe } = await db.live.query(
'SELECT * FROM todos WHERE done = false ORDER BY id',
[],
(result) => {
console.log('Todos updated from server:', result.rows);
},
);Server-side changes flow through Electric to PGlite, which triggers the live query callback. This provides a fully reactive pipeline from server to UI.
Extensions
Loading Extensions
Extensions must be declared at construction time in the extensions option. They cannot be added after the instance is created.
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp';
const db = await PGlite.create({
extensions: {
vector,
pg_trgm,
uuid_ossp,
},
});After creating the instance, enable each extension with CREATE EXTENSION:
await db.exec('CREATE EXTENSION IF NOT EXISTS vector');
await db.exec('CREATE EXTENSION IF NOT EXISTS pg_trgm');
await db.exec('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');Extension Import Paths
| Extension | Import Path |
|---|---|
vector | @electric-sql/pglite/vector |
pg_trgm | @electric-sql/pglite/contrib/pg_trgm |
pgcrypto | @electric-sql/pglite/contrib/pgcrypto |
uuid-ossp | @electric-sql/pglite/contrib/uuid_ossp |
pg_uuidv7 | @electric-sql/pglite-uuidv7 (separate package) |
hstore | @electric-sql/pglite/contrib/hstore |
ltree | @electric-sql/pglite/contrib/ltree |
bloom | @electric-sql/pglite/contrib/bloom |
live | @electric-sql/pglite/live |
pgvector: Vector Similarity Search
Store and query vector embeddings with exact and approximate nearest neighbor search.
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
const db = await PGlite.create({ extensions: { vector } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536)
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
`);
await db.query('INSERT INTO documents (content, embedding) VALUES ($1, $2)', [
'Hello world',
'[0.1, 0.2, ...]',
]);
const similar = await db.query<{ id: number; content: string }>(
`SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 5`,
['[0.1, 0.2, ...]'],
);Distance operators:
| Operator | Distance Type |
|---|---|
<-> | L2 (Euclidean) |
<=> | Cosine |
<#> | Inner product |
pg_trgm: Trigram Similarity
Fuzzy text matching using trigram decomposition.
import { PGlite } from '@electric-sql/pglite';
import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
const db = await PGlite.create({ extensions: { pg_trgm } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);
`);
const results = await db.query<{
id: number;
name: string;
similarity: number;
}>(
`SELECT id, name, similarity(name, $1) AS similarity
FROM products
WHERE name % $1
ORDER BY similarity DESC
LIMIT 10`,
['laptop'],
);pgcrypto: Cryptographic Functions
Hashing, encryption, and random data generation.
import { PGlite } from '@electric-sql/pglite';
import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto';
const db = await PGlite.create({ extensions: { pgcrypto } });
await db.exec('CREATE EXTENSION IF NOT EXISTS pgcrypto');
const hash = await db.query<{ digest: string }>(
"SELECT encode(digest($1, 'sha256'), 'hex') as digest",
['my secret data'],
);
const uuid = await db.query<{ id: string }>('SELECT gen_random_uuid() as id');uuid-ossp: UUID Generation
import { PGlite } from '@electric-sql/pglite';
import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp';
const db = await PGlite.create({ extensions: { uuid_ossp } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL
);
`);pg_uuidv7: UUIDv7 with Timestamp Ordering
UUIDv7 embeds a timestamp for natural sort ordering. Requires a separate package.
npm install @electric-sql/pglite-uuidv7import { PGlite } from '@electric-sql/pglite';
import { pg_uuidv7 } from '@electric-sql/pglite-uuidv7';
const db = await PGlite.create({ extensions: { pg_uuidv7 } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
name TEXT NOT NULL
);
`);hstore: Key-Value Store
Store sets of key-value pairs in a single column.
import { PGlite } from '@electric-sql/pglite';
import { hstore } from '@electric-sql/pglite/contrib/hstore';
const db = await PGlite.create({ extensions: { hstore } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
config hstore
);
`);
await db.query('INSERT INTO settings (config) VALUES ($1::hstore)', [
'"theme"=>"dark","lang"=>"en"',
]);
const result = await db.query<{ value: string }>(
"SELECT config -> 'theme' as value FROM settings WHERE id = $1",
[1],
);ltree: Hierarchical Labels
Store and query hierarchical tree-like data.
import { PGlite } from '@electric-sql/pglite';
import { ltree } from '@electric-sql/pglite/contrib/ltree';
const db = await PGlite.create({ extensions: { ltree } });
await db.exec(`
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
path ltree NOT NULL
);
CREATE INDEX idx_categories_path ON categories USING gist (path);
`);
await db.exec(`
INSERT INTO categories (path) VALUES
('root'),
('root.electronics'),
('root.electronics.phones'),
('root.electronics.laptops')
`);
const descendants = await db.query<{ id: number; path: string }>(
"SELECT * FROM categories WHERE path <@ 'root.electronics'",
);Full-Text Search
Postgres built-in full-text search works out of the box without additional extensions.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.exec(`
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED
);
CREATE INDEX idx_articles_search ON articles USING gin (search_vector);
`);
const results = await db.query<{ id: number; title: string; rank: number }>(
`SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, plainto_tsquery('english', $1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10`,
['postgres embedded browser'],
);Live Queries
Setup
The live extension must be loaded at construction time.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({
extensions: { live },
});live.query()
Re-runs the full query whenever underlying data changes. Best for small to medium result sets.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
task TEXT NOT NULL,
done BOOLEAN DEFAULT false
)
`);
const { rows, unsubscribe } = await db.live.query<{
id: number;
task: string;
done: boolean;
}>('SELECT * FROM todos WHERE done = $1 ORDER BY id', [false], (result) => {
console.log('Updated todos:', result.rows);
});The callback fires with the full result set each time the query result changes. The initial call returns the current rows.
Unsubscribing
unsubscribe();Always unsubscribe when the query is no longer needed to prevent memory leaks.
live.incrementalQuery()
Computes diffs against previous results instead of re-running the full query. Designed for large result sets where full re-execution is expensive.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
const { rows, unsubscribe } = await db.live.incrementalQuery<{
id: number;
title: string;
body: string;
}>('SELECT * FROM articles ORDER BY id', [], 'id', (result) => {
console.log('Updated articles:', result.rows);
});The third argument is the key column name. This column must uniquely identify each row and is used to compute the diff between result sets. Typically a primary key column.
When to Use Incremental vs Standard
| Scenario | Recommended |
|---|---|
| Small result set (<100 rows) | live.query() |
| Large result set (100+ rows) | live.incrementalQuery() |
| Need insert/update/delete ops | live.changes() |
| Simple reactive binding | live.query() |
live.changes()
Returns raw change operations (insert, update, delete) instead of the full result set. Useful for building custom sync logic or applying granular updates to UI state.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
const { fields, unsubscribe } = await db.live.changes<{
id: number;
task: string;
done: boolean;
}>('SELECT * FROM todos ORDER BY id', [], 'id', (changes) => {
for (const change of changes) {
switch (change.__changed__) {
case 'insert':
console.log('New row:', change);
break;
case 'update':
console.log('Updated row:', change);
break;
case 'delete':
console.log('Deleted row:', change);
break;
}
}
});Each change object includes the row data plus a __changed__ field indicating the operation type.
Windowed / Paginated Live Queries
Combine live.query() with LIMIT and OFFSET for paginated reactive data.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
const PAGE_SIZE = 20;
let currentPage = 0;
const { rows, unsubscribe } = await db.live.query<{
id: number;
task: string;
}>(
'SELECT * FROM todos ORDER BY id LIMIT $1 OFFSET $2',
[PAGE_SIZE, currentPage * PAGE_SIZE],
(result) => {
renderPage(result.rows);
},
);To change pages, unsubscribe from the current query and create a new one with updated offset parameters.
Combining Live Queries with Listen/Notify
Live queries automatically detect changes from any query on the same PGlite instance. They do not require explicit NOTIFY calls. The live extension internally tracks table modifications.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
const { unsubscribe } = await db.live.query<{ count: number }>(
'SELECT count(*)::int as count FROM todos WHERE done = false',
[],
(result) => {
updateBadge(result.rows[0].count);
},
);
await db.query('INSERT INTO todos (task) VALUES ($1)', ['New task']);Error Handling
Live query callbacks do not receive errors directly. Handle errors in the initial setup.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({ extensions: { live } });
try {
const { unsubscribe } = await db.live.query(
'SELECT * FROM nonexistent_table',
[],
(result) => {
console.log(result.rows);
},
);
} catch (error) {
console.error('Live query setup failed:', error);
}SQL errors in the query itself are thrown at setup time, not in the callback.
Multi-Tab Worker
Problem
PGlite uses a single-writer model. When multiple browser tabs open the same database, concurrent writes cause corruption. PGliteWorker solves this with automatic leader election: one tab holds the database lock, and other tabs proxy queries through it.
Installation
PGliteWorker is included in the main package:
npm install @electric-sql/pgliteWorker File Setup
Create a dedicated worker file that initializes PGlite and calls worker() to expose it.
// pglite-worker.ts
import { PGlite } from '@electric-sql/pglite';
import { worker } from '@electric-sql/pglite/worker';
import { live } from '@electric-sql/pglite/live';
worker({
async init() {
return await PGlite.create({
dataDir: 'idb://my-app',
relaxedDurability: true,
extensions: { live },
});
},
});The worker() function wraps the PGlite instance with message handling for cross-tab communication.
Using PGliteWorker
In the main thread, create a PGliteWorker that connects to the worker file.
import { PGliteWorker } from '@electric-sql/pglite/worker';
import { live } from '@electric-sql/pglite/live';
const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.ts', import.meta.url), {
type: 'module',
}),
{
extensions: { live },
},
);
await db.query('SELECT * FROM todos');The PGliteWorker instance exposes the same API as a regular PGlite instance: query, sql, exec, transaction, and extension methods all work transparently.
Leader Election
PGliteWorker uses automatic leader election across tabs. Only the leader tab holds the actual database connection. Other tabs route queries through the leader.
Checking Leader Status
import { PGliteWorker } from '@electric-sql/pglite/worker';
const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.ts', import.meta.url), {
type: 'module',
}),
);
if (db.isLeader) {
console.log('This tab is the database leader');
}Listening for Leader Changes
When the leader tab closes, a new leader is elected automatically. Listen for leadership transitions.
import { PGliteWorker } from '@electric-sql/pglite/worker';
const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.ts', import.meta.url), {
type: 'module',
}),
);
db.onLeaderChange((isLeader) => {
if (isLeader) {
console.log('This tab became the leader');
} else {
console.log('This tab lost leadership');
}
});Architecture Overview
Tab A (Leader) Tab B (Follower) Tab C (Follower)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PGliteWorker│ │ PGliteWorker│ │ PGliteWorker│
│ (active DB)│◄───────│ (proxy) │ │ (proxy) │
│ │ │ │ │ │
└─────┬───────┘ └─────────────┘ └──────┬──────┘
│ │
│ ◄─────────────────────────────────┘
▼
┌─────────────┐
│ PGlite │
│ (idb://) │
└─────────────┘When Tab A closes, leadership transfers automatically to Tab B or Tab C.
Full React Example
// pglite-worker.ts
import { PGlite } from '@electric-sql/pglite';
import { worker } from '@electric-sql/pglite/worker';
import { live } from '@electric-sql/pglite/live';
worker({
async init() {
return await PGlite.create({
dataDir: 'idb://my-app',
relaxedDurability: true,
extensions: { live },
});
},
});// db.ts
import { PGliteWorker } from '@electric-sql/pglite/worker';
import { live } from '@electric-sql/pglite/live';
export const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.ts', import.meta.url), {
type: 'module',
}),
{
extensions: { live },
},
);// App.tsx
import { PGliteProvider } from '@electric-sql/pglite-react';
import { db } from './db';
function App() {
return (
<PGliteProvider db={db}>
<TodoApp />
</PGliteProvider>
);
}The PGliteWorker instance is compatible with PGliteProvider, so all React hooks work with the multi-tab setup without any changes.
OPFS-AHP with Worker
For maximum performance, combine OPFS-AHP storage with the worker pattern. OPFS-AHP requires a worker context, making this a natural pairing.
// pglite-worker.ts
import { PGlite } from '@electric-sql/pglite';
import { worker } from '@electric-sql/pglite/worker';
import { live } from '@electric-sql/pglite/live';
worker({
async init() {
return await PGlite.create({
dataDir: 'opfs-ahp://my-app',
relaxedDurability: true,
extensions: { live },
});
},
});Bundler Configuration
Most bundlers (Vite, webpack 5, esbuild) handle the new Worker(new URL(...)) pattern natively. For Vite:
const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.ts', import.meta.url), {
type: 'module',
}),
{
extensions: { live },
},
);No additional bundler configuration is needed for Vite. For webpack, ensure the worker-loader or built-in worker support is enabled.
Queries and Transactions
Parameterized Queries
The query method executes a single SQL statement with optional parameters. Parameters use $1, $2 positional placeholders.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`);
const result = await db.query<{ id: number; name: string; email: string }>(
'SELECT * FROM users WHERE email = $1',
['alice@example.com'],
);The QueryResult<T> type provides:
interface QueryResult<T> {
rows: T[];
fields: { name: string; dataTypeID: number }[];
affectedRows: number;
}SQL Tagged Template
The sql tagged template literal auto-parameterizes interpolated values, preventing SQL injection.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
const name = 'Alice';
const email = 'alice@example.com';
const result = await db.sql`
INSERT INTO users (name, email) VALUES (${name}, ${email})
RETURNING *
`;Interpolated values become query parameters. Do not use template literals for table or column names; those must be hardcoded in the SQL string.
Multi-Statement Exec
The exec method runs multiple SQL statements in a single call. It does not support parameters and returns void.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.exec(`
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_posts_created ON posts (created_at);
`);Best for schema migrations and setup scripts where parameterization is not needed.
Transactions
Transactions provide full ACID guarantees. Use db.transaction() with an async callback that receives a transaction object.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.transaction(async (tx) => {
const { rows } = await tx.query<{ balance: number }>(
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
[1],
);
if (rows[0].balance < 100) {
throw new Error('Insufficient funds');
}
await tx.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[100, 1],
);
await tx.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[100, 2],
);
});Throwing inside the callback automatically rolls back the transaction. The tx object supports query, sql, and exec with the same signatures as the main db instance.
COPY Support
PGlite supports the Postgres COPY protocol for bulk data import and export.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.exec(`
CREATE TABLE logs (id SERIAL, message TEXT, level TEXT)
`);
const csvData = `1,Server started,info
2,Connection failed,error
3,Request received,info`;
await db.query(
"COPY logs (id, message, level) FROM '/dev/blob' WITH (FORMAT csv)",
[],
{ blob: new Blob([csvData]) },
);Listen / Notify
PGlite supports Postgres LISTEN/NOTIFY for event-driven communication.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
const unsubscribe = await db.listen('order_created', (payload) => {
console.log('New order:', payload);
});
await db.query("NOTIFY order_created, 'order-123'");
unsubscribe();Combine with triggers for automatic notifications:
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.exec(`
CREATE OR REPLACE FUNCTION notify_changes() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('table_changed', TG_TABLE_NAME || ':' || NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_notify
AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION notify_changes();
`);Data Export and Import
Dump Data Directory
Export the entire database as a compressed blob for backup or transfer.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create('idb://my-app');
const dump = await db.dumpDataDir('gzip');The returned File object can be stored in IndexedDB, sent to a server, or saved locally.
Load Data Directory
Restore a database from a previous dump.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create({
loadDataDir: previousDump,
});This replaces the entire data directory. Any existing data at the storage location is overwritten.
Query Result Types
Type query results with a generic parameter for type-safe row access.
import { PGlite } from '@electric-sql/pglite';
interface User {
id: number;
name: string;
email: string;
created_at: Date;
}
const db = await PGlite.create();
const { rows } = await db.query<User>('SELECT * FROM users WHERE id = $1', [1]);
const user = rows[0];Close and Cleanup
Close the database when done to free WASM memory and release storage locks.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
await db.close();React Integration
Installation
npm install @electric-sql/pglite @electric-sql/pglite-reactPGliteProvider
Wrap the application with PGliteProvider to make the PGlite instance available to all hooks.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
import { PGliteProvider } from '@electric-sql/pglite-react';
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: { live },
});
function App() {
return (
<PGliteProvider db={db}>
<TodoList />
</PGliteProvider>
);
}The live extension is required for useLiveQuery and useLiveIncrementalQuery hooks.
usePGlite
Access the PGlite instance directly for imperative operations.
import { usePGlite } from '@electric-sql/pglite-react';
function AddTodo() {
const db = usePGlite();
async function handleSubmit(task: string) {
await db.query('INSERT INTO todos (task) VALUES ($1)', [task]);
}
return <button onClick={() => handleSubmit('New task')}>Add</button>;
}useLiveQuery
Reactive hook that re-renders the component when query results change. Wraps live.query() internally.
import { useLiveQuery } from '@electric-sql/pglite-react';
interface Todo {
id: number;
task: string;
done: boolean;
}
function TodoList() {
const result = useLiveQuery<Todo>(
'SELECT * FROM todos WHERE done = $1 ORDER BY id',
[false],
);
if (!result) return null;
return (
<ul>
{result.rows.map((todo) => (
<li key={todo.id}>{todo.task}</li>
))}
</ul>
);
}The hook returns undefined on the initial render before the query resolves. Always handle the loading state.
With Parameters
Parameters are reactive. When they change, the live query re-subscribes automatically.
import { useLiveQuery } from '@electric-sql/pglite-react';
interface SearchResult {
id: number;
name: string;
}
function SearchResults({ query }: { query: string }) {
const result = useLiveQuery<SearchResult>(
'SELECT * FROM products WHERE name ILIKE $1 LIMIT 20',
[`%${query}%`],
);
if (!result) return <p>Loading...</p>;
return (
<ul>
{result.rows.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}useLiveIncrementalQuery
Optimized for large result sets. Computes diffs using a key column instead of re-running the full query.
import { useLiveIncrementalQuery } from '@electric-sql/pglite-react';
interface Article {
id: number;
title: string;
body: string;
}
function ArticleList() {
const result = useLiveIncrementalQuery<Article>(
'SELECT * FROM articles ORDER BY id',
[],
'id',
);
if (!result) return null;
return (
<ul>
{result.rows.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
);
}The third argument is the key column used for diffing. Must be a unique column, typically the primary key.
makePGliteProvider: Typed Hooks
Create a typed provider and hooks for full type safety across the application.
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
import { makePGliteProvider } from '@electric-sql/pglite-react';
const { PGliteProvider, usePGlite, useLiveQuery, useLiveIncrementalQuery } =
makePGliteProvider<PGlite & { live: typeof live }>();
const db = await PGlite.create({
dataDir: 'idb://my-app',
extensions: { live },
});
function App() {
return (
<PGliteProvider db={db}>
<Content />
</PGliteProvider>
);
}The returned hooks are typed to the specific PGlite instance type, providing autocomplete for extensions.
Initialization Pattern
Handle async PGlite creation with a loading state.
import { useState, useEffect } from 'react';
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';
import { PGliteProvider } from '@electric-sql/pglite-react';
function AppLoader() {
const [db, setDb] = useState<PGlite | null>(null);
useEffect(() => {
let mounted = true;
PGlite.create({
dataDir: 'idb://my-app',
extensions: { live },
}).then((instance) => {
if (mounted) setDb(instance);
});
return () => {
mounted = false;
};
}, []);
if (!db) return <p>Loading database...</p>;
return (
<PGliteProvider db={db}>
<App />
</PGliteProvider>
);
}Mutation Pattern with Live Queries
Mutations trigger live query updates automatically. No manual invalidation is needed.
import { usePGlite, useLiveQuery } from '@electric-sql/pglite-react';
interface Todo {
id: number;
task: string;
done: boolean;
}
function TodoApp() {
const db = usePGlite();
const result = useLiveQuery<Todo>('SELECT * FROM todos ORDER BY id');
async function addTodo(task: string) {
await db.query('INSERT INTO todos (task, done) VALUES ($1, false)', [task]);
}
async function toggleTodo(id: number, done: boolean) {
await db.query('UPDATE todos SET done = $1 WHERE id = $2', [!done, id]);
}
if (!result) return null;
return (
<div>
<button onClick={() => addTodo('New task')}>Add</button>
<ul>
{result.rows.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo(todo.id, todo.done)}>
{todo.done ? '(done) ' : ''}
{todo.task}
</li>
))}
</ul>
</div>
);
}Setup
Installation
npm install @electric-sql/pgliteFor framework-specific packages:
npm install @electric-sql/pglite-react
npm install @electric-sql/pglite-vueCreating an Instance
Always use the PGlite.create() static factory method. It awaits the database ready state internally, so the returned instance is immediately usable.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();The legacy new PGlite() constructor requires manually awaiting .waitReady and is not recommended.
Storage Backends
In-Memory (Default)
Ephemeral storage that is lost when the process or tab closes. Best for testing and prototyping.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();IndexedDB (Browser)
Persistent browser storage using IndexedDB. Works in all modern browsers including Safari.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create('idb://my-database');OPFS-AHP (Browser Worker)
Origin Private File System with Access Handle Pool. Provides the best browser performance but requires a Web Worker context and does not work in Safari.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create('opfs-ahp://my-database');OPFS-AHP constraints:
- Must run inside a Web Worker (not the main thread)
- Not supported in Safari
- Best combined with
PGliteWorkerfor multi-tab setups
Filesystem (Node.js / Bun)
Persists to the local filesystem. Provide a directory path.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create('./path/to/pgdata');Configuration Options
Pass an options object as the second argument (or as the first argument with dataDir included).
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
import { live } from '@electric-sql/pglite/live';
const db = await PGlite.create({
dataDir: 'idb://my-app',
relaxedDurability: true,
extensions: {
vector,
live,
},
});Key Options
| Option | Type | Description |
|---|---|---|
dataDir | string | Storage backend URI or filesystem path |
relaxedDurability | boolean | Skips fsync for better browser write performance |
extensions | Record<string, Extension> | Extensions to load at construction time |
loadDataDir | `Blob \ | File` |
debug | 1-5 | Postgres debug level |
initialMemory | number | Initial WASM memory allocation in bytes |
Relaxed Durability
Enabling relaxedDurability significantly improves write performance in the browser by skipping fsync calls. Data remains consistent within a session but may be lost on unexpected tab closure.
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create({
dataDir: 'idb://my-app',
relaxedDurability: true,
});Recommended for browser apps where occasional data loss on crash is acceptable (local-first apps that sync with a server).
TypeScript Configuration
PGlite ships with full TypeScript types. No additional @types packages are needed.
import {
type PGliteOptions,
type QueryResult,
PGlite,
} from '@electric-sql/pglite';Verifying the Setup
Run a simple query to confirm the instance is working:
import { PGlite } from '@electric-sql/pglite';
const db = await PGlite.create();
const result = await db.query<{ version: string }>('SELECT version()');
console.log(result.rows[0].version);This returns the embedded PostgreSQL 17.4 version string.