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.

46databases verified by the full compat workload
18distinct ODBC drivers exercised, three of them built from source
up to 734×faster bulk ingest after the write-path rework (IBM Db2, 600 → 440,492 rows/s)
1.2–1.5×faster than the native PostgreSQL ADBC driver on fetch, on the quiet Linux reference host — 0.97× busy, 0.60× on an M4 Max, and why
46 × 5databases × languages measured on one binary — the full grid; 229 of 230 language × database cells carry a number; the one without (C# on Azure SQL Edge) is explained

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.

DatabaseWire / ODBC driverIngestpyodbcFetchpyodbcNative ADBCNotes

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

Re-measured 2026-08-24. An earlier version of this section claimed 1.83× on a 1,000,000-row fetch. Our benchmark harness was importing the cheap 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, interleavedoursnativeratiovs 1.2× bar
1 M rows, fetch, 8 partitions (ctid)0.187 s0.227 s1.21×PASS narrow
1 M rows, fetch, 8 partitions — quiet host, 3 runs0.166 s0.234 s1.37–1.52×PASS
1 M rows, fetch, 8 partitions — 2026-08-24, 13 containers up, load 4.6–5.70.218 s0.262 s1.20×PASS on the line
1 M rows, fetch, 8 partitions — 2026-08-24, 46 containers idling, load 3.0–3.30.266 s0.259 s0.97×FAIL
1 M rows, fetch, 8 partitions — 2026-08-24, PostgreSQL alone, load 2.0–2.20.184 s0.233 s1.27×PASS
1 M rows, fetch, 8 partitions — macOS, Apple M4 Max, PostgreSQL 15, psqlodbc from source, not idle0.314 s0.189 s0.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 s1.919 s1.55×PASS
1 M rows, ingest, 16 connections0.317 s0.322 s1.02×FAIL
1 M rows, ingest, 8 and 16 connections — 2026-08-24, PostgreSQL alone0.447–0.451 s0.325–0.334 s0.73–0.74×FAIL
10 M rows, ingest, 16 connections3.486 s2.417 s0.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.

Ingest lands at parity, and the ceiling is structural. An 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.

ServerStrategyParallel readsvs native pg driver
PostgreSQL, plain heapctidunchanged1.21× @1M · 1.55× @10M
PostgreSQL, partitioned parentkey rangenew — used to get 1 partition1.36× (was 0.42×)
CockroachDB v26.3key rangenew — 5.1× @N=8, 6.5× @N=16no baseline exists
YugabyteDB 2026.1yb_hash_code()new — 2.6× over one connection1.18–1.22× @4M
adbcBridge is the only ADBC path to CockroachDB. 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.
YugabyteDB sits on the bar and was not called a pass. 1.210× and 1.216× at N=8, 1.181× at N=16 — two runs differing only in partition count straddle 1.2×. Measured reason: on PostgreSQL the client costs 4.3× the server, and that lopsidedness is what partitioning exploits; on Yugabyte the two are equal, and splitting 8 ways multiplies server CPU 4× while client CPU grows 1.7×. Rebuilding with SPLIT INTO 8 TABLETS changed nothing.
Three guarantees keep every row in exactly one slice. The key column's 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.

Databasebeforeafter×what it was
IBM Db2 12.1600440,492734×we created LONG VARCHARORDER BY/GROUP BY/DISTINCT all SQL0134N
StarRocks 4.1.4104,783478×one INSERT per row is a backend load transaction
Apache Doris 2.1.072,184312×same; both plateau past K=500, and Stream Load is out of ODBC's reach
CrateDB 6.482049,98661×multi-row batching
GreptimeDB 1.1.45,171180,76035×multi-row batching
YDB 23.4551,75932×capped by its own SQL text — its PG wire cannot bind a NULL
openGauss 6.010,174220,46522×multi-row batching
MatrixOne 4.24,42297,49222×multi-row batching
RisingWave 3.01,71131,91019×multi-row batching
SQL Server 2022172,081233,3031.4×we created TEXT — deprecated since 2005, not even comparable
Google Spanner2157,28634×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.

SQL Server: ingest now creates 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.

DatabasePythonRustC#JavaGoWhere a cell is empty
ingestfetchingestfetchingestfetchingestfetchingestfetch

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.

The ratio view, read per client, shows where the driver's lead is largest. Against PostgreSQL, the C# and Go clients are the ones this driver leaves furthest behind on ingest (21× and 30×), because 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 rowsbeforeafterodbc-apiratio before → after
SQLite735k1,910k2,050k0.36× → 0.94×
MariaDB 111,842k2,860k2,890k0.62× → 1.02×
MySQL 8.41,209k1,513k1,760k0.69× → 0.92×
PostgreSQL 161,940k2,054k2,090k0.93× → 0.98×
SQL Server 2022831k844k~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

Reachable ≠ verified. The ODBC ecosystem spans a few hundred sources. The 46 above are the ones that passed the full workload against a real server or file. All but a few of the drivers needed at least one workaround, so an untested driver should be expected to work on the generic path and to have a quirk waiting.
Drivers are the real unit. 46 databases ride on 18 drivers: psqlodbc fronts 14 of them and MySQL Connector/ODBC 11. Three of those drivers were built from source for Linux here — Apache Ignite's and OpenSearch's have no Linux release, and OpenSearch's POSIX branch had never been compiled at all and needed source fixes before it built; TDengine's own driver builds from its repository. That's useful — H2, QuestDB, CrateDB and Databend all broke psqlodbc/myodbc in ways plain PostgreSQL/MySQL never did — but it's what the number means.
Reached only outside ODBC: H2 and GreptimeDB's PostgreSQL wire (psqlodbc's connect handshake sends 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).
Need an account to verify: Snowflake, BigQuery, Redshift/Athena, Databricks, Synapse (the same msodbcsql as SQL Server), Cassandra/DataStax, Hive/Impala/Spark, Trino, SAP HANA, Teradata — free or registration-walled drivers, hosted-only or licence-bound servers; expected to work on the generic path, not verified. Queued: Exasol (needs a privileged container and 4 GB).

Driver fixes found along the way

Open, and stated rather than hidden. Ingest does not reach 1.2× against native PostgreSQL and the WAL measurement says it cannot over ODBC — parity on a good day, 0.73× on an ordinary one. The fetch win is real but conditional on a host that is not busy. YugabyteDB's fetch straddles the bar. The language grid's one fully empty Linux cell is C# on Azure SQL Edge; every other ADBC dash is either a step a server or driver cannot take (read-only stores, no transactions, no PRIMARY KEY) or a binding-side failure, each written up with its error. The parallel-ingest test that was flaky is now deterministic (0/40 failures against 8/40 before).