The day I removed `better-sqlite3` from `package.json` — migrating WAL, transactions, and migrations to Bun's built-in `bun:sqlite`
When better-sqlite3 compilation blew up on an ARM build server, that was the first time I seriously looked for alternatives. The error message was unfamiliar, the CI logs were long, and I ended up wasting an hour wrestling with node-pre-gyp. The whole time I kept thinking, "This is SQLite — why is it this complicated?"
This article is written for server developers who are already using Bun as their runtime and are running into native build/CI issues because of the better-sqlite3 dependency. bun:sqlite has been built in since Bun 0.6.x (late 2022), and its stability and API have improved with each subsequent release. As of August 2026, I'm writing this because I believe it's mature enough to fully replace better-sqlite3 in production servers. That said, it's not a 1:1 drop-in replacement and there are definite caveats, so let's walk through how to actually migrate, how to handle WAL mode, transactions, and migrations, and where you might get stuck.
Why This Option Is Valid Now
SQLite as a First-Class Runtime Citizen
bun:sqlite isn't new, but over the past few years there's been a clear trend toward JavaScript runtimes treating SQLite as a standard built-in feature rather than an external native addon. Node.js has also introduced an experimental built-in SQLite (node:sqlite), while Bun already provides it as a stable API.
bun:sqlite is integrated directly into JavaScriptCore without going through N-API, which means there's no marshaling overhead from crossing the boundary between a native addon and the JavaScript runtime.
API Comparison with better-sqlite3
Here's a summary of the practical differences between the two libraries. SQLite syntax itself (e.g., PRAGMA journal_mode=WAL) executes identically with both drivers, so it's excluded — only the differences in the driver API itself are shown in the table.
| Item | better-sqlite3 (Node.js) |
bun:sqlite (Bun built-in) |
|---|---|---|
| Installation | npm install better-sqlite3 + native build |
No separate install |
| Binding method | N-API (native addon) | Direct JavaScriptCore integration |
| Execution model | Synchronous | Synchronous |
| Explicit prepared statement | db.prepare(sql) |
db.prepare(sql) |
| Auto-caching prepared statement | No dedicated API (manual caching) | db.query(sql) (internal caching) |
| Transaction wrapping | db.transaction(fn) |
db.transaction(fn) |
| TypeScript types | @types/better-sqlite3 separate install |
Built into Bun |
The key difference is db.query(). With better-sqlite3, optimizing a frequently-used query required manually storing the prepare() result in a variable. With bun:sqlite, db.query() internally caches the prepared statement for the same SQL. If you only use db.prepare(), you miss this benefit — so it's worth migrating frequently re-executed queries to db.query().
Basic Migration: Change One Import Line
Let's start with the simplest case.
// before: Node.js + better-sqlite3
import Database from "better-sqlite3";
const db = new Database("app.db");
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(1);// after: Bun built-in bun:sqlite
import { Database } from "bun:sqlite";
const db = new Database("app.db");
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(1);The import path changes and it becomes a named export — that's it. The .get(), .all(), and .run() method names are identical. For queries that get called repeatedly, you can switch to db.query() like below to take advantage of caching.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
const getUserById = db.query("SELECT * FROM users WHERE id = ?");
const row = getUserById.get(1);WAL Mode and Performance PRAGMA Settings
WAL (Write-Ahead Logging) mode is a SQLite journaling strategy that writes to a separate -wal file first, allowing reads and writes to be processed in parallel. In a multi-reader + single-writer environment — which describes most backend API servers — it improves throughput compared to the default mode (DELETE).
In WAL mode, read transactions reference both the DB file and the WAL file until a checkpoint runs. The diagram below is a simplified view; in practice, read requests also scan the latest commits in the WAL file.
Applying a few PRAGMAs right after the DB connection makes ongoing operations easier.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode = WAL;");
db.run("PRAGMA synchronous = NORMAL;");
db.run("PRAGMA cache_size = -64000;");
db.run("PRAGMA temp_store = MEMORY;");synchronous = NORMAL reduces fsync frequency compared to the default (FULL), increasing throughput at the cost of potential loss of the last few transactions in extreme scenarios like power cuts. This is fine for typical API server workloads, but if you need strong durability — such as for financial transactions — it's safer to leave it at the default.
On performance: various community benchmarks have been shared, but results vary significantly depending on the scenario. The numbers shared by a Bun team member in better-sqlite3 GitHub Discussions #1057 are a commonly cited reference, though these reflect pure driver overhead differences. Workloads dominated by complex SQL operations may yield different results, so validating with your own production traffic is the safer approach.
Transaction Wrapping
The db.transaction(fn) API has the same name and signature. If an exception is thrown inside the function, it automatically rolls back.
import { Database } from "bun:sqlite";
interface Item {
name: string;
value: number;
}
const db = new Database("app.db");
const insertMany = db.transaction((items: Item[]) => {
const stmt = db.prepare(
"INSERT INTO items (name, value) VALUES ($name, $value)"
);
for (const item of items) {
stmt.run({ $name: item.name, $value: item.value });
}
});
insertMany(largeDataset);A common point of confusion when first using this API is that db.transaction(fn) returns a function, not an immediately executed result. You have to call the returned function — like insertMany(largeDataset) — to actually open the transaction. This is the same pattern as better-sqlite3.
Choosing a Migration Strategy
There are two approaches, and the right one depends on your situation. Deciding which one to look at before diving into code will save you time.
Custom _migrations Table
This approach uses a _migrations table to track migration state directly, without any external tools. Useful when you want to minimize dependencies.
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
applied_at TEXT DEFAULT (datetime('now'))
)
`);
function applyMigration(name: string, sql: string) {
const already = db
.prepare("SELECT 1 FROM _migrations WHERE name = ?")
.get(name);
if (already) return;
db.transaction(() => {
db.run(sql);
db.prepare("INSERT INTO _migrations (name) VALUES (?)").run(name);
})();
}
applyMigration(
"001_create_users",
`CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`
);
applyMigration(
"002_add_email_to_users",
`ALTER TABLE users ADD COLUMN email TEXT`
);A few things make this implementation practical: the UNIQUE constraint on name prevents the same migration from being applied twice. And because the schema change and the record insert are wrapped in db.transaction(), if db.run(sql) fails, nothing gets written to the _migrations table either — so a re-run will try again. The atomicity also prevents the reverse scenario where the schema change succeeds but only the record insert fails.
Drizzle ORM Integration
Drizzle ORM officially supports bun:sqlite via the drizzle-orm/bun-sqlite adapter.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email"),
});
const sqlite = new Database("app.db");
const db = drizzle(sqlite);
await migrate(db, { migrationsFolder: "./drizzle" });
const allUsers = db.select().from(users).all();The pattern is: use drizzle-kit generate to produce SQL files from schema changes, then use migrate() to apply them automatically at app startup.
One caveat: when running the drizzle-kit CLI with Bun, some versions have been reported to assume a Node environment internally and require better-sqlite3 (failing to run at all if it's not installed). In that case, separating file generation (using the CLI, in a Node environment if needed) from actual application (using migrate() in app code) means your runtime code doesn't need the better-sqlite3 dependency. Check the release notes for your version of drizzle-kit for the current status.
Trade-offs
What You Gain
| Item | Details |
|---|---|
| Dependency removal | Delete better-sqlite3 → lighter node_modules, no more ARM/musl cross-compile errors |
| Install and startup speed | Ready to use immediately after bun install. Eliminates native module recompile step in CI/CD |
| Built-in TypeScript | No need to separately install @types/ packages |
| Familiar API | Same names: .prepare(), .get(), .all(), .run(), .transaction() |
| Auto-caching queries | Optimize repeated queries with a single line using db.query() |
What You Give Up
| Item | Details |
|---|---|
| Not 100% compatible | Some libraries are designed to accept a better-sqlite3 instance as an adapter — verify separately |
| ORM toolchain constraints | Some versions of drizzle-kit may require better-sqlite3 internally in the CLI |
| Runtime lock-in | bun:sqlite is Bun-only. If you need to support both Node.js and Bun runtimes simultaneously, you'll need a separate abstraction layer |
| Complexity of mixed use | Trying to use better-sqlite3 as-is in Bun has recompile-related issues #16050, so half-hearted mixed usage actually increases complexity |
| Performance is workload-dependent | Results vary significantly by query type. Don't assert an absolute winner without your own benchmarks |
The biggest concern is API compatibility. For a simple CRUD app, changing one import line is all it takes. But if you're using a third-party library that expects a better-sqlite3 instance injected as a specific adapter, check whether that library supports bun:sqlite first. You'll occasionally hear about shim packages that mimic the better-sqlite3 API — but verify them directly against the npm registry and recent release/issue activity before adopting.
What's Gone and What Remains
Comparing package.json before and after the migration, the change is clear.
{
"dependencies": {
- "better-sqlite3": "^11.x.x"
- },
- "devDependencies": {
- "@types/better-sqlite3": "^7.x.x"
}
}In your CI pipeline, things like node-gyp cache warming, platform-specific prebuilt binary fallbacks, and ARM-runner-specific build steps disappear along with it. Those node-pre-gyp logs that cost me an hour? Gone for good. CI logs going quiet isn't something you notice right away — you feel it as a string of nights without deployment failure alerts.
The migration itself comes down to updating import paths in a handful of files, deciding on a migration strategy, and adding 4–5 lines of PRAGMAs. If you have dependencies tied to toolchains like drizzle-kit or third-party adapters, just double-check those specific points separately. There isn't much to prepare — but what gets deleted is roughly half your CI logs, and that's the real practical gain of this migration.
References
- SQLite — Bun Official Docs
- Get Started with Drizzle and Bun:SQLite — Drizzle ORM Official
- Drizzle ORM — Connect Bun SQLite
- Bun claims SQLite driver is 3-6x faster — better-sqlite3 GitHub Discussions #1057
- better-sqlite3 compatibility issue #16050 — oven-sh/bun GitHub
- SQLite WAL Mode Official Docs
- Node.js
node:sqliteExperimental Module Docs