QuestDB over QWP - an ADBC driver on QuestDB's own wire protocol, measured against the ODBC route
On 2026-10-27 QuestDB answered a request for ADBC support (questdb/roadmap#126) on its community forum: since QuestDB 10.0 the preferred protocol is QWP, QuestDB's own columnar wire protocol over WebSocket; the PostgreSQL wire stays for compatibility; and if ADBC comes, it should come over QWP.
That is a reasonable ask, and the pieces for it already existed in QuestDB's official C client
(c-questdb-client, Apache-2.0, release 7.2.0 of
2026-10-21): SQL over QWP with positional bind parameters, every result batch handed back through
the Arrow C Data Interface, and Arrow batch ingestion. An ADBC driver over it is a thin shell. So
adbcBridge now has a second driver, adbc_driver_questdb, merged the same day
(adbcbridge#166, docs in
getting-started/questdb-qwp).
What the driver is
An ADBC 1.1.0 driver, one C file over the client's public API, built as its own CMake project because the client is written in Rust. The client is fetched as a hash-pinned source tarball and linked statically, so the result is a single library for the ADBC driver manager to load.
- Results are the client's own Arrow batches passed through. Each
ExecuteQueryborrows a reader from the database's connection pool; theArrowArrayStreamowns reader and cursor and returns the reader on release, so several statements can stream at once. - Bind parameters run the statement once per bound row, so
executemanyworks;ExecuteUpdatereports the server's row count. - Bulk ingest in the four ADBC modes goes through the client's Arrow sender. QWP's own
auto-create names the designated timestamp column
timestampwhatever the data calls it, so the driver issuesCREATE TABLEfrom the Arrow schema first and the table matches the caller's names and types. The server acknowledges a frame before its rows are visible (WAL apply is asynchronous), so the driver pollswal_tables()untilwriterTxnhas caught up withsequencerTxnbefore returning; aSELECTthat follows sees the rows. - Catalog from
tables()andtable_columns();GetTableSchematypes columns exactly as the client types results (dictionary-encoded SYMBOL, thearrow.uuidextension, decimal storage classes), and a test compares the two. - No transactions. QuestDB has none over QWP; switching autocommit off returns
NOT_IMPLEMENTED, as the ADBC specification allows.
Tested against QuestDB 10.1.0 in CI: eighteen cases through the ADBC driver manager (every column
type including NULLs, binds, executemany, the four ingest modes, catalog, errors, timeouts) and
a C smoke test.
Measured: three routes, one machine
The workload from the earlier note,
with a read of 2,000,000 rows (long_sequence, five typed columns: long, double, int, string,
timestamp) into a pyarrow Table, best of three, and a 500,000-row adbc_ingest (symbol, double,
long, timestamp) verified by count(). QuestDB 10.1.0 in Docker; pyarrow 25.0.1;
adbc_driver_manager 1.12.0.
| Route | Connect | Read 2M rows | Ingest 500k rows |
|---|---|---|---|
adbc_driver_questdb (QWP, c-questdb-client 7.2.0) |
2 ms | 0.125 s (16.0M rows/s) | 0.084 s, acknowledged and WAL-applied |
| adbcBridge over psqlodbc 18 (PostgreSQL wire) | 29 ms | 1.47 s (1.36M rows/s) | not re-measured (needs the autocommit setting from the compatibility entry) |
| Apache Arrow's PostgreSQL ADBC driver 1.12.0 (PostgreSQL wire) | fails: pg_type bootstrap query |
The QWP read is about twelve times the ODBC route's throughput. That is the protocol doing the work: QWP sends columns, typed and compressed, and the client adopts the buffers into Arrow where it can; the PostgreSQL wire sends rows as text that psqlodbc parses and adbcBridge re-columnarises.
Two findings on the way, and where each belongs
Both turned up while writing the type test, both have a reproduction in the driver's test suite, both are recorded in UPSTREAM.md. The first report of them, on the forum, put both on the C client; reading the code places them differently.
DECIMALwith precision 9 or less cannot be read over QWP. This is the server: aSELECTof aDECIMAL(8,2),DECIMAL(4,1)orDECIMAL(2,0)column fails withjava.lang.UnsupportedOperationException: QWP egress: unsupported column type DECIMAL(8,2), thrown by QuestDB'sQwpColumnTypeMapper, whileDECIMAL(18,3),DECIMAL(19,2)andDECIMAL(40,5)stream and decode asdecimal64(18,3),decimal128(38,2)anddecimal256(76,5). The wire protocol has kinds for the DECIMAL64, DECIMAL128 and DECIMAL256 storage classes only; the server refuses the three smaller classes instead of widening them to the DECIMAL64 kind, which they fit with the same scale. That widening is the fix: questdb/questdb#8111, opened 2026-10-28 with a test across the three classes (34 of 34 in the egress type suite on master).- An all-NULL multi-dimensional array column comes out flat. This is the wire format, and
the client handles it as well as it can: a
DOUBLE[][]column whose every value in a batch is NULL arrives aslist<double>, because the protocol carries an array's dimensions per value, not in the column header, so an all-NULL batch has no dimension count to go on. The client marks the fieldquestdb.array_dim_tentative=trueand upgrades the schema once a value arrives; a consumer that pins the first batch's schema, as most ADBC consumers do, sees a schema change mid-stream. A dimension count in the column header would close it.
Two smaller observations for anyone mapping types: CHAR arrives as a uint16 UTF-16 code unit,
and QuestDB's BYTE, SHORT, CHAR and BOOLEAN have no NULL, so they read back as 0 or false.
The client also reports a DECIMAL(10,2) column as decimal64(18, 2): the storage class's
precision, not the declared one.
What this changes for QuestDB users
For QuestDB 10 or newer, the native driver is the route: no ODBC driver manager, no psqlodbc, no PostgreSQL-wire emulation in the path, and an order of magnitude more throughput. The ODBC route still works for QuestDB 9 and older, and stays in the compatibility matrix as measured. The driver is Apache-2.0 and built to be lifted out if QuestDB would rather own it.