npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

duckdb-bun

v0.7.0

Published

Efficient DuckDB driver for Bun, using pure FFI

Readme

duckdb-bun

npm version License: MIT Bun ≥1.1 DuckDB CI TypeScript

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-dev
import { 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 throw

For 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. AbortSignal is supported on every async op (query / exec / run / all / get / iterate / chunks / Statement.*); aborting fires duckdb_interrupt on the worker's connection handle. The synchronous duckdb-bun API does not support AbortSignal (sync FFI blocks the JS thread that would receive the abort event). For sync code, the safety valve for hung queries is db.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-bun installs a ~50 KB JS driver plus a small pre-built C shim for your platform (linux x64/arm64, darwin x64/arm64, win32 x64). No gyp step, no compiler invocation on the user's machine, no postinstall hooks. The only thing you need to install separately is libduckdb itself (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 via Bun.ffi.read, avoiding the per-value N-API roundtrips that make older Node bindings slow.
  • Bun-native, not a Node compatibility shim. Uses bun:ffi directly. 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 at 0xFFFFFFFFFFFFFFFF), 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-bun

You 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. Use duckdb-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 | undefined

The 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 on INSERT 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 Connection are serialized through a JS-level promise lock to match DuckDB's per-connection threading model. Use multiple Connections (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, use duckdb-bun/async (Worker-backed; same API surface, ~25% latency tax on small queries, but the main event loop stays responsive).

Examples

  • examples/basic.mjsusing db = open(...), db.exec/run/all/get, parameters
  • examples/prepared.mjs — prepared statements, transactions, real timings
  • examples/appender.mjs — 100k bulk insert via the Appender API
  • examples/iterate.mjs — streaming with stmt.iterate() / conn.iterate() / db.iterate(), early-break cleanup, parallel streams across two Connections (v0.3+)
  • examples/async.mjsduckdb-bun/async Worker-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.2Database / Connection / Statement classes; 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.3Statement.iterate(params?) for streaming large result sets; Connection.iterate(sql, params?) and Database.iterate(sql, params?) sugar (lazy-prepare); per-Connection locks (replaces the older process-global lock); [Symbol.asyncDispose]; async close() (soft-breaking — public handles still null synchronously).
  • v0.4duckdb-bun/async Worker-backed subpath. Identical API, every DuckDB call runs in a Worker so the main event loop stays responsive. Pull-based per-chunk streaming with configurable prefetch. Streaming AsyncAppender with proxy-side batching.
  • v0.5 — Core-polish release. open(path, opts?) with OpenOptions (readOnly / accessMode / threads / memoryLimit / config escape hatch). pragma / installExtension / loadExtension helpers 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.1checkpoint(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-latest CI job, findDuckDBLibrary Windows paths, DUCKDB_LIB_PATH env override. Bun 1.1+ required on Windows.
  • v0.7.0AbortSignal per-query cancellation on the async subpath. Every async op accepts { signal?: AbortSignal }; aborts fire duckdb_interrupt on the worker's connection handle. New DuckDBAbortError class. AsyncDatabase shortcuts route through a lazy implicit AsyncConnection for 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 → bigint mode for users hitting values >2^53 (snowflake IDs, nanosecond timestamps, hashes). Currently BIGINT → number (documented sharp edge); users can CAST(? AS HUGEINT) or CAST(? AS VARCHAR) for full precision today.
  • Nested transactions via SAVEPOINT. Blocked on upstream DuckDB: v1.5.2's parser does not yet accept SAVEPOINT. 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 / DESCRIBE already 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 platform

Testing

bun test

Tests 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

License

MIT © Steve Shreeve