chDB Node.js bindings — an in-process ClickHouse engine for Node, Bun and Deno.
v3 (Layer 1) is in development. The v2
query/queryBind/SessionAPI is preserved (your v2 code keeps working); v3 adds async queries, server-side parameter binding, inserts, streaming, and Arrow output.
npm i chdbPrebuilt native binaries ship as per-platform subpackages (@chdb/lib-*,
resolved via optionalDependencies) — no local compilation, no node-gyp, no
Python. First-batch platforms: Linux x64/arm64 (glibc) and macOS x64/arm64.
Windows is not supported (use WSL2).
const { query, queryAsync, insert, Session } = require("chdb"); // or: import { ... } from "chdb"
// Sync standalone query (v2-compatible, returns a string)
console.log(query("SELECT version(), 'Hello chDB'", "CSV"));
// Async query (non-blocking) -> ChdbResult (text() / json() / bytes() + metrics)
const r = await queryAsync("SELECT number FROM numbers(5)", { format: "JSONEachRow" });
console.log(r.rowsRead, r.elapsed);
// Server-side parameter binding (no SQL injection surface)
const { queryBind } = require("chdb");
console.log(queryBind("SELECT {n:UInt32} * 2 AS v", { n: 21 }, "CSV")); // 42
// Session: persistent/in-memory database
const session = new Session(); // temp dir; or new Session("./data")
session.query("CREATE TABLE t (id UInt32, name String) ENGINE = MergeTree() ORDER BY id");
// Insert (inline, async; never reads stdin)
await session.insert({ table: "t", values: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] });
// Streaming (chunk-by-chunk, no full buffering)
for await (const row of session.queryStream("SELECT * FROM t").rows()) {
console.log(row);
}
// Arrow output (no serialization on your side)
const a = await session.queryAsync("SELECT * FROM t", { format: "arrow" });
const table = a.toArrow(); // requires the optional `apache-arrow` peer dep
// const bytes = a.bytes(); // raw Arrow IPC if you bring your own Arrow
session.close(); // (cleanup() is an alias; `using` is supported too)Errors are typed (ChdbSyntaxError, ChdbQueryError, ChdbConnectionError,
ChdbBindError, ChdbInsertError, ChdbStreamError, ChdbArrowError,
ChdbAbortError, ChdbTimeoutError, …), each carrying .code, the ClickHouse
.clickhouseCode, and .cause.
libchdb binds a single data directory per process, so opening a Session takes
the slot the stateless query/queryAsync calls were using and closes their
connection. A connection closed while an operation is still running on it aborts
the engine for the rest of the process, so new Session() refuses instead:
const { queryAsync, Session, drainPending } = require("chdb");
const p = queryAsync("SELECT max(sipHash64(number)) FROM numbers(20000000)");
new Session(); // throws: 1 standalone operation is still running
await p;
new Session(); // fineAwaiting your own promise is not always enough. An aborted or timed-out call
rejects immediately while the engine keeps computing, and close() returns
before the connection is really gone when an operation is still using it.
drainPending() waits for both:
const ac = new AbortController();
const p = queryAsync("SELECT max(sipHash64(number)) FROM numbers(20000000)", {
signal: ac.signal,
});
ac.abort();
await p.catch(() => {}); // rejected, but the engine is still computing
await drainPending(); // now the connection is actually free
const s = new Session("./data");Moving between directories works the same way: after session.close(), wait with
drainPending() before opening one at a different path. Opening another session
at the same path needs no wait — those connections coexist by design.
The refusal is enforced in the addon, on a per-connection in-flight count, so
it holds for every entry point rather than only the ones that remember to
check. chdb/durable's engine reaches the addon directly and gets the same
protection.
Some of what an embedder needs cannot be a SET: --config-file is not a
setting, and the arguments deciding how a data directory loads have already
taken effect before the first query could run.
const s = new Session("./data", {
connectionArgs: [
"--async_load_databases=0", // load databases synchronously
"--async_load_system_database=0", // and the system tables too
"--tables_loader_foreground_pool_size=4",
"--restore_threads=1",
"--output_format_json_quote_64bit_integers=1",
"--config-file=/etc/my-chdb.xml",
],
});Anything the engine accepts on a clickhouse local command line is passed
through verbatim. Two forms are refused: --path, since the data directory is
the connection registry's key and comes from the first argument, and anything
containing a NUL byte, which would truncate the argument at the C boundary and
let the next entry become its value.
Behaviour change. Earlier versions did not refuse — they closed the busy connection, which usually aborted the engine and on macOS could leave a query whose promise never settled. Code that opened a session without awaiting its standalone queries now gets an error at the call site instead of a failure somewhere later.
| Capability | Status |
|---|---|
| Stateless query (sync + async) | ✅ |
| Session (persistent / in-memory) | ✅ |
Server-side parameter binding ({name:Type}) |
✅ |
| Insert (object / positional rows) | ✅ |
Streaming results (AsyncIterable) |
✅ |
Arrow output (format: 'arrow' + toArrow()) |
✅ |
| AbortSignal / timeout | ✅ (single-shot is honest: rejects early; native runs to completion) |
Arrow scan (registerArrowTable, Arrow input) |
⏳ follow-up |
Arrow zero-copy (M2, { zeroCopy: true }) |
⏳ follow-up |
chDB ↔ @clickhouse/client integration (chdb/connection, experimental) |
✅ |
Durable V1 control plane (chdb/durable, experimental) |
✅ pure TS, engine injected |
Default durable engine over the addon (chdb/durable/node) |
✅ |
Remote object storage for durable (chdb/durable/s3) |
✅ verified on AWS S3 and MinIO; R2 untested |
Status: this integration uses the experimental
createClient({ connection })hook in@clickhouse/client(clickhouse-js#879 merged; framing follow-up #880 merged). Upstream considers this a deliberately narrow chDB-only hook — not a public plugin system — and the shape may change. We'll keepchdb/connectionworking against whatever the upstream hook evolves into.
For users coming from @clickhouse/client, chdb-node ships a
Connection implementation under the chdb/connection subpath that
plugs into @clickhouse/client's createClient({ connection })
injection point (tracking issue:
clickhouse-js#865).
import { createChdbConnection } from 'chdb/connection'
const conn = createChdbConnection({ path: ':memory:' })
const r = await conn.query({ query: 'SELECT * FROM numbers(5)', format: 'JSONEachRow' })
let body = ''
for await (const chunk of r.stream) body += Buffer.from(chunk).toString('utf8')
console.log(JSON.parse(`[${body.trim().split('\n').join(',')}]`))
await conn.close()
// chDB-specific escape hatches (raw ChdbResult, raw insert, session info)
conn.chdb.queryAsync('SELECT 1', { format: 'arrow' }) // bytes/text/json/toArrow
conn.chdb.session.path // bound on-disk pathSee docs/design/pluggable-connection.md
for the full design, the Connection interface, the .chdb extension
namespace, the tests/clickhouse-js/skip_list.json parity blacklist,
and the sync policy with @clickhouse/client.
Status: implements chDB Durable V1 as specified in
CHDB_DURABLE_V1_CONTRACT.mdin the chdb repository, which is the source of truth for the protocol. The control plane is pure TypeScript and the engine is injected; Node callers get a default engine over this package's own addon fromchdb/durable/node.Requires an engine exporting the durable ABI — currently
26.7.3. Compatibility is a floor rather than an equality: an object recordsmin_readerandbackup_format, and any engine at or above that floor opens it. An object written by26.7.2-rc.2stays readable on26.7.3and later.
chdb/durable makes an embedded chDB database recoverable on a different
machine: a full checkpoint plus a statement WAL in object storage, with a
single head.json updated by compare-and-swap under a fenced writer lease.
Importing chdb/durable loads no native code — not the addon, not
libchdb. The engine arrives as an EngineAdapter, which is what lets a Bun
process that already owns its own dlopen(libchdb) reuse the state machine
without a second engine in it. The companion chdb/libchdb subpath resolves
where the library is without opening it.
Node callers do not have to write that adapter: chdb/durable/node re-exports
the control plane plus one over this package's addon. Importing it loads no
native code either — the addon arrives when open() first builds an engine,
which is also where the ABI is checked, so a stale addon fails there naming
what is missing. Backup, restore and statement classification run on the libuv
pool, so a checkpoint of a large database neither freezes the event loop nor
starves the lease heartbeat. chdb-core binds one data path per process and each
object needs a private one, so a process holds one open durable object at a
time and no ordinary Session beside it — fan-out goes across worker
processes.
The local backend answers to both file: and local:, because a namespace URL
gets shared between services in different languages and Python spells it
local: while Go takes either.
Recovery on another machine needs the object to live somewhere neither machine
owns, so chdb/durable/s3 provides an S3-compatible backend — AWS S3,
Cloudflare R2 and MinIO through one implementation. It sits behind its own
subpath, and @aws-sdk/client-s3 is an optional peer dependency, so callers
who only use the local backend never install it.
import { DurableNamespace, nodeEngineFactory } from 'chdb/durable/node'
import 'chdb/durable/s3' // registers the s3:// scheme
const ns = new DurableNamespace('s3://my-bucket/durable?region=eu-west-1', {
engineFactory: nodeEngineFactory(),
})
const obj = await ns.open('orders', { database: 'default' })
const ticket = await obj.execute("INSERT INTO events VALUES (1, 'a')")
await obj.flushThrough(ticket) // durability barrier; execute() alone is not one
await obj.checkpoint()
await obj.close() // rejects if the final flush or lease release failedWrites go through ClickHouse's own parser, not a regex: execute() takes
exactly one MUTATING statement that core can prove writes only inside this
object's database and embeds no credential, and query() takes exactly one
READ_ONLY statement. Method names are not the gate.
obj.stats gives a consistent snapshot for a status endpoint or log line —
lease generation, committed sequence, pending statements and bytes, last flush
and checkpoint times — with no credentials or SQL in it. onRestoreProgress
reports each phase of a recovery, so a slow restore is distinguishable from a
stuck one.
The conditional writes S3 needs are real preconditions on PutObject
(If-None-Match: * and If-Match: <etag>), never a HEAD followed by a PUT.
A provider counts as supported once it has passed
test/durable/s3-backend.test.ts, which is parameterised for exactly that.
AWS S3 and MinIO have; Cloudflare R2 has not been run yet.
See docs/design/durable-control-plane.md for the object layout, the lease and fencing rules, the ambiguous-commit reconcile, both backends' compare-and-swap, and what V1 deliberately leaves out.
- Layered API design: the Layer 1 / Layer 2 / Layer 3 architecture, package shape, and intended user-facing surfaces.
- Layer 1 native binding reviewer guide: the PR #43 design and implementation map, organized by commit and review feedback.
- chDB ↔
@clickhouse/clientintegration (experimental): thechdb/connectionsurface, theConnectioninterface chdb-node implements, the.chdbextension namespace, and the parity-test sync policy. - Durable V1 control plane (experimental): the
chdb/durable,chdb/durable/nodeandchdb/libchdbsubpaths, theEngineAdapterseam, lease/fencing/reconcile behaviour, and the V1 boundary.
A single N-API binary serves Node 18/20/22 + Bun + Deno.
npm install # JS deps only (no compile-on-install)
npm run libchdb # download libchdb for this platform
npm run build # node-gyp build + fix loader path + tsc (dist)
npm run test:all # v2 (mocha) + v3 (vitest)
npm run build:platform # package this platform's @chdb/lib-* subpackage