この記事で分かること
- Node.js 24 LTS の標準モジュール
node:sqliteが、フラグなしでどこまで実用になるか -
node:sqliteとbetter-sqlite3の API の違いと、移行時にハマる落とし穴 - 1万件 INSERT / SELECT の実測値(速度を左右する本当の要因はライブラリではないという話)
対象読者: Node.js で軽量なデータ保存(ローカルツール、CLI、Electron、小規模サーバのキャッシュや設定)に SQLite を使いたい開発者。「新規プロジェクトで better-sqlite3 を入れるか、標準の node:sqlite で済ますか」を迷っている方。
結論: 基本用途(CREATE / INSERT / SELECT / プリペアドステートメント / トランザクション)なら node:sqlite は better-sqlite3 と同等に動き、速度差も誤差レベルです。依存を増やしたくないなら node:sqlite で十分実用的です。ただし安定度は Stable 未満で、better-sqlite3 にある便利ヘルパの一部が欠けます。安定版の保証と db.transaction() などのヘルパが要るなら better-sqlite3 を選びます。
前提(動作環境)
本記事の実測はすべて次の環境で行いました。数値は「この環境での参考値」です。OS や CPU が変われば絶対値は変わります。
| 項目 | 値 |
|---|---|
| マシン | Apple M3 / メモリ 24GB |
| OS | macOS (Darwin 25.5.0, arm64) |
| Node.js | v24.16.0(LTS ライン) |
| better-sqlite3 | v12.11.1(バンドル SQLite 3.53.2、prebuilt バイナリ) |
| node:sqlite バンドル SQLite | 3.53.0 |
数値はすべて上記環境での実測です。公式ドキュメント(nodejs.org/api/sqlite.html)は新しいバージョン向けの内容を表示します。そのためバージョン依存の記述は、手元の v24.16.0 で実行確認した結果を正としています。
node:sqlite はフラグなしで使える
Node.js 24 では node:sqlite を --experimental-sqlite フラグなしで読み込めます。手元の v24.16.0 で require('node:sqlite') を実行すると、exit code 0・stderr 空でロードできました。
// require(CommonJS)
const sqlite = require('node:sqlite');
// loaded OK, exports: DatabaseSync, StatementSync, Session, constants, backup
ESM の import も同様に警告なしでロードできます。
import { DatabaseSync } from 'node:sqlite';
process.on('warning', ...) を仕込んで基本 API を一通り叩いても、ExperimentalWarning は発火しませんでした。これはフラグが v23.4.0 / v22.13.0 で撤廃済みのためです。公式ドキュメントの History 表にも「v23.4.0, v22.13.0 — SQLite is no longer behind --experimental-sqlite but still experimental.」と記載があります。
ただし「フラグが不要」であることと「安定版」であることは別です。安定度インデックスはまだ Stable ではありません。公式ドキュメント(最新版)では「Stability: 1.2 - Release candidate」で、History 表に「v25.7.0 — SQLite is now a release candidate」とあります。v24.16.0 は RC 化(v25.7.0)より前のバージョンなので、v24 系では experimental 相当(Stability 1.1、Stable 未満)と理解するのが正確です。いずれにせよ Stability 2(Stable)ではありません。将来 API が変わる可能性を許容できるかは、導入前に判断してください。
基本操作(node:sqlite)
まずは最小のサンプルです。CREATE / INSERT / SELECT / プリペアドステートメント / トランザクションまでを 1 ファイルにまとめました。v24.16.0 で動作確認済みです。
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('app.db'); // ファイル。メモリは ':memory:'
db.exec('PRAGMA journal_mode = WAL'); // 実運用では推奨
db.exec(`CREATE TABLE IF NOT EXISTS users(
id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER
)`);
// プリペアド + 位置パラメータ
const insert = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
const { lastInsertRowid } = insert.run('Alice', 30); // {changes:1, lastInsertRowid:1}
// 名前付きパラメータ
db.prepare('INSERT INTO users (name, age) VALUES (:name, :age)')
.run({ name: 'Bob', age: 25 });
// トランザクション(BEGIN/COMMIT)。1万件級はこれで桁違いに速くなる
const many = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
db.exec('BEGIN');
try {
for (let i = 0; i < 10000; i++) many.run('user' + i, i);
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
console.log(db.prepare('SELECT * FROM users WHERE id = ?').get(lastInsertRowid));
console.log(db.prepare('SELECT COUNT(*) AS c FROM users').get().c);
db.close();
押さえておきたい戻り値と挙動は次のとおりです(すべて v24.16.0 実測)。
-
new DatabaseSync(':memory:')でメモリDB、パス文字列を渡すとファイルDBです。 -
db.exec(sql)は複数文の DDL / DML をまとめて実行します(戻り値なし)。CREATE TABLE ... STRICTも使えます。 -
stmt.run(...)の戻り値は{ changes, lastInsertRowid }です(実測:{"changes":1,"lastInsertRowid":1})。 -
stmt.get(...)は 1 行をオブジェクトで返します(実測:{"id":1,"name":"Alice","age":30})。該当なしはundefinedです。 -
stmt.all(...)は全行をオブジェクトの配列で返します。 -
stmt.iterate(...)は行オブジェクトのイテレータを返し、for...ofで回せます。
パラメータバインドは次のとおりです。
- 位置指定:
?にstmt.run('Alice', 30) - 名前付き:
:name/$name/@nameのいずれも使え、オブジェクトでstmt.run({ name: 'Bob', age: 25 })
トランザクション中の判定
トランザクション中かどうかは db.isTransaction プロパティで判定できます。実測では BEGIN 後に true、COMMIT 後に false になりました。db.exec('ROLLBACK') を実行すると件数が元に戻ることも確認しています。
なお better-sqlite3 のような関数ラッパである db.transaction は node:sqlite には存在せず、undefined です。この差は後述します。
コンストラクタのオプション
new DatabaseSync(path[, options]) で使えるオプションのうち、実測で確認したものは次のとおりです。
-
readOnly: true: 読み取り専用オープン。実測で書き込みがattempt to write a readonly databaseで拒否され、存在しないファイルへのreadOnlyオープンはunable to open database fileで失敗しました。 -
timeout: 5000: 受理されます(v24.0.0で追加。History 表に「v24.0.0, v22.16.0 — Addtimeoutoption」)。 - ほかに
enableForeignKeyConstraints(デフォルトtrue)、enableDoubleQuotedStringLiterals、allowExtension、openがあります。
better-sqlite3 との違い
両方を実際に動かして確認した差分をまとめます。設計思想(すべて同期 API)は共通で、run() の戻り値の形({changes, lastInsertRowid})や get() / all() の返し方も同じです。違いが出るのは以下の点です。
| 観点 | node:sqlite (v24.16.0) | better-sqlite3 (v12.11.1) |
|---|---|---|
| 導入 | Node 標準(npm install 不要、依存ゼロ) |
npm install better-sqlite3。prebuilt バイナリで今回は約1秒・node-gyp ビルド不要 |
| import | import { DatabaseSync } from 'node:sqlite' |
import Database from 'better-sqlite3' |
| DBクラス | new DatabaseSync(path, opts) |
new Database(path, opts) |
| トランザクション中判定 | db.isTransaction |
db.inTransaction(プロパティ名が違う) |
| トランザクションヘルパ | なし(BEGIN/COMMIT を exec) |
db.transaction(fn) あり。関数化でき、例外時に自動 ROLLBACK |
| スカラ取得 |
setReturnArrays(true) で配列化。.pluck() 相当はなし |
.pluck()(単一列をスカラで)、.raw()(行を配列で)あり |
| 名前付き param のキー |
{ v: ... }(bare)も { ':v': ... }(prefix付き)も両方OK |
{ v: ... }(bare)のみ。{ ':v': ... } は Missing named parameter "v" でエラー |
| 拡張ロード |
loadExtension() あり。ただし allowExtension: true を明示しないと extension loading is not allowed で拒否 |
db.loadExtension() を標準提供 |
| boolean 束縛 | 不可。Provided value cannot be bound to SQLite parameter で例外 |
不可。SQLite3 can only bind numbers, strings, bigints, buffers, and null で例外 |
| BigInt |
setReadBigInts(true) で INTEGER を BigInt 読み出し |
defaultSafeIntegers() 等で対応 |
| ユーザー定義関数/集約 |
db.function() / db.aggregate() あり |
あり |
| Worker thread | 標準ドキュメントに明示なし | 公式に Worker thread サポートを明記 |
node:sqlite のトップレベル export は DatabaseSync, StatementSync, Session, backup, constants でした(v24.16.0 実測)。StatementSync は all, columns, get, iterate, run, setAllowBareNamedParameters, setAllowUnknownNamedParameters, setReadBigInts, setReturnArrays を持ちます。DatabaseSync は aggregate, applyChangeset, close, createSession, createTagStore, deserialize, enableDefensive, enableLoadExtension, exec, function, loadExtension, location, open, prepare, serialize, setAuthorizer を持ちます。createTagStore(プリペアド文の LRU キャッシュ)や Session / changeset、serialize / deserialize、backup() は Node 独自です。
移行時の落とし穴: 名前付きパラメータのキー
移行で特にハマりやすいのが名前付きパラメータのキー命名です。node:sqlite は bare({ v: ... })でも prefix 付き({ ':v': ... })でも通ります。一方 better-sqlite3 は bare のみで、prefix 付きは Missing named parameter "v" でエラーになります。
// node:sqlite: どちらも通る
stmt.run({ name: 'Bob' }); // OK
stmt.run({ ':name': 'Bob' }); // OK
// better-sqlite3: prefix 付きはエラー
stmt.run({ name: 'Bob' }); // OK
stmt.run({ ':name': 'Bob' }); // Missing named parameter "name"
node:sqlite 向けに prefix 付きキーで書いたコードを better-sqlite3 に移すと、この違いがバグの原因になります。
better-sqlite3 の transaction() ヘルパ
better-sqlite3 には db.transaction(fn) があり、処理を関数化できます。例外が飛べば自動で ROLLBACK されます(実測で throw により件数が巻き戻ることを確認)。
import Database from 'better-sqlite3';
const db = new Database('app.db');
const insertMany = db.transaction((rows) => {
const stmt = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
for (const [n, a] of rows) stmt.run(n, a);
});
insertMany([['Dave', 50], ['Eve', 22]]); // 例外が飛べば自動でROLLBACK
node:sqlite には同等のヘルパがないため、同じ「自動ロールバック」が欲しい場合は自前でラッパを書きます(動作確認済みのパターン)。
function tx(db, fn) {
db.exec('BEGIN');
try { const r = fn(); db.exec('COMMIT'); return r; }
catch (e) { db.exec('ROLLBACK'); throw e; }
}
パフォーマンス実測
1万件 INSERT と SELECT を両ライブラリで計測しました。
測定条件:
- 1万件(N=10000)INSERT を「非トランザクション(各
run()が自動コミット)」と「単一トランザクションで一括」で比較 - SELECT は「単一行
get()を5000回ループ」と「全件all()(1万件)」 - ファイルベースDB(
:memory:ではなく一時ファイル)。PRAGMA synchronous = NORMAL固定 - ジャーナルモードは WAL と DELETE(デフォルトのロールバックジャーナル)の両方を計測
- ウォームアップ1回を捨て、計測3回の中央値を採用。SELECT の検証用チェックサムが全構成で一致し、結果の正当性を確認
環境は前掲のとおり Node.js v24.16.0 / Apple M3 / macOS arm64 です。中央値(ミリ秒。小さいほど速い)は次のとおりです。
| 構成 | INSERT 1万件・非TX | INSERT 1万件・単一TX | SELECT get()×5000 | SELECT all() 1万件 |
|---|---|---|---|---|
| node:sqlite / WAL | 65.9 | 3.1 | 6.9 | 3.3 |
| node:sqlite / DELETE | 1391.9 | 3.3 | 19.1 | 3.5 |
| better-sqlite3 / WAL | 68.5 | 4.2 | 5.5 | 2.5 |
| better-sqlite3 / DELETE | 1421.0 | 4.5 | 17.7 | 2.4 |
この表から読み取れることは 3 点です。
- トランザクションの効果が支配的です。 node:sqlite / DELETE で見ると、非TX 1391.9ms に対し単一TX 3.3ms で、約422倍の差です。WAL でも 65.9ms 対 3.1ms で約21倍です。ライブラリの違いより、こちらが桁違いに効きます。
- node:sqlite と better-sqlite3 の差は誤差レベルです。 TX 付き INSERT では node:sqlite がわずかに速く(約3.1〜3.3ms 対 約4.2〜4.5ms)、SELECT get() は better-sqlite3 がわずかに速い(約5.5ms 対 約6.9ms)です。全件 all() も better-sqlite3 がわずかに速い(2.4〜2.5ms 対 3.3〜3.5ms)です。いずれも「どちらを選んでも実用上変わらない」レベルです。
- WAL は非トランザクションの大量 INSERT と単発 SELECT を速くします。 DELETE の 1400ms 級が WAL では 66ms 級になります。
これらは Apple M3 / macOS arm64 での参考値です。Linux x64 や Windows、ストレージの種類で絶対値は変わるため、数値そのものの一般化はしません。ただし「トランザクションが効く」「両者はほぼ同等」という傾向は、この計測から読み取れます。
現時点の制約・注意点
node:sqlite を新規採用する前に、次の点を確認してください(v24.16.0 時点)。
- 安定度は Stable 未満です(v24 系は experimental 相当、v25.7.0 で RC)。API 変更の可能性を許容できるか判断が要ります。
-
better-sqlite3にあってnode:sqliteに無い / 弱いものがあります:db.transaction(fn)の自動ロールバック・ネスト管理ヘルパ、.pluck()、.raw()(setReturnArraysで代替できますが文単位のトグル)、公式に謳う Worker thread サポート、unsafe mode などの上級機能。 - boolean を直接バインドできません(両者共通の制約)。true / false は自分で 0 / 1 に変換します。
- 拡張ロードは
allowExtension: trueの明示が必須です(安全側デフォルト)。 - 名前付きパラメータのキー命名が
better-sqlite3と非互換になり得ます。移行時のバグ源です。
まとめ
- Node.js 24 LTS(v24.16.0 で確認)の
node:sqliteは、フラグ不要・警告なしでimport/requireでき、基本用途はbetter-sqlite3と同等に動きます。 - 実測(Apple M3 / macOS、1万件 INSERT / SELECT)では両者の速度差は誤差レベルです。支配的なのはライブラリではなくトランザクションで、DELETE ジャーナルでは非TX比 約422倍、WAL でも約21倍 INSERT が速くなりました。
- 性能を上げたいなら、まず「トランザクションで囲む」「WAL を使う」を先に検討します。ライブラリ選択より効きます。
- 依存を増やしたくない・軽量に始めたいなら
node:sqliteで十分実用的です。 - 安定版の保証と
db.transaction()/.pluck()/.raw()などのヘルパ、Worker thread の明示サポートが要るならbetter-sqlite3を選びます。
参考リンク
- Node.js 公式
node:sqliteドキュメント: https://nodejs.org/api/sqlite.html (表示は新しいバージョン向け。v24 固有はローカル v24.16.0 で実行確認) - Node.js 24.0.0 リリースブログ(2025-05-06): https://nodejs.org/en/blog/release/v24.0.0
- better-sqlite3 リポジトリ(README・API・ベンチ比較表): https://github.com/WiseLibs/better-sqlite3