adbcBridge — what it reaches, and how fast
A plain-C ADBC driver that turns any ODBC driver into an Arrow-native one. The Linux figures on this page were measured on one Linux box (Linux Mint 22.3, Core i9-13900HK, 31 GiB) on 2026-08-23 and 24 with servers in local Docker, build main@db03415; the macOS and Windows campaigns that followed are summarised in the per-platform line below and detailed in their own files.
Per-database results
Workload: table (int32, float64, varchar(20), date). Ingest = adbc_ingest of 10,000 rows, DDL + data + commit, row count verified (driver default: parameter arrays where the ODBC driver handles them, else a multi-row INSERT batch in one transaction). Fetch = SELECT * of 100,000 rows into Arrow, median of 3. pyodbc = the same thing through pyodbc (executemany / fetchall()→Arrow). Rows/s, higher is better; click a header to sort. Bars are log-scale.
| Database | Wire / ODBC driver | Ingest | pyodbc | Fetch | pyodbc | Native ADBC | Notes |
|---|
Native ADBC = the same fetch after adbc.odbc.delegate=auto hands the connection to the database's own ADBC driver (measured here for SQLite and PostgreSQL; the same hand-over covers DuckDB, Snowflake, BigQuery and Flight SQL URIs when that native driver is installed) — that is what you get from the same install when a native driver is present. † Oracle and Db2 quiet-host figures from the ingest rework run; the sweep later that night ran while a stuck CockroachDB container held five cores and read ~1k / 50k for both us and pyodbc. ‡ ClickHouse at 300 rows: clickhouse-odbc issues one HTTP request per row. Read-only drivers (Access, Flight SQL ODBC) have no ingest.
Against the native PostgreSQL driver, conditions attached
adbc_driver_manager before starting our clock, but the expensive adbc_driver_postgresql.dbapi inside the native driver's clock — measured at ~190 ms against a native run of ~330 ms. We were charging the reference for its own startup. Corrected below. Our own timings did not move (0.186 s → 0.187 s); only the reference's did.The native adbc_driver_postgresql talks libpq and pulls COPY … (FORMAT binary) straight into Arrow buffers. On one connection through psqlodbc we cannot match that and never will — psqlodbc decodes PostgreSQL's text protocol into strings we parse back, which is strictly more work. The win comes from doing something the native driver does not: splitting one query across N connections with ADBC's ExecutePartitions.
| Corrected — mean of 3, fresh process per run, interleaved | ours | native | ratio | vs 1.2× bar |
|---|---|---|---|---|
| 1 M rows, fetch, 8 partitions (ctid) | 0.187 s | 0.227 s | 1.21× | PASS narrow |
| 1 M rows, fetch, 8 partitions — quiet host, 3 runs | 0.166 s | 0.234 s | 1.37–1.52× | PASS |
| 1 M rows, fetch, 8 partitions — 2026-08-24, 13 containers up, load 4.6–5.7 | 0.218 s | 0.262 s | 1.20× | PASS on the line |
| 1 M rows, fetch, 8 partitions — 2026-08-24, 46 containers idling, load 3.0–3.3 | 0.266 s | 0.259 s | 0.97× | FAIL |
| 1 M rows, fetch, 8 partitions — 2026-08-24, PostgreSQL alone, load 2.0–2.2 | 0.184 s | 0.233 s | 1.27× | PASS |
| 1 M rows, fetch, 8 partitions — macOS, Apple M4 Max, PostgreSQL 15, psqlodbc from source, not idle | 0.314 s | 0.189 s | 0.60× | FAIL native reads 5.3 M rows/s in this run (6.4 M rows/s in the single-process matrix read) |
| 10 M rows, fetch, 12 partitions (ctid) | 1.237 s | 1.919 s | 1.55× | PASS |
| 1 M rows, ingest, 16 connections | 0.317 s | 0.322 s | 1.02× | FAIL |
| 1 M rows, ingest, 8 and 16 connections — 2026-08-24, PostgreSQL alone | 0.447–0.451 s | 0.325–0.334 s | 0.73–0.74× | FAIL |
| 10 M rows, ingest, 16 connections | 3.486 s | 2.417 s | 0.69× | FAIL |
Threshold harness: bench/native_threshold.py, mean of three runs, a fresh process per timed run, sides interleaved A/B/A/B so load drift lands on both, every checksum verified against a reference read. The 1.21× row was taken at load average 7–9; the quiet-host row is three separate runs at load 2.4–3.0 (1.410×, 1.521×, 1.372×). Quote the low end. The three 2026-08-24 rows are the same build and harness on one day: load average did not predict the result, the number of idle containers did — their wake-ups fragment the eight cores the partitioned read needs, and the native driver's single stream barely notices. Ingest that day sat at 0.73× against 1.02× the day before; running the previous day's build on the same host gave 0.59×, so that is the host, not the driver — and 0.73× is the ingest number to plan around. Our side needs eight cores where the native driver needs about 1.5, so a busy machine costs us more than it costs the reference.
INSERT writes 96.4 MB of WAL per million rows; COPY writes 48.8 MB — one WAL record per row against one per page-batch. That is a property of the statement PostgreSQL is executing, and no statement an ODBC driver can send avoids it (CREATE TABLE AS is identical: 88.3 vs 88.3 MB). COPY … FROM STDIN through psqlodbc does not fail, it hangs — libpq enters PGRES_COPY_IN with no PQputCopyData reachable. We went from 0.16× to parity; parity is the ceiling over ODBC. If you want native write speed on PostgreSQL, adbc.odbc.delegate=auto already hands the job to the native driver.Parallel reads beyond ctid: key ranges and tablet hashes
ctid is a PostgreSQL heap detail, and the databases that most need a driver do not have it. Verified against live servers: CockroachDB answers 42703 column "ctid" does not exist; YugabyteDB answers 0A000 system column "ctid" is not supported yet. The reader now picks among three split strategies.
| Server | Strategy | Parallel reads | vs native pg driver |
|---|---|---|---|
| PostgreSQL, plain heap | ctid | unchanged | 1.21× @1M · 1.55× @10M |
| PostgreSQL, partitioned parent | key range | new — used to get 1 partition | 1.36× (was 0.42×) |
| CockroachDB v26.3 | key range | new — 5.1× @N=8, 6.5× @N=16 | no baseline exists |
| YugabyteDB 2026.1 | yb_hash_code() | new — 2.6× over one connection | 1.18–1.22× @4M |
adbc_driver_postgresql reads via COPY … FORMAT binary, which CockroachDB does not implement — it cannot fetch a single row. adbcBridge is the only ADBC driver that works there. That is a stronger claim than "faster than native", and it applies to a family: fourteen databases in the matrix ride the PostgreSQL wire, and Yugabyte, Cockroach, Citus, Timescale, Cloudberry and openGauss have no ADBC driver of their own — their users reach for adbc_driver_postgresql, which is exactly the driver measured above.SPLIT INTO 8 TABLETS changed nothing.NOT NULL is read from the catalog rather than inferred from "it is a primary key" — a NULL compares false against every bound and would vanish from all K slices. Half-open intervals put a repeated key in exactly one slice. Unbounded outer slices mean a stale MIN/MAX costs balance, never rows. Everything is gated on ODBC's own SQLPrimaryKeys/SQLColumns, not any server's system tables. Modulo and LIMIT/OFFSET were measured, not assumed: modulo is 3.7× worse on CockroachDB; LIMIT/OFFSET is 3.0× behind the key range there, and on YugabyteDB it is slower than not splitting at all (0.8×).The write path: where the real multiples were
The largest wins of the whole project were not against native drivers — they were against our own floor. Two of them were correctness bugs in the DDL adbcBridge generates, not speed bugs.
| Database | before | after | × | what it was |
|---|---|---|---|---|
| IBM Db2 12.1 | 600 | 440,492 | 734× | we created LONG VARCHAR — ORDER BY/GROUP BY/DISTINCT all SQL0134N |
| StarRocks 4.1.4 | 10 | 4,783 | 478× | one INSERT per row is a backend load transaction |
| Apache Doris 2.1.0 | 7 | 2,184 | 312× | same; both plateau past K=500, and Stream Load is out of ODBC's reach |
| CrateDB 6.4 | 820 | 49,986 | 61× | multi-row batching |
| GreptimeDB 1.1.4 | 5,171 | 180,760 | 35× | multi-row batching |
| YDB 23.4 | 55 | 1,759 | 32× | capped by its own SQL text — its PG wire cannot bind a NULL |
| openGauss 6.0 | 10,174 | 220,465 | 22× | multi-row batching |
| MatrixOne 4.2 | 4,422 | 97,492 | 22× | multi-row batching |
| RisingWave 3.0 | 1,711 | 31,910 | 19× | multi-row batching |
| SQL Server 2022 | 172,081 | 233,303 | 1.4× | we created TEXT — deprecated since 2005, not even comparable |
| Google Spanner | 215 | 7,286 | 34× | PGAdapter accepts >950 params, then drops the connection at execute |
Db2 at 100,000 rows, SQL Server at 20,000, Spanner at 300; the rest at 10,000. Db2's read-back went 92,989 → 1,565,590 rows/s and SQL Server's 859,215 → 3,172,747 as a side-effect of the type fix alone. One behaviour change to weigh: VARCHAR(32672) holds 28 bytes less than LONG VARCHAR, so a Db2 ingest of a string sized 32,673–32,700 bytes now fails where it used to succeed.
NVARCHAR(MAX), so a WHERE on a string column works. The driver names TEXT for an unbounded string, so that is what ingest asked for — and WHERE s = 'a' on a TEXT column is error 402. A text_sortable matrix test now reads an ingest-created table back with ORDER BY, GROUP BY and DISTINCT so this cannot regress quietly.Five languages × 46 databases, one binary
Five packages: the same library ships as a Python wheel (adbcbridge, library bundled), a Rust crate (adbcbridge, builds the driver from bundled sources), a NuGet package (AdbcBridge, native assets per runtime), a Maven library (org.adbcbridge:adbcbridge, natives inside the jar) and a Go module (github.com/singhpratech/adbcbridge/go) — each built and tested in the repository with a SELECT through SQLite and attached to every release (v0.1.0 is the first); registry publication follows.
Per platform: Linux 229 of 230 cells (the table below) · macOS arm64 191 cells, campaign complete (40 databases: 36 with all five languages, SQLite and DuckDB without Rust, TDengine with Python and Rust, PostgreSQL with Python — the MySQL-wire tier through MariaDB Connector/ODBC 3.2.9, with Databend, GreptimeDB, Doris and StarRocks through MySQL Connector/ODBC 26.7.1 on an iODBC-built bridge, as were the four iODBC-width drivers; in bench/LANGUAGE_BENCHMARKS-macos.md) · Windows 11 x64 219 of 230 cells, all five languages on all 46 databases on a 14-core machine under Docker Desktop on WSL2 (the first machine's 142 cells kept as history); the eleven open cells each carry their reason — the Spanner emulator wedges under the workload for all five languages, YDB hung once for Rust, Java and C#, and Rust on DuckDB (the driver's C++ exception across FFI), TDengine (a driver assert) and Ignite (harness quoting) — in bench/LANGUAGE_BENCHMARKS-windows.md. All three campaigns are closed; every empty cell carries its reason.
The driver is a plain C shared library: Python, Rust, C#, Java and Go each dlopen it and call AdbcDriverInit. Same workload everywhere — ingest 10,000 rows in one transaction, then read 100,000 back into that language's own Arrow batches — one database per run, median of 3 after a warmup, adbc.odbc.delegate=never so every row really travels over ODBC. The point of this table is agreement across a row, not any single number: on YDB all five languages land within 1,597–1,742 rows/s of ingest; on StarRocks within 4,764–4,828. That is what "the same driver from any language" looks like when it is true.
Cells are tinted by how close they sit to the best language on that row.
| Database | Python | Rust | C# | Java | Go | Where a cell is empty | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| ingest | fetch | ingest | fetch | ingest | fetch | ingest | fetch | ingest | fetch | ||
Rows/s; — is a step that did not finish, and every one of them is written up with the exact error in bench/LANGUAGE_BENCHMARKS.md ("Why a cell is empty"). The ratio view divides by the ordinary non-Arrow path that language already has — pyodbc, odbc-api, System.Data.Odbc, JDBC, database/sql+alexbrainman/odbc — which is not comparable across languages: each uses the bulk API its client actually offers, and the Java column needs a JDBC driver the pom only carries for SQLite and PostgreSQL. Databases whose ODBC driver refuses SQL_ATTR_AUTOCOMMIT (Flight SQL, Dremio, MongoDB BI) or whose server has no transactions (Databend, Doris, StarRocks, YDB) ran the four non-python harnesses with ADBC_BENCH_AUTOCOMMIT=1, which puts them on python's footing.
System.Data.Odbc and database/sql insert one row per execute; Rust's odbc-api already array-binds, so there it is closer to parity (6.3×). On fetch the picture is flatter — every ADBC column lands within a factor of two of the best language on most rows — because the work that matters is inside the shared C library, not the binding. Two kinds of empty cell remain and neither is a mystery: servers that are read-only over ODBC (fetch only), and drivers that crash the comparison path in a language binding (Go's arrow-go finalizer, alexbrainman/odbc under psqlodbc and maodbc); the four non-Python harnesses now apply each entry's ingest types, refresh step and read-only fixture, so those cells are filled.Against a real column-buffered client (Rust)
pyodbc builds a Python object per cell, so beating it is expected. The stricter comparison is the Rust odbc-api / arrow-odbc crates reading the same table through the same ODBC driver. Before today we were behind on drivers that describe TEXT columns as "unbounded" — our reader refused to bind them, which collapsed the rowset to one row per SQLFetch (100,000 fetches where odbc-api did 13). Wide text is now bound at 2 KiB and cells that overflow are re-read, so nothing is truncated and the rowset stays large.
| Fetch 100k rows | before | after | odbc-api | ratio before → after |
|---|---|---|---|---|
| SQLite | 735k | 1,910k | 2,050k | 0.36× → 0.94× |
| MariaDB 11 | 1,842k | 2,860k | 2,890k | 0.62× → 1.02× |
| MySQL 8.4 | 1,209k | 1,513k | 1,760k | 0.69× → 0.92× |
| PostgreSQL 16 | 1,940k | 2,054k | 2,090k | 0.93× → 0.98× |
| SQL Server 2022 | 831k | 844k | ~810k | ~1.0× |
SQL Server is unchanged by design: msodbcsql offers neither re-read route, so VARCHAR(MAX) stays one row per fetch — already at parity with odbc-api.
What "any ODBC source" means, measured
SHOW/SET GUCs neither implements — GreptimeDB is in the table via its MySQL wire instead), libSQL server (dropped its PG-wire listener), Elasticsearch and the Microsoft Text/Excel drivers (Windows-only).Driver fixes found along the way
- Firebird was writing NULLs over your data. A NULL bound as
SQL_C_DEFAULTon aSQL_BIGINTparameter — whose ODBC default C type isSQL_C_CHAR, notSQL_C_SBIGINT— makes OdbcFb retype that parameter permanently, so every 64-bit integer after the first NULL in a column was written as NULL. Pre-existing on every path. It hid because the ingest test's NULLs were in the last row; the payload now puts a value after every NULL. - Generated ingest DDL asked for types the server can barely use — Db2
LONG VARCHARand SQL ServerTEXT, both unsortable, one of them 700× slower to write. Also fixedCREATE_PARAMSbeing matched case-sensitively against lowercase literals (IBM writesLENGTH). - A driver that would not load, and a message that lied about why. Importing pyarrow makes glibc pin libstdc++ to dynamic TLS; MySQL Connector/ODBC is the one driver here with initial-exec relocations against it, so it can then never be loaded — and unixODBC reports that as
file not foundfor a file that plainly exists. The driver now opens the path itself and appends the realdlerror()plus the fix, and the Python package loads the ODBC driver before pyarrow can poison it. Eleven matrix entries ride that driver. - Oracle segfaulted on any block fetch of a CLOB, and it was the driver, not our buffers. SQORA sizes its per-rowset LOB state when the statement executes; raising
SQL_ATTR_ROW_ARRAY_SIZEon an open cursor then dereferences a null slot insidelibsqora(reproduced with plainSQLBindCol/SQLFetch, AddressSanitizer clean on our side), and lowering it silently ends the result set early — 100,000 rows read back as 14,336. The reader now settles the rowset before the first fetch and never moves it there, and a column with no real declared width is read a row at a time. Ingest DDL spells an Arrow string as CLOB on Oracle, so ingest-then-read-back used to crash on the default path; all five languages now fetch it (66k–122k rows/s — the row-at-a-time protocol, not us:odbc-apireads the same table at 136k). - On Windows, a non-ASCII literal in statement text was stored double-encoded. The Windows driver manager reads narrow text as the ANSI code page; every string this driver handed to
SQLExecDirect,SQLPrepare,SQLDescribeColorSQLGetDiagRecwas UTF-8 read as cp1252 —WHERE s = 'héllo'matched nothing, a column namedprix_€became invalid UTF-8 in the Arrow schema, CJK was lost outright. Bound parameters and fetched values were always right, which hid it; unixODBC passes narrow text through, which is why Linux and macOS never saw it. Found by the first human build on Windows; fixed with the W entry points behind a UTF-8 layer, and the compat workload now has a statement-literal step so a PASS on Windows means something. - Firebird was storing every string as a BLOB. Generated ingest DDL took
SQLGetTypeInfo(SQL_LONGVARCHAR)'s answer,BLOB SUB_TYPE TEXT, and OdbcFb reads a BLOB one row at a time: a table with one such column read back at 8,256 rows/s against 1,004,277 without it, andodbc-apiread it at the same 7,500 — the type, not the reader. Found because all five languages agreed on a number that made no sense. Strings are nowVARCHAR(8191): 40,670 rows/s in instead of 5,705, 289k–295k out, filterable. - Two things that were poisoning the numbers themselves. The four non-python harnesses followed a failed
DROP TABLEwith a literalROLLBACK, after which sqliteodbc neverBEGINs again and every SQLite run read "—"; gone, SQLite re-measured with autocommit off in all four. Andcockroachdb/cockroach:latestwas spending 6–7 cores in its garbage collector while idle (288 MiB Go soft limit) during every benchmark on this host —--max-go-memory=2GiBin the compose file. - A lock leak on close —
SQLDisconnectwith a transaction open is refused by sqliteodbc (25000); we ignored it and leaked the handle and its read lock, hanging the next writer. Found because a benchmark hung. - The fetch rowset cliff — one column a driver describes as unbounded collapsed the whole rowset to a single row per
SQLFetch. Wide text is now bound at 2 KiB with overflow re-read, and the bind width adapts on measured bytes per row. - A ceiling no probe can find (Spanner), never-written
SQLRowCount(CrateDB), standard-SQL ingest DDL andtrue/falsebooleans (QuestDB, Databend),GetObjectsfallback whenSQLColumnsfails (QuestDB, Flight SQL), a thirdINSERTform for Firebird, which has neither multi-rowVALUESnorINSERT ALL.