duckdb-bun
v0.7.0
Published
Efficient DuckDB driver for Bun, using pure FFI
Maintainers
Readme
duckdb-bun
Efficient DuckDB driver for Bun, using pure FFI
A Bun-native binding to DuckDB's modern C API. No node-gyp, no
N-API marshaling, no install-time native build — bun add duckdb-bun
just works. The driver dlopens libduckdb directly through
bun:ffi and uses DuckDB's chunk-based result API for column-store
reads with minimal overhead.
(There is one piece of native code in the package: a ~30-line C shim
that works around a bun:ffi limitation on Linux/Windows x64. It
ships pre-built per platform in the npm tarball and is loaded via the
same bun:ffi dlopen path as libduckdb itself — not as a Node
addon. See § FFI shim — what's it for.)
bun add duckdb-bun
brew install duckdb # or: apt install libduckdb-devimport { open } from 'duckdb-bun';
using db = open(':memory:'); // closes automatically
await db.exec('CREATE TABLE users (id INT, name VARCHAR)');
await db.run('INSERT INTO users VALUES (?, ?), (?, ?)',
[1, 'Alice', 2, 'Bob']);
const rows = await db.all('SELECT * FROM users ORDER BY id');
// → [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
const one = await db.get('SELECT * FROM users WHERE id = ?', [1]);
// → { id: 1, name: 'Alice' }For repeated execution, use prepared statements:
using stmt = await db.prepare('INSERT INTO users VALUES (?, ?)');
for (const [id, name] of users) await stmt.run([id, name]);For atomic units of work, use transactions:
await db.transaction(async (tx) => {
await tx.run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 'A']);
await tx.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 'B']);
}); // BEGIN before, COMMIT after; ROLLBACK + rethrow on any throwFor large result sets, stream row-by-row without materializing in memory:
await using db = open(':memory:'); // async dispose: waits for iterator cleanup
for await (const row of db.iterate('SELECT * FROM big_table')) {
if (row.id > 10_000) break; // break / throw cleanly disposes the stream
}For HTTP servers and interactive workloads, run DuckDB on a Worker thread
via the duckdb-bun/async subpath. Identical API surface, but every
query runs off the main event loop, so the loop stays responsive:
import { open, DuckDBAbortError } from 'duckdb-bun/async'; // /async subpath
await using db = open(':memory:'); // sync proxy; worker spawns lazily
await db.exec('CREATE TABLE users (id INT, name VARCHAR)');
const rows = await db.all('SELECT * FROM users');
// While a heavy query runs, the main event loop stays free:
const big = db.get('SELECT count(*) FROM range(1500000) a, range(80) b WHERE (a.range + b.range) % 7 = 0');
const t = setInterval(() => console.log('tick'), 100); // these still fire on time
await big;
clearInterval(t);
// AbortSignal cancellation (v0.7+) — same shape as fetch():
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 500);
try {
await db.get('SELECT count(*) FROM range(5e9) a, range(200) b', undefined, { signal: ctl.signal });
} catch (err) {
if (err instanceof DuckDBAbortError) console.log('canceled');
else throw err;
}See examples/async.mjs for the full surface.
Cancellation: async only.
AbortSignalis supported on every async op (query / exec / run / all / get / iterate / chunks / Statement.*); aborting firesduckdb_interrupton the worker's connection handle. The synchronousduckdb-bunAPI does not supportAbortSignal(sync FFI blocks the JS thread that would receive the abort event). For sync code, the safety valve for hung queries isdb.close({ timeout: ms }), which terminates the whole Worker — not a per-request primitive.
Why
Which package should I use?
| You're on... | You want... | Use |
|---|---|---|
| Bun | Embedded DuckDB, zero install friction | duckdb-bun (this package) |
| Bun | A query builder layered on top | duckdb-bun + Kysely/Drizzle (when those add support) |
| Bun | Multi-tenant DuckDB via HTTP | duckdb-bun + a thin HTTP wrapper, or duckdb-harbor (separate project) |
| Node | Embedded DuckDB | @duckdb/node-api (official) |
| Browser | DuckDB in WebAssembly | @duckdb/duckdb-wasm |
Why this over alternatives on Bun
| Option | Problem on Bun |
|---|---|
| @duckdb/node-api (official, N-API) | Native build required (node-gyp install dance), value-by-value marshaling overhead, verbose API designed for Node |
| @duckdb/duckdb-wasm | Browser-only — full DuckDB inside a 6 MB Wasm module is overkill for server-side Bun |
| node-duckdb (older) | Abandoned, last release before the chunk API |
duckdb-bun is built around four properties Bun developers actually want:
- Pure FFI, no install-time native build.
bun add duckdb-buninstalls a ~50 KB JS driver plus a small pre-built C shim for your platform (linux x64/arm64, darwin x64/arm64, win32 x64). Nogypstep, no compiler invocation on the user's machine, nopostinstallhooks. The only thing you need to install separately islibduckdbitself (brew install duckdb,apt install libduckdb-dev, the Windows zip, etc.). - Modern chunk-based API. Each
query()reads results as vector-batched chunks (typically 2048 rows per chunk) directly from DuckDB's column store viaBun.ffi.read, avoiding the per-value N-API roundtrips that make older Node bindings slow. - Bun-native, not a Node compatibility shim. Uses
bun:ffidirectly. No emulation layer, no Node FFI quirks. - Working on Linux x86_64. Bun's FFI has two real bugs that bite
any C library wrapper: opaque-handle-as-
'ptr'arguments get corrupted (segfault at0xFFFFFFFFFFFFFFFF), and structs-by-value cannot be passed at all on the SysV AMD64 ABI. This driver works around both — handles flow as'u64'/BigInt, and a tiny C shim wraps the three by-value DuckDB functions. Hard-won knowledge that isn't documented anywhere else publicly. See AGENTS.md for the full story.
Install
bun add duckdb-bunYou also need libduckdb (the DuckDB shared library) installed
somewhere duckdb-bun can find it. Common locations checked
automatically:
macOS: /opt/homebrew/lib/libduckdb.dylib
/usr/local/lib/libduckdb.dylib
/usr/lib/libduckdb.dylib
Linux: /usr/lib/libduckdb.so
/usr/local/lib/libduckdb.so
/usr/lib/x86_64-linux-gnu/libduckdb.so
/usr/lib/aarch64-linux-gnu/libduckdb.so
Windows: C:\Program Files\DuckDB\duckdb.dll
duckdb.dll (in PATH)Override with DUCKDB_LIB_PATH=/your/path/libduckdb.so (on Windows:
$env:DUCKDB_LIB_PATH = "C:\path\to\duckdb.dll").
Installing libduckdb
# macOS
brew install duckdb
# Debian/Ubuntu
sudo apt install libduckdb-dev
# Fedora/RHEL
sudo dnf install libduckdb
# Or download a release directly:
# https://github.com/duckdb/duckdb/releases# Windows: no package manager — download the release zip and point
# duckdb-bun at it via $env:DUCKDB_LIB_PATH.
#
# https://github.com/duckdb/duckdb/releases → libduckdb-windows-amd64.zip
#
# Extract somewhere, then either:
# - put the directory on PATH so Windows's DLL loader finds duckdb.dll, or
# - set $env:DUCKDB_LIB_PATH = "C:\path\to\duckdb.dll" (preferred).FFI shim — what's it for
The driver depends on one tiny C wrapper (lib/duckdb-shim.c, ~30
lines) around three DuckDB functions that take a 48-byte struct by
value — something Bun's FFI can't currently do directly on most
platforms. See AGENTS.md § FFI Bug 2
for details.
For npm users (the common case): the shim is pre-built and
shipped in the package for the four major platforms. bun add
duckdb-bun is everything you need to install — no make step, no
toolchain dependency.
| Platform | Shim shipped? |
|---|---|
| Linux x86_64 | ✅ lib/libduckdb-shim-linux-x64.so |
| Linux arm64 | ✅ lib/libduckdb-shim-linux-arm64.so |
| macOS arm64 (Apple Silicon) | ✅ lib/libduckdb-shim-darwin-arm64.dylib (also has a non-shim fallback that works on this platform) |
| macOS x86_64 (Intel) | ✅ lib/libduckdb-shim-darwin-x64.dylib |
| Windows x86_64 | ✅ lib/libduckdb-shim-win32-x64.dll (since v0.6.0) |
The pre-built shims are produced per-platform by the release workflow and bundled into the npm tarball before publish.
For source-clone / contributor use: build a local untagged shim once with the included Makefile.
# In a clone of the repo
make -C lib # → lib/libduckdb-shim.{so,dylib}
# Or platform-tagged (matches what CI ships):
make -C lib TAGGED=1 # → lib/libduckdb-shim-{platform}-{arch}.{so,dylib}The driver's findShimLibrary() searches in priority order: the
$DUCKDB_SHIM_PATH env override, then the platform-tagged shim,
then the untagged shim, then any shim next to libduckdb itself.
Override the search with DUCKDB_SHIM_PATH=/path/to/libduckdb-shim.so.
Quick start
import { open, version } from 'duckdb-bun';
console.log(version()); // → DuckDB version string
const db = open(':memory:'); // or open('mydata.duckdb') for on-disk
const conn = db.connect();
// Simple query, no parameters
const a = await conn.query('SELECT 42 AS answer');
// Parameterized query — '?' placeholders
const b = await conn.query('SELECT ? + ? AS sum', [3, 4]);
// DDL + DML
await conn.query('CREATE TABLE users (id INTEGER, name VARCHAR)');
const ins = await conn.query(
'INSERT INTO users VALUES (?, ?), (?, ?)',
[1, 'Alice', 2, 'Bob'],
);
console.log(`inserted ${ins.rowsChanged} rows`);
const rows = await conn.query('SELECT * FROM users ORDER BY id');
for (const row of rows) {
console.log(row); // { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }
}
conn.close();
db.close();For bulk insert see examples/appender.mjs.
Note:
conn.query()returns a Promise. The driver serializes FFI calls through an internal async lock to keep concurrent calls on a single Connection safe. The return value is awaitable. Note: FFI calls themselves are synchronous — long-running queries still block the Bun event loop. Useduckdb-bun/async(Worker- backed; same API) when event-loop responsiveness matters.
TypeScript
Type declarations ship with the package — import works in TS without
extra setup, and db.query<T>(sql) lets you narrow the row shape:
import { open, type QueryResult, type Statement } from 'duckdb-bun';
interface User { id: number; name: string }
using db = open(':memory:');
await db.exec('CREATE TABLE users (id INT, name VARCHAR)');
await db.run('INSERT INTO users VALUES (?, ?), (?, ?)',
[1, 'Alice', 2, 'Bob']);
const rows: QueryResult<User> = await db.all<User>(
'SELECT * FROM users ORDER BY id',
);
rows[0].id; // number
rows[0].name; // string
rows.columns; // ColumnInfo[]
rows.rowsChanged; // bigint
using stmt: Statement<User> = await db.prepare<User>(
'SELECT * FROM users WHERE id = ?',
);
const alice = await stmt.get([1]); // User | undefinedThe defaults are loose (Row = Record<string, unknown>) so untyped
calls work without ceremony — tighten only when you know the shape.
API reference
open(path, opts?) → Database
Opens (or creates) a DuckDB database at path. Pass ':memory:' for
an in-memory database.
const db = open(':memory:');
const db = open('analytics.duckdb');
const db = open('analytics.duckdb', { readOnly: true });
const db = open(':memory:', { threads: 4, memoryLimit: '2GB' });opts (v0.5+) is an optional OpenOptions bag:
| Field | Type | Notes |
|---|---|---|
| readOnly | boolean | Sugar for accessMode: 'READ_ONLY' |
| accessMode | 'AUTOMATIC' \| 'READ_ONLY' \| 'READ_WRITE' | Maps to DuckDB's access_mode |
| threads | number | Positive integer |
| memoryLimit | string | e.g. '1GB', '512MB', '80%' |
| tempDirectory | string | DuckDB's temp_directory |
| config | Record<string, string\|number\|boolean\|bigint> | Escape hatch for any DuckDB config key not exposed above |
Typed options and config setting the same DuckDB key with different
values throw DuckDBError; matching values are allowed. Throws if
DuckDB rejects the path or the config.
version() → string
Returns the version of the loaded libduckdb (e.g. 'v1.5.2').
Database
A Database lazily creates one default Connection on first use of
the shortcut methods (query, all, get, run, exec, prepare,
transaction). For parallelism or scoped lifetimes, call
db.connect() for an independent Connection.
| Method | Returns | Description |
|---|---|---|
| db.query(sql, params?) | Promise<QueryResult<T>> | Execute via the implicit Connection. |
| db.all(sql, params?) | Promise<QueryResult<T>> | Alias of query. |
| db.get(sql, params?) | Promise<T \| undefined> | First row, or undefined. |
| db.run(sql, params?) | Promise<{ rowsChanged }> | Execute for side effects (DML/DDL). |
| db.exec(sql) | Promise<void> | Fire-and-forget multi-statement; no params, no rows. |
| db.prepare(sql) | Promise<Statement<T>> | Returns a reusable Statement. Caller closes it. |
| db.iterate(sql, params?) | AsyncIterableIterator<T> | Stream rows row-by-row. Sugar that lazy-prepares a temp Statement on first .next(); closed in finally. (v0.3+) |
| db.chunks(sql, params?) | AsyncIterableIterator<RowChunk<T>> | Stream chunk-by-chunk; each yield is { rows, chunkIndex, rowOffset } (DuckDB vector ≈ 2048 rows). (v0.5+) |
| db.transaction(fn) | Promise<R> | BEGIN before, COMMIT after success, ROLLBACK + rethrow on throw. fn receives a scoped TxnHandle that throws on use after the callback returns (v0.5+). |
| db.pragma(name, value?) | Promise<Row \| undefined> | PRAGMA name (get) or PRAGMA name=value (set). Strict identifier validation. (v0.5+) |
| db.installExtension(name) | Promise<void> | INSTALL <name> with identifier validation. (v0.5+) |
| db.loadExtension(name) | Promise<void> | LOAD <name> with identifier validation. (v0.5+) |
| db.checkpoint(opts?) | Promise<void> | CHECKPOINT / FORCE CHECKPOINT / CHECKPOINT <db>. Flushes WAL on file-backed DBs. (v0.5.1+) |
| db.connect() | Connection | A fresh, independent Connection. |
| db.close() | Promise<void> | Closes the database and the implicit Connection. Idempotent. Async as of v0.3 (the public handle still nulls out synchronously so db.close(); db.handle === null continues to hold). |
| db[Symbol.dispose]() | void | Fires close().catch(...) — fire-and-forget. Enables using db = open(...). |
| db[Symbol.asyncDispose]() | Promise<void> | Awaits close(). Enables await using db = open(...). Preferred for streaming, and on Windows when reopening / deleting the same DB file — see Windows note below. (v0.3+) |
Connection
Connection exposes the same shortcut methods as Database, plus the
underlying lifecycle and bulk-insert primitives. Multiple Connections
on one Database run independently — use them for parallelism.
| Method | Returns | Description |
|---|---|---|
| conn.query(sql, params?) | Promise<QueryResult<T>> | Execute. One-shot for no params; prepare+execute+destroy if params given. |
| conn.all/get/run/exec/prepare/transaction | (same as Database) | Shortcuts mirror Database. |
| conn.iterate(sql, params?) | AsyncIterableIterator<T> | Stream rows. Sugar over prepare(sql).iterate(params); lazy temp Statement. (v0.3+) |
| conn.chunks(sql, params?) | AsyncIterableIterator<RowChunk<T>> | Stream chunk-by-chunk. (v0.5+) |
| conn.pragma(name, value?) | Promise<Row \| undefined> | PRAGMA get/set. (v0.5+) |
| conn.installExtension(name) | Promise<void> | INSTALL <name>. (v0.5+) |
| conn.loadExtension(name) | Promise<void> | LOAD <name>. (v0.5+) |
| conn.checkpoint(opts?) | Promise<void> | CHECKPOINT / FORCE CHECKPOINT / named. (v0.5.1+) |
| conn.append(table, columns, rows) | Promise<{ rows: number }> | Bulk insert via DuckDB's Appender API. Fastest path for loading many rows. See examples/appender.mjs. |
| conn.executeBatchPrepared(sql, batches) | Promise<{ rows: number }> | Advanced: execute one prepared statement multiple times with batched parameter sets. |
| conn.countStatements(sql) | number (sync) | Parses sql; returns the number of statements without executing. Throws on parse failure. |
| conn.close() | Promise<void> | Closes the connection. Idempotent. Async as of v0.3 (cancels active iterators before destroy; public handle nulls out synchronously). |
| conn[Symbol.dispose]() | void | Fires close().catch(...) — fire-and-forget. |
| conn[Symbol.asyncDispose]() | Promise<void> | Awaits close(). (v0.3+) |
| conn.handle | bigint \| null | Internal: handle to the underlying duckdb_connection. |
Statement
A reusable prepared statement. Created via db.prepare(sql) or
conn.prepare(sql). Holds the prepared handle until close() is
called. Reuse the same statement across many executions for
significant savings vs query(sql, params) which re-prepares each
time.
using stmt = await db.prepare('INSERT INTO t (id, name) VALUES (?, ?)');
for (const [id, name] of rows) await stmt.run([id, name]);| Method | Returns | Description |
|---|---|---|
| stmt.all(params?) | Promise<QueryResult<T>> | Bind, execute, return all rows. |
| stmt.get(params?) | Promise<T \| undefined> | First row, or undefined. |
| stmt.run(params?) | Promise<{ rowsChanged }> | Execute for side effects. |
| stmt.iterate(params?) | AsyncIterableIterator<T> | Bind + execute, then stream rows one at a time. Holds the owning Connection's lock for the iterator's lifetime — concurrent ops on that Connection queue. Use multiple db.connect() for parallel streams. (v0.3+) |
| stmt.chunks(params?) | AsyncIterableIterator<RowChunk<T>> | Same lock/lifecycle as iterate, but yields per-DuckDB-vector chunks of rows. Useful for batch processing. (v0.5+) |
| stmt.close() | Promise<void> | Free the prepared handle. Idempotent. Async as of v0.3 (cancels any active iterator before destroy; .closed still flips synchronously). |
| stmt[Symbol.dispose]() | void | Fires close().catch(...) — fire-and-forget. |
| stmt[Symbol.asyncDispose]() | Promise<void> | Awaits close(). (v0.3+) |
| stmt.closed | boolean | True after close. Subsequent calls throw DuckDBClosedError. |
Parameters are positional arrays — [1, 'foo'], not
(1, 'foo'). This avoids ambiguity with DuckDB LIST values.
Errors
All driver errors extend DuckDBError. Specific subclasses identify
common failure modes:
| Class | When it's thrown |
|---|---|
| DuckDBError | Generic driver error (DuckDB returned an error message) |
| DuckDBClosedError | Use of a closed Database / Connection / Statement |
| DuckDBPrepareError | prepare() failed (typically a SQL syntax error) |
| DuckDBTransactionError | Nested transactions (DuckDB does not yet support SAVEPOINT) or using a TxnHandle after its callback returned |
| DuckDBWorkerCrashedError | (duckdb-bun/async only) Worker exited unexpectedly. All pending request promises reject with this; future calls on any proxy from that Database reject with DuckDBClosedError. |
| DuckDBAbortError | (duckdb-bun/async only, v0.7+) Query was canceled via AbortSignal. .name === 'AbortError', .code === 'ERR_DUCKDB_ABORTED'. Compatible with code that checks err.name === 'AbortError' (fetch / ReadableStream / similar). |
import { DuckDBError, DuckDBClosedError } from 'duckdb-bun';
try {
await stmt.run([1]);
} catch (e) {
if (e instanceof DuckDBClosedError) {
// Statement was closed before this call
} else if (e instanceof DuckDBError) {
// Other DuckDB-side failure
}
}Result shape
conn.query() resolves to an Array of row objects with two
extra properties attached:
const rows = await conn.query('SELECT id, name FROM users');
rows // [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]
rows.length // 2
rows[0] // { id: 1, name: 'Alice' }
rows.columns // [
// { name: 'id', type: 4, typeName: 'INTEGER' },
// { name: 'name', type: 25, typeName: 'VARCHAR' },
// ]
rows.rowsChanged // 0n (BigInt; non-zero only for DML)The array IS the rows — no nested rows property. Iterate directly,
spread into [...rows], etc. Treat .columns and .rowsChanged as
metadata on the side.
For INSERT/UPDATE/DELETE the array is empty and .rowsChanged is
the number of affected rows (as BigInt — DuckDB returns this as
uint64, which is wider than Number.MAX_SAFE_INTEGER).
Type mapping
What you get back from a query, by DuckDB column type. The contract is geared toward "values stay JSON-safe by default; precision-sensitive types fall back to strings".
| DuckDB type | JavaScript value | Notes |
|---|---|---|
| BOOLEAN | boolean | |
| TINYINT, SMALLINT, INTEGER | number | always safe (32-bit) |
| UTINYINT, USMALLINT, UINTEGER | number | always safe (32-bit) |
| BIGINT, UBIGINT | number | lossy above 2^53 — see "Precision" below |
| HUGEINT, UHUGEINT | string | decimal string, full precision |
| FLOAT, DOUBLE | number | |
| DECIMAL | string | decimal string, full precision |
| VARCHAR, CHAR | string | UTF-8 |
| BLOB | Uint8Array | raw bytes, copied out of DuckDB-owned memory |
| DATE | string | "YYYY-MM-DD" |
| TIME, TIME_NS, TIME_TZ | string | ISO-ish formatted time (TZ includes +HH:MM) |
| TIMESTAMP, TIMESTAMP_S, TIMESTAMP_MS, TIMESTAMP_TZ | Date | UTC |
| TIMESTAMP_NS | Date | truncated to millisecond precision |
| INTERVAL | string | e.g. "3 months 2 days 1.5 seconds" |
| UUID | string | canonical 8-4-4-4-12 form |
| ENUM | string | dictionary lookup |
| LIST, ARRAY | Array | |
| STRUCT | object | plain {} |
| MAP | object | plain {} (keys stringified) — not a Map |
| NULL | null | |
| BIT, UNION | null | not yet decoded — surfaces as null |
Precision. BIGINT/UBIGINT are returned as number so they fit
naturally into JSON and arithmetic. Values beyond 2^53 lose precision.
If you need full precision, cast to HUGEINT/DECIMAL in SQL or read
the raw integer via a VARCHAR cast. A future opt-in mode (planned)
will let you elect bigint returns for BIGINT/UBIGINT and string
returns for DECIMAL/large integers from a single config.
DUCKDB_TYPE is exported as a frozen object mapping type names to
DuckDB's internal integer IDs, useful when introspecting result.columns.
Parameter binding
Use ? placeholders. Values are mapped to DuckDB types automatically:
| JS value | Bound as |
|---|---|
| null, undefined | NULL |
| boolean | BOOLEAN |
| number (integer) | BIGINT (duckdb_bind_int64) |
| number (non-integer) | DOUBLE |
| bigint | BIGINT |
| string | VARCHAR |
| Uint8Array, ArrayBuffer | BLOB (duckdb_bind_blob, byte-exact) |
| Date | VARCHAR ISO-8601 string (use CAST(? AS TIMESTAMP) for the typed form) |
| anything else | stringified via String(value) and bound as VARCHAR |
For typed parameters beyond what auto-detection picks, use explicit
SQL casts: 'SELECT CAST(? AS UINTEGER)'.
Performance notes
- Chunk-based reading. Result vectors are read directly via
Bun.ffi.read.u8/i32/u64/...against the chunk's underlying memory. This avoids per-value N-API/FFI overhead — the driver crosses the FFI boundary once per chunk (typically 2048 rows), not once per value. - Appender is dramatically faster than parameterized INSERT. For
loading thousands+ rows, use
conn.append(...)instead of looping onINSERT VALUES (?, ?, ...).- On a 2024 M-series Mac: 100,000 rows inserted in ~46ms (TIMESTAMP + INTEGER + DOUBLE columns) via Appender vs many seconds for the equivalent loop of single-row INSERTs.
- Internal serialization lock. Concurrent calls into a single
Connectionare serialized through a JS-level promise lock to match DuckDB's per-connection threading model. Use multipleConnections (db.connect()returns a fresh one each time) for parallelism. - FFI calls are synchronous, the public API is async. Each
query()returns a Promise that resolves once the (synchronous) FFI work completes. The Promise interface is the serialization mechanism, not a true off-thread runner — long-running analytical queries still block the Bun event loop while they execute. For a truly off-thread interface, useduckdb-bun/async(Worker-backed; same API surface, ~25% latency tax on small queries, but the main event loop stays responsive).
Examples
examples/basic.mjs—using db = open(...), db.exec/run/all/get, parametersexamples/prepared.mjs— prepared statements, transactions, real timingsexamples/appender.mjs— 100k bulk insert via the Appender APIexamples/iterate.mjs— streaming withstmt.iterate()/conn.iterate()/db.iterate(), early-break cleanup, parallel streams across two Connections (v0.3+)examples/async.mjs—duckdb-bun/asyncWorker-backed subpath: same API, event loop stays responsive during heavy queries (v0.4+)
Roadmap
For a full release-by-release history (including breaking changes, benchmarks, and design notes), see CHANGELOG.md.
Shipped
- v0.2 —
Database/Connection/Statementclasses; shortcut methods (query/all/get/run/exec/prepare/transaction);Symbol.dispose; TypeScript declarations; named error classes; pre-built shim binaries for Linux x64 / Linux arm64 / macOS x64 / macOS arm64. - v0.3 —
Statement.iterate(params?)for streaming large result sets;Connection.iterate(sql, params?)andDatabase.iterate(sql, params?)sugar (lazy-prepare); per-Connection locks (replaces the older process-global lock);[Symbol.asyncDispose]; asyncclose()(soft-breaking — public handles still null synchronously). - v0.4 —
duckdb-bun/asyncWorker-backed subpath. Identical API, every DuckDB call runs in a Worker so the main event loop stays responsive. Pull-based per-chunk streaming with configurableprefetch. StreamingAsyncAppenderwith proxy-side batching. - v0.5 — Core-polish release.
open(path, opts?)withOpenOptions(readOnly/accessMode/threads/memoryLimit/configescape hatch).pragma/installExtension/loadExtensionhelpers with strict identifier validation.chunks()chunk-by-chunk streaming on Statement / Connection / Database.TxnHandle— scoped transaction handle that throws on use after callback. - v0.5.1 —
checkpoint(opts?)helper (CHECKPOINT /FORCE CHECKPOINT/ named). - v0.6.0 — Windows x86_64 support. Pre-built shim DLL (MSVC build
via
lib/build.ps1), cross-platform path handling,windows-latestCI job,findDuckDBLibraryWindows paths,DUCKDB_LIB_PATHenv override. Bun 1.1+ required on Windows. - v0.7.0 —
AbortSignalper-query cancellation on the async subpath. Every async op accepts{ signal?: AbortSignal }; aborts fireduckdb_interrupton the worker's connection handle. NewDuckDBAbortErrorclass. AsyncDatabase shortcuts route through a lazy implicitAsyncConnectionfor stable cancellation identity. Per-conn serialization on the main thread.
Planned
- v1.0 — API freeze. Optional companion packages
(
duckdb-bun-kysely,duckdb-bun-drizzle).
Likely later
- Configurable type conversion — opt-in
BIGINT → bigintmode for users hitting values >2^53 (snowflake IDs, nanosecond timestamps, hashes). CurrentlyBIGINT → number(documented sharp edge); users canCAST(? AS HUGEINT)orCAST(? AS VARCHAR)for full precision today. - Nested transactions via
SAVEPOINT. Blocked on upstream DuckDB: v1.5.2's parser does not yet acceptSAVEPOINT.tx.transaction()is reserved on the API surface so it becomes a non-breaking add when upstream lands it.
Explicitly not planned
- Query builder, ORMs, models / repositories / Active Record
- Migrations
- Schema introspection beyond what DuckDB's
PRAGMA show_tables/DESCRIBEalready gives you - Connection pooling — DuckDB is in-process, pooling is the wrong shape
The driver stays a driver. Higher layers belong in separate packages.
Building from source
git clone https://github.com/shreeve/duckdb-bun
cd duckdb-bun
bun install # no deps actually, just for lockfile
bun test # requires libduckdb installed
make -C lib # builds libduckdb-shim for current platformTesting
bun testTests are skipped automatically if libduckdb is not installed (the
import fails and the test suite no-ops with describe.skip).
Compatibility
- Bun: ≥ 1.1 (Bun added Windows support in 1.1; 1.0 still works on Linux/macOS but the package now declares 1.1 as the minimum so the npm metadata matches reality)
- DuckDB: any modern release with the chunk API (≥ v0.10 recommended; tested against v1.5.x)
- Platforms (shipped shims): macOS arm64, macOS x64, Linux x64, Linux arm64, Windows x64
- Windows arm64: not shipped — compile-only support isn't enough for a native/FFI package and we can't currently runtime-test on Windows arm64 in CI. Open an issue if you'd use it.
Windows disposal note
Windows enforces exclusive file locks on open DuckDB databases — you
cannot reopen or delete a DB file until DuckDB releases its handle.
using db = open(path) calls the synchronous Symbol.dispose, which
is fire-and-forget (it doesn't await the underlying async close). On
Unix this is harmless; on Windows it means code like
{ using db = open(path); await db.exec('...'); }
const db2 = open(path); // Windows: "file is already open"races the close. If you need to reopen or delete the same file, use one of:
// Preferred: TC39 explicit-resource-management with async dispose
await using db = open(path);
// Or just be explicit
const db = open(path);
try { /* ... */ } finally { await db.close(); }using is still fine for any DB you don't reopen during its
lifetime — which is the common case.
Related
- DuckDB — the database
- DuckDB C API docs
- Bun FFI docs
License
MIT © Steve Shreeve
