はじめに
個人的に、PGliteは「ブラウザでデータベースを扱う」という概念を変えたライブラリだと思っています。
GitHubで13,900スター以上、npmパッケージは月間950万ダウンロード超え。ElectricSQLが開発した「WebAssemblyで動くPostgreSQL」という触れ込みなんですが、これが想像以上に実用的。
正直なところ、最初は「ブラウザでPostgres?おもちゃでしょ?」と思っていたんですよ。IndexedDBやSQLite(sql.js)で十分じゃない?と。でも実際に触ってみたら、「本物のPostgreSQLがそのまま動く」という事実に驚きました。30代になって思うのは、データベースの知識は使い回せるに越したことはないということ。PostgreSQLの文法がそのまま使えるのは、学習コスト的にかなり嬉しい。
とはいえ、説明を読むより先に触ってみた方が早いと思います。ブラウザ上でPGliteを初期化してdb.query()を呼ぶだけで、本物のPostgreSQLにフォームからデータを登録できるサンプルを用意したので、先に動きを見たい方はこちらからどうぞ。
PGliteとは
PGliteはWebAssemblyにコンパイルされたPostgreSQLで、TypeScript/JavaScriptライブラリとして提供されています。
ブラウザ、Node.js、Bun、Denoで動作し、外部依存なしで完全なPostgreSQLインスタンスを実行できます。圧縮時わずか3MB以下という軽量さで、これが本当にPostgreSQLなのかと疑うレベル。
従来の「ブラウザでPostgres」系のソリューションは、Linux VMを内部で動かすような重い実装が多かったんですが、PGliteは純粋なWASMコンパイル。Postgresの「シングルユーザーモード」を活用して、JavaScriptとデータベースエンジンの間にI/Oインターフェースを構築しています。
ライセンスはApache 2.0とPostgreSQL Licenseのデュアルライセンス。商用利用も問題なし。
特徴・メリット
1. 本物のPostgreSQLが動く
これ、意外と重要なんですよ。IndexedDBはKey-Value的な使い方しかできないし、sql.jsはSQLite。
PGliteはPostgreSQLなので、JSONBカラム、配列型、CTEなど、PostgreSQL固有の機能がそのまま使える。サーバー側と同じクエリが書けるのはコスパ的に最高です。
2. 拡張機能に対応
pgvectorを含む複数のPostgres拡張機能をサポート。ベクトル検索がブラウザでできるということは、AIアプリのローカル処理も視野に入る。
動的拡張ロードの仕組みがあるので、今後も対応拡張は増えていくはず。
3. 柔軟な永続化オプション
- ブラウザ: IndexedDBに永続化
- Node.js/Bun/Deno: ファイルシステムに永続化
- インメモリ: 一時的なデータ処理用
用途に応じて使い分けられる。特にブラウザでIndexedDBに永続化できるのは、オフラインファーストなPWAを作るときに便利。
4. 軽量
3MB以下(gzip圧縮時)。これでPostgreSQLが動くのは驚き。
初期ロードは発生するものの、CDN経由で配信すればキャッシュも効く。実用上は問題ないレベル。
5. リアクティブ対応
ライブクエリのプリミティブが組み込まれている。データの変更を監視して、UIを自動更新するようなパターンが簡単に実装できる。
インストール方法
npm/yarn/pnpm
npm install @electric-sql/pglite
これだけ。追加の依存関係は不要。
CDN経由(ブラウザ直接)
<script type="module">
import { PGlite } from 'https://cdn.jsdelivr.net/npm/@electric-sql/pglite/dist/index.js'
</script>
バンドラーなしでも使える。プロトタイピングには便利。
PGliteのサンプルを動かす
下のフォームは、PGliteをインメモリで起動し、db.exec()でテーブルを作成、db.query()にプレースホルダー付きのSQLを渡してユーザーを登録・一覧表示するサンプルです。名前とメールアドレスを書き換えて「追加」を押すと、その場でテーブルに行が増えます。名前を空にするとINSERTが実行されないようにガードしてあるので、空欄のまま送信して挙動を確認してみてください。
まず要点だけを抜き出すと、こういう形です。
import { PGlite } from '@electric-sql/pglite'
const db = new PGlite() // インメモリで起動(永続化なし)
await db.exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT
)
`)
// プレースホルダー($1, $2...)を使ってINSERT
await db.query('INSERT INTO users (name, email) VALUES ($1, $2)', [
'田中太郎',
'tanaka@example.com',
])
const result = await db.query('SELECT * FROM users ORDER BY id')
console.log(result.rows)
実際に動かせるものが以下です。フォームから送信するたびにdb.query()が呼ばれ、テーブルの中身が即座に更新されます。
new PGlite()だけでPostgreSQLインスタンスがブラウザ上に立ち上がり、CREATE TABLEもINSERTもサーバーサイドと同じSQLがそのまま通ることが分かるはずです。このサンプルはインメモリなのでリロードすると消えますが、new PGlite('idb://my-database')に書き換えるとIndexedDBに永続化され、次に開いたときもデータが残るようになります。
基本的な使い方
初期化
import { PGlite } from '@electric-sql/pglite'
// インメモリデータベース
const db = new PGlite()
// IndexedDBに永続化(ブラウザ)
const db = new PGlite('idb://my-database')
// ファイルシステムに永続化(Node.js)
const db = new PGlite('./data/my-database')
初期化はシンプル。パスを指定するだけで永続化先が切り替わる。
クエリの実行
// テーブル作成
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
)
`)
// データ挿入
await db.exec(`
INSERT INTO users (name, email) VALUES ('田中太郎', 'tanaka@example.com')
`)
// データ取得
const result = await db.query(`
SELECT * FROM users WHERE name LIKE $1
`, ['田中%'])
console.log(result.rows)
// [{ id: 1, name: '田中太郎', email: 'tanaka@example.com', created_at: '2025-...' }]
PostgreSQLのSQLがそのまま動く。プレースホルダーも$1形式で普通に使える。
トランザクション
await db.transaction(async (tx) => {
await tx.exec(`INSERT INTO users (name, email) VALUES ('佐藤花子', 'sato@example.com')`)
await tx.exec(`UPDATE users SET name = '佐藤はな子' WHERE email = 'sato@example.com'`)
// エラーが発生すると自動ロールバック
})
トランザクションも普通に使える。ACIDが保証されるのは安心感がある。
型安全なクエリ(TypeScript)
interface User {
id: number
name: string
email: string
created_at: Date
}
const result = await db.query<User>(`SELECT * FROM users`)
// result.rows は User[] 型
ジェネリクスで型を指定できる。TypeScriptとの相性も良い。
実践的なユースケース
オフラインファーストなTodoアプリ
import { PGlite } from '@electric-sql/pglite'
class TodoStore {
private db: PGlite
async init() {
this.db = new PGlite('idb://todos')
await this.db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
)
`)
}
async addTodo(title: string) {
await this.db.exec(`INSERT INTO todos (title) VALUES ($1)`, [title])
}
async toggleTodo(id: number) {
await this.db.exec(`
UPDATE todos SET completed = NOT completed WHERE id = $1
`, [id])
}
async getTodos() {
const result = await this.db.query(`
SELECT * FROM todos ORDER BY created_at DESC
`)
return result.rows
}
async deleteTodo(id: number) {
await this.db.exec(`DELETE FROM todos WHERE id = $1`, [id])
}
}
ネットワーク接続なしで完全に動作する。PWAと組み合わせればオフライン対応アプリが簡単に作れる。
実際にTodoStoreと同じCRUD操作(追加・完了トグル・削除)を、フォームとチェックボックスから触れるサンプルにしてみました。db.query()でINSERT・UPDATE・DELETEを呼んでいる箇所に注目してください。テキストを入力して「追加」を押す、チェックボックスをクリックする、「削除」を押す、それぞれのタイミングでtodosテーブルの中身がその場で書き換わります。
const db = new PGlite('idb://todos') // 本番ではIndexedDBに永続化
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE
)
`)
await db.query('INSERT INTO todos (title) VALUES ($1)', [title])
await db.query('UPDATE todos SET completed = NOT completed WHERE id = $1', [id])
await db.query('DELETE FROM todos WHERE id = $1', [id])
チェックボックスを押すたびにUPDATE ... SET completed = NOT completedが飛んでいるので、完了状態がPostgreSQL側のデータとして正しく保持されているのが分かります。「削除」を押せばDELETEが実行され、一覧から消えます。実アプリでこのdbをidb://todosで初期化すれば、この一覧はブラウザを閉じても残るオフラインTodoになります。
ローカルでのデータ分析
// CSVデータをインポートして分析
const db = new PGlite()
await db.exec(`
CREATE TABLE sales (
date DATE,
product TEXT,
amount INTEGER,
region TEXT
)
`)
// データをバルクインサート
for (const row of csvData) {
await db.exec(`
INSERT INTO sales (date, product, amount, region)
VALUES ($1, $2, $3, $4)
`, [row.date, row.product, row.amount, row.region])
}
// 集計クエリ
const result = await db.query(`
SELECT
region,
product,
SUM(amount) as total,
AVG(amount) as average
FROM sales
WHERE date >= '2025-01-01'
GROUP BY region, product
ORDER BY total DESC
`)
ブラウザ上でSQLを使ったデータ分析ができる。ちょっとしたデータ処理ならサーバー不要。
地域のセレクトボックスを切り替えると、WHERE region = $1を付けたdb.query()が再実行されてGROUP BYの集計結果が絞り込まれるサンプルです。SUM()やAVG()といったPostgreSQLの集計関数がそのままブラウザ上で動くところに注目してください。
const result = await db.query(
`SELECT region, product, SUM(amount) as total, ROUND(AVG(amount)::numeric, 0) as average
FROM sales
WHERE region = $1
GROUP BY region, product
ORDER BY total DESC`,
[region]
)
セレクトボックスを「東京」に変えると、SUM(amount)が東京の行だけで再計算されます。CSVを読み込んでこのパターンに流し込めば、サーバーにアップロードすることなくブラウザ内だけで売上分析が完結します。
Reactとの統合
import { useState, useEffect } from 'react'
import { PGlite } from '@electric-sql/pglite'
const db = new PGlite('idb://app-data')
function usePGlite<T>(query: string, deps: any[] = []) {
const [data, setData] = useState<T[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
const result = await db.query<T>(query)
setData(result.rows)
} catch (e) {
setError(e as Error)
} finally {
setLoading(false)
}
}
fetchData()
}, deps)
return { data, loading, error }
}
// 使用例
function UserList() {
const { data: users, loading } = usePGlite<User>(
'SELECT * FROM users ORDER BY created_at DESC'
)
if (loading) return <div>Loading...</div>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
カスタムフックを作れば、Reactとの統合も簡単。
usePGliteフックの中身だけを抜き出すと、useEffectの中でdb.query()を呼んで結果をuseStateに流し込んでいるだけです。
function usePGlite<T>(query: string, deps: any[] = []) {
const [data, setData] = useState<T[]>([])
useEffect(() => {
db.query<T>(query).then((result) => setData(result.rows))
}, deps)
return data
}
function UserList() {
const users = usePGlite<User>('SELECT * FROM users ORDER BY id')
return (
<ul>
{users.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
下のサンプルはこれをフォーム付きに拡張し、「追加」を押すたびにrefresh()経由でusePGliteが再度db.query()を呼び直す形にしています。Reactの状態管理とPGliteのクエリ結果がどう繋がるかを確認してみてください。
refresh()を呼ぶたびにloadingがtrueになり、db.query()の結果が返ってきた瞬間に一覧が再描画されます。PGliteの非同期APIとReactの状態更新のタイミングがどう噛み合っているか、コンソールを開いて確認してみてください。
pgvectorでベクトル検索
import { PGlite } from '@electric-sql/pglite'
import { vector } from '@electric-sql/pglite/vector'
const db = new PGlite({
extensions: { vector }
})
await db.exec('CREATE EXTENSION IF NOT EXISTS vector')
await db.exec(`
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(384)
)
`)
// 類似検索
const result = await db.query(`
SELECT content, embedding <-> $1 as distance
FROM documents
ORDER BY distance
LIMIT 5
`, [queryEmbedding])
ブラウザでベクトル検索。RAGアプリのローカル版とか作れそう。
注意点
シングルユーザー/シングル接続
PGliteは設計上、同時接続をサポートしていません。WebAssemblyの制約でプロセスフォークができないため。
複数タブで同じデータベースを開くと問題が起きる可能性があるので、SharedWorkerやService Workerで単一インスタンスを管理するなどの工夫が必要。
初期化に時間がかかる
WASMのロードとデータベースの初期化で、初回起動時に数百ミリ秒かかる。ユーザー体験を考えると、ローディング表示は必須。
大規模データには不向き
数GB級のデータを扱う場合は、素直にサーバーサイドのPostgreSQLを使うべき。PGliteは「ローカルで完結するデータ」向け。
まとめ
PGliteを使ってみて感じたこと:
- 学習コスト: PostgreSQLを知っていればゼロ
- セットアップ: npm installするだけ
- 実用性: オフラインファーストアプリに最適
- パフォーマンス: 小〜中規模データなら十分実用的
- 将来性: pgvectorなど拡張機能の対応が進んでいる
正直なところ、「ブラウザでちゃんとしたDBを使いたい」という需要に対する回答としては、現時点でPGlite一択ですね。
特に「オフラインでも動くアプリを作りたい」「サーバーを立てずにデータ処理したい」「PostgreSQLの知識を活かしたい」という人には最適。IndexedDBの低レベルAPIと格闘する時間を節約できますよ。
まだ試していない人は、簡単なTodoアプリあたりから始めてみてください。ブラウザでPostgreSQLが動く感動を味わえるはずです。