← All notes

Notes · 2026-10-27

QuestDB over QWP - an ADBC driver on QuestDB's own wire protocol, measured against the ODBC route

QuestDB asked for ADBC over QWP rather than the PostgreSQL wire. adbcBridge now ships adbc_driver_questdb, built on QuestDB's C client - 2M rows into Arrow in 0.125 s against 1.47 s over psqlodbc - plus two findings: one in the server's QWP egress, one in the wire format.

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 ExecuteQuery borrows a reader from the database's connection pool; the ArrowArrayStream owns 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 executemany works; ExecuteUpdate reports 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 timestamp whatever the data calls it, so the driver issues CREATE TABLE from 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 polls wal_tables() until writerTxn has caught up with sequencerTxn before returning; a SELECT that follows sees the rows.
  • Catalog from tables() and table_columns(); GetTableSchema types columns exactly as the client types results (dictionary-encoded SYMBOL, the arrow.uuid extension, 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.

  1. DECIMAL with precision 9 or less cannot be read over QWP. This is the server: a SELECT of a DECIMAL(8,2), DECIMAL(4,1) or DECIMAL(2,0) column fails with java.lang.UnsupportedOperationException: QWP egress: unsupported column type DECIMAL(8,2), thrown by QuestDB's QwpColumnTypeMapper, while DECIMAL(18,3), DECIMAL(19,2) and DECIMAL(40,5) stream and decode as decimal64(18,3), decimal128(38,2) and decimal256(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).
  2. 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 as list<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 field questdb.array_dim_tentative=true and 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.