adbcBridge is a single C library that turns any ODBC driver on your machine
into an Apache Arrow ADBC driver. Point it at a database you already reach
through ODBC — Db2, Oracle, SQL Server, SQLite, PostgreSQL, ClickHouse, Vertica,
SAP HANA, or anything else with an ODBC driver — and read the results as Arrow
record batches from Python, Rust, C#, Java, Go, R or C, with no per-database
code of your own.
Two acronyms show up on every page, so here they are once:
ODBC (Open Database Connectivity) — the decades-old C API almost every
database ships a driver for. You talk to a driver manager (a small system
library), which loads the vendor driver for your database.
ADBC (Arrow Database Connectivity) — a newer C API whose result sets are
Apache Arrow columnar batches instead of ODBC's row-at-a-time buffers. ADBC
clients exist for many languages.
adbcBridge is the piece in between.
What it is
One shared library — libadbc_driver_odbc.so on Linux,
libadbc_driver_odbc.dylib on macOS, libadbc_driver_odbc.dll on Windows —
written in plain C11. It exposes the ADBC ABI (Application Binary Interface,
the exact function-pointer layout an ADBC client expects) on top, and calls the
ODBC driver manager underneath. It is exactly one file. The driver manager and
the vendor ODBC drivers are already yours; adbcBridge does not replace them or
bundle them.
Python · Rust · C# · Java · Go · R · C your application + its ADBC client
│ ADBC ABI (1.0.0 / 1.1.0)
▼
libadbc_driver_odbc.{so,dylib,dll} adbcBridge — one shared library
│ ODBC API
▼
unixODBC · iODBC · the Windows driver manager the driver manager (yours)
│
▼
psqlodbc · msodbcsql · Db2 CLI · Oracle · … the vendor ODBC driver (yours)
│
▼
your database
A SELECT becomes Arrow record batches; parameters, prepared statements,
transactions, bulk ingest, and catalog metadata all map onto the ODBC calls
underneath. Where a database already has a first-class native ADBC driver
(PostgreSQL, SQLite, DuckDB, and others), adbcBridge can hand the whole
connection over to it — see Native delegation.
Who it is for
You need Arrow-native access to a database that has no native ADBC driver,
and you would rather not hand-roll ODBC-to-Arrow conversion.
You already have ODBC drivers installed and configured, and want every
language's ADBC client to use them.
You are building a data tool and want one code path that reaches hundreds of
databases instead of one integration per vendor.
You do not need to know ODBC to use adbcBridge — the getting-started pages walk
you through installing a driver manager and a driver from scratch — but the
reference pages explain what is happening whenever you
want to look under the hood.
How it works
Your language's ADBC client (for example adbc_driver_manager in Python)
loads libadbc_driver_odbc and calls its ADBC entry point, AdbcDriverInit.
You pass an ODBC connection string as the ADBC uri option, for example
Driver=SQLite3;Database=my.db;. adbcBridge hands it to the ODBC driver
manager, which loads the vendor driver and connects.
adbcBridge reads each result set through ODBC's block-fetch and column-wise
binding calls and builds Arrow record batches, which flow back to your
application through the ADBC ABI.
The library is discoverable by name: an install writes a small ADBC driver
manifest (odbc.toml) into a directory the ADBC driver manager already
searches, so every binding can load it as driver="odbc" with nothing else set.
See Connection strings and
Options for the details.
Current status
adbcBridge is early software — version 0.1.3, published on
GitHub Releases.
Verified as of 2026-09-04 (rows measured on 0.1.0; 0.1.1 changed native delegation only, re-probed on the 14 PostgreSQL-wire rows on 2026-09-05; the 2026-09-15 releases changed PostgreSQL temporal types, Windows array ingest and the psqlodbc session time zone, re-run on 42 Linux rows and then on the 14 PostgreSQL-wire rows that day)
five — Python, Rust, C#, Java, Go (R is smoke-tested)
ADBC ABI
1.0.0 and 1.1.0
Release
v0.1.3: four prebuilt libraries, four wheels + sdist, crate, NuGet package, jar; on PyPI, crates.io, nuget.org and Maven Central; SHA256SUMS, its GPG signature, build provenance and an SBOM on the release (how to verify)
Each verified database is listed, with the exact driver and any quirks handled,
in COMPATIBILITY.md. Reachability is far wider than
verification: adbcBridge can talk to anything with an ODBC driver, and the ODBC
ecosystem covers a few hundred data sources. What the numbers above count is what
has actually been run end to end. See ROADMAP.md for what is
planned.
Tip: The three per-operating-system numbers differ because of driver and
server availability on each machine, not because the bridge behaves
differently. Where a database is missing on one platform it is almost always
because, as of 2026-09-04, no obtainable ODBC driver or runnable server
exists there.
adbcBridge is a single C library that turns any ODBC driver on your machine
into an Apache Arrow ADBC driver. Point it at a database you already reach
through ODBC — Db2, Oracle, SQL Server, SQLite, PostgreSQL, ClickHouse, Vertica,
SAP HANA, or anything else with an ODBC driver — and read the results as Arrow
record batches from Python, Rust, C#, Java, Go, R or C, with no per-database
code of your own.
Two acronyms show up on every page, so here they are once:
ODBC (Open Database Connectivity) — the decades-old C API almost every
database ships a driver for. You talk to a driver manager (a small system
library), which loads the vendor driver for your database.
ADBC (Arrow Database Connectivity) — a newer C API whose result sets are
Apache Arrow columnar batches instead of ODBC's row-at-a-time buffers. ADBC
clients exist for many languages.
adbcBridge is the piece in between.
What it is
One shared library — libadbc_driver_odbc.so on Linux,
libadbc_driver_odbc.dylib on macOS, libadbc_driver_odbc.dll on Windows —
written in plain C11. It exposes the ADBC ABI (Application Binary Interface,
the exact function-pointer layout an ADBC client expects) on top, and calls the
ODBC driver manager underneath. It is exactly one file. The driver manager and
the vendor ODBC drivers are already yours; adbcBridge does not replace them or
bundle them.
Python · Rust · C# · Java · Go · R · C your application + its ADBC client
│ ADBC ABI (1.0.0 / 1.1.0)
▼
libadbc_driver_odbc.{so,dylib,dll} adbcBridge — one shared library
│ ODBC API
▼
unixODBC · iODBC · the Windows driver manager the driver manager (yours)
│
▼
psqlodbc · msodbcsql · Db2 CLI · Oracle · … the vendor ODBC driver (yours)
│
▼
your database
A SELECT becomes Arrow record batches; parameters, prepared statements,
transactions, bulk ingest, and catalog metadata all map onto the ODBC calls
underneath. Where a database already has a first-class native ADBC driver
(PostgreSQL, SQLite, DuckDB, and others), adbcBridge can hand the whole
connection over to it — see Native delegation.
Who it is for
You need Arrow-native access to a database that has no native ADBC driver,
and you would rather not hand-roll ODBC-to-Arrow conversion.
You already have ODBC drivers installed and configured, and want every
language's ADBC client to use them.
You are building a data tool and want one code path that reaches hundreds of
databases instead of one integration per vendor.
You do not need to know ODBC to use adbcBridge — the getting-started pages walk
you through installing a driver manager and a driver from scratch — but the
reference pages explain what is happening whenever you
want to look under the hood.
How it works
Your language's ADBC client (for example adbc_driver_manager in Python)
loads libadbc_driver_odbc and calls its ADBC entry point, AdbcDriverInit.
You pass an ODBC connection string as the ADBC uri option, for example
Driver=SQLite3;Database=my.db;. adbcBridge hands it to the ODBC driver
manager, which loads the vendor driver and connects.
adbcBridge reads each result set through ODBC's block-fetch and column-wise
binding calls and builds Arrow record batches, which flow back to your
application through the ADBC ABI.
The library is discoverable by name: an install writes a small ADBC driver
manifest (odbc.toml) into a directory the ADBC driver manager already
searches, so every binding can load it as driver="odbc" with nothing else set.
See Connection strings and
Options for the details.
Current status
adbcBridge is early software — version 0.1.3, published on
GitHub Releases.
Verified as of 2026-09-04 (rows measured on 0.1.0; 0.1.1 changed native delegation only, re-probed on the 14 PostgreSQL-wire rows on 2026-09-05; the 2026-09-15 releases changed PostgreSQL temporal types, Windows array ingest and the psqlodbc session time zone, re-run on 42 Linux rows and then on the 14 PostgreSQL-wire rows that day)
five — Python, Rust, C#, Java, Go (R is smoke-tested)
ADBC ABI
1.0.0 and 1.1.0
Release
v0.1.3: four prebuilt libraries, four wheels + sdist, crate, NuGet package, jar; on PyPI, crates.io, nuget.org and Maven Central; SHA256SUMS, its GPG signature, build provenance and an SBOM on the release (how to verify)
Each verified database is listed, with the exact driver and any quirks handled,
in COMPATIBILITY.md. Reachability is far wider than
verification: adbcBridge can talk to anything with an ODBC driver, and the ODBC
ecosystem covers a few hundred data sources. What the numbers above count is what
has actually been run end to end. See ROADMAP.md for what is
planned.
Tip: The three per-operating-system numbers differ because of driver and
server availability on each machine, not because the bridge behaves
differently. Where a database is missing on one platform it is almost always
because, as of 2026-09-04, no obtainable ODBC driver or runnable server
exists there.
This page takes you from a machine with nothing installed to a working
adbcBridge that a five-line Python program can query. There are three
ingredients, installed in this order:
an ODBC driver manager (the system library that loads database drivers);
one or more vendor ODBC drivers (one per database you want to reach);
adbcBridge itself (the single library that presents ODBC to Arrow ADBC).
Every command below is copy-paste ready. Package names are shown for
Debian/Ubuntu (apt) and Fedora/RHEL (dnf); translate to your distribution as
needed.
1. The ODBC driver manager: unixODBC
ODBC (Open Database Connectivity) drivers are loaded by a driver manager. On
Linux the driver manager is unixODBC. (There is a second driver manager,
iODBC, but it is not needed on Linux — every driver in adbcBridge's test suite
loads through unixODBC. iODBC only matters on macOS, and only for a few vendor
drivers; see Installing on macOS.)
Install the runtime and the development headers — you need the headers only if
you will build adbcBridge from source:
Confirm it is there and see where it keeps its configuration:
odbcinst -j
odbcinst -j prints the paths of odbcinst.ini (the file that lists installed
drivers) and odbc.ini (the file that lists named data sources, or
DSNs). You will not usually edit these by hand — most driver packages register
themselves — but knowing where they are makes troubleshooting far easier.
2. Example ODBC drivers
You need one vendor driver per database. These are the ones adbcBridge's own
test suite uses, all installable from distribution packages or vendor
repositories:
# Debian/Ubuntu: the three that are in the base repositories
sudo apt install libsqliteodbc odbc-postgresql odbc-mariadb
msodbcsql18 (Microsoft's SQL Server driver) and clickhouse-odbc come from vendor
repositories; follow each vendor's Linux instructions. After installing any
driver, check that the driver manager registered it:
odbcinst -q -d # list the driver names now in odbcinst.ini
Tip: A driver name from this list (for example SQLite3 or PostgreSQL
Unicode) is what you put after Driver= in a connection string. You can also
put the full path to the driver's .so there instead — adbcBridge accepts
either. See Connection strings.
3. adbcBridge
Pick whichever of these fits how you work. All four leave you with the same
library and, except where noted, the same by-name discovery.
From the GitHub Release (prebuilt library)
Each release attaches a .tar.gz holding the prebuilt library for a platform.
On Linux the assets are named adbcbridge-<tag>-linux-x64.tar.gz (x86-64) and
adbcbridge-<tag>-linux-arm64.tar.gz (ARM64), built on manylinux_2_28 so they
run on any reasonably recent glibc-based distribution.
sha256sum -c SHA256SUMS --ignore-missing # SHA256SUMS is on the same release page
tar xzf adbcbridge-v0.1.3-linux-x64.tar.gz
# → linux-x64/libadbc_driver_odbc.so
The checksum file is signed by the release key (SHA256SUMS.asc); releases after v0.1.0 also
carry a build-provenance attestation for every asset and an SBOM; Security
shows how to check each.
Point adbcBridge's clients at it with the ADBC_ODBC_DRIVER environment
variable (see environment variables),
or copy it into ~/.local/lib and write a manifest as the source
and install.sh paths do.
From the Python wheel (bundles the library)
The Python wheel carries the compiled library inside it, so pip install gives
you both the binding and the driver in one step:
pip install adbcbridge # from PyPI
# or, from a downloaded release wheel (the aarch64 wheel is named likewise):
pip install adbcbridge-0.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
pip install adbc-driver-manager pyarrow
The wheel's own adbcbridge.driver_path() finds the bundled library, so nothing
else has to be set. The native library still needs unixODBC present at run time
(libodbc.so.2) and the vendor driver for your database — those are never
bundled.
From source (cmake)
You need a C compiler, CMake 3.16+, and unixodbc-dev:
cmake --install writes two things: the library into
lib/libadbc_driver_odbc.so under the prefix, and the driver manifest into
~/.config/adbc/drivers/odbc.toml. ADBCBRIDGE_MANIFEST_DIR is given as an
absolute path because its default (etc/adbc/drivers, relative to the prefix)
would put the manifest in ~/.local/etc/adbc/drivers, a directory the ADBC
driver manager does not search, so driver="odbc" would not resolve. See
The driver manifest for how that manifest makes
driver="odbc" resolve.
Via install.sh
install.sh is the no-root, no-thinking path: it configures, builds, and
installs into your home directory, and writes the manifest into the ADBC user
config directory so discovery just works.
./install.sh
It puts the library in ~/.local/lib/libadbc_driver_odbc.so (lib64 on
Fedora/RHEL-style 64-bit systems) and the manifest in
~/.config/adbc/drivers/odbc.toml, then prints both paths. Re-running it is
safe — it reconfigures the same build tree and overwrites the same two files. It
honours these environment overrides:
Variable
Meaning
Default
PREFIX
install prefix for the library
$HOME/.local
MANIFEST_DIR
directory to write odbc.toml into
${XDG_CONFIG_HOME:-$HOME/.config}/adbc/drivers
BUILD_DIR
CMake build tree
<repo>/build
BUILD_TYPE
CMake build type
Release
JOBS
parallel build jobs
nproc
Troubleshooting: If install.sh stops with cmake not found, install the
build prerequisites first — on Debian/Ubuntu sudo apt install cmake
unixodbc-dev.
The ADBC driver manifest
A driver manifest is a small TOML file that lets the ADBC driver manager find
a driver by a short name. adbcBridge installs one called odbc.toml, so any
ADBC client can load the bridge as driver="odbc" with no path and no
environment variable.
Where it is installed
cmake --install --prefix P writes it to P/etc/adbc/drivers/odbc.toml
unless -DADBCBRIDGE_MANIFEST_DIR points elsewhere, as the recipe above does.
install.sh writes it to ~/.config/adbc/drivers/odbc.toml — the ADBC
user config directory, which the driver manager searches automatically.
What is inside
The manifest names the library per platform, under a [Driver.shared] key that
is an operating-system-plus-architecture tuple:
The key is linux_amd64 on a normal (glibc) x86-64 system and
linux_arm64 on ARM64. On a musl-based distribution (Alpine, for example)
the driver manager appends a _musl suffix and looks up linux_amd64_musl
instead — adbcBridge detects musl the same way the driver manager does and
writes the matching key, so a manifest built on musl carries linux_amd64_musl.
Troubleshooting: If driver="odbc" silently loads the wrong library (or
unixODBC's own libodbc.so), the manifest's platform key probably does not
match what the driver manager is looking up. Rebuild adbcBridge on the target
machine so the key is generated for that libc, rather than copying a manifest
between glibc and musl systems.
How driver="odbc" resolves
When a client asks for driver="odbc", the ADBC driver manager searches its
manifest directories — $ADBC_DRIVER_PATH, then ~/.config/adbc/drivers, then
/etc/adbc/drivers, and a few more — for odbc.toml, reads the path under the
platform key, and loads that library. Because install.sh writes the manifest
into one of those default directories, driver="odbc" works with noADBC_DRIVER_PATH and noLD_LIBRARY_PATH set.
Environment variables the library and bindings honour
You rarely need any of these once the manifest is installed, but they are the
escape hatches:
Variable
Read by
Effect
ADBC_ODBC_DRIVER
every binding's driver finder
Absolute path to libadbc_driver_odbc.so to use. Set but non-existent is an error, not a silent fallback.
ADBC_DRIVER_PATH
the ADBC driver manager, and adbcBridge's delegation search
Extra directory (or directories) to search for driver manifests.
ADBCBRIDGE_LIBRARY
the Rust, C#, Java and Go finders
Same idea as ADBC_ODBC_DRIVER; where both are read, the order is documented on each language page.
ADBCBRIDGE_PRELOAD
the Python package
0 disables the automatic ODBC-driver preload that adbcbridge.connect() does before importing pyarrow.
Extra directories to search for native ADBC drivers to delegate to (ignored for setuid/setcap processes).
Verifying the install
Check the ODBC layer with the driver manager's own tools
odbcinst -j shows the driver manager is present and where its config lives.
isql (shipped with unixODBC) opens an interactive session against a DSN, which
proves the vendor driver and server work before adbcBridge is in the picture:
import adbc_driver_manager.dbapi as dbapi
with dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=/tmp/t.db;"}) as conn:
print(conn.adbc_get_info()["driver_name"])
It prints ADBC ODBC Driver (or, if a native driver was installed and
delegation took over, the native driver's name). If
you installed the Python wheel you can use its convenience wrapper instead —
import adbcbridge; adbcbridge.connect(uri="Driver=SQLite3;Database=/tmp/t.db;")
— which additionally finds the bundled library for you.
Tip: The adbcbridge command line (from the Python package) has two
checks worth knowing: adbcbridge drivers lists the ODBC drivers the driver
manager can see, and adbcbridge driver-path prints which
libadbc_driver_odbc.so would be loaded. See
the command-line tool and
environment variables.
Known limitations
The driver manager and vendor drivers are yours. adbcBridge never bundles
a driver manager or a vendor ODBC driver; it loads whatever is installed. A
missing or misconfigured vendor driver is reported through adbcBridge but
fixed in the vendor driver's own setup.
A driver present but failing to load is reported by unixODBC as the
misleading Can't open lib '<path>' : file not found, even when the file is
there. adbcBridge re-opens the path itself and adds the real reason to the
error. The most common real causes are a static-TLS conflict after importing
pyarrow, a missing dependency of the driver (ldd <driver.so>), and a 32-bit
driver in a 64-bit process. See TROUBLESHOOTING.md.
32-bit-SQLLEN drivers (IBM Db2's libdb2.so is the known case) need the
adbc.odbc.sqllen_32bit quirk, which is autodetected for Db2 and can be set
by hand for others. See Options.
Full per-driver quirks and the exact verified set are in
COMPATIBILITY.md.
Installing on macOS
This page mirrors Installing on Linux for macOS. The shape
is the same — a driver manager, then vendor drivers, then adbcBridge — with one
extra wrinkle that is unique to macOS: some vendor drivers ship built against a
different ODBC driver manager (iODBC) than the default one (unixODBC), and the
two are not interchangeable. That is explained in full below.
Note: The prebuilt macOS release library is Apple Silicon (arm64) only.
There is no Intel (x86-64) build. On an Intel Mac you must build from source
(cmake), and against whichever driver manager your vendor drivers use.
1. The ODBC driver manager
macOS has two ODBC driver managers, and which one you need depends on your
vendor drivers.
🍎 unixODBC (the default)
Most drivers work through unixODBC, the same driver manager Linux uses.
Install it with Homebrew:
brew install unixodbc
Confirm it and find its config paths:
odbcinst -j
odbcinst -j prints where odbcinst.ini (installed drivers) and odbc.ini
(named data sources / DSNs) live. On a Homebrew install these are under the
Homebrew prefix.
When you need a bridge built against iODBC instead
A handful of vendor drivers for macOS are compiled against iODBC, the other
ODBC driver manager, and only ship in that form. The reason is a low-level
incompatibility: unixODBC's wide-character type (SQLWCHAR) is two bytes
(UTF-16), while iODBC's is four bytes (wchar_t). A driver built for one
cannot be loaded through the other — the wide entry points exchange text in
the wrong unit size, and diagnostics come back unreadable. Relinking such a
driver is not a valid workaround.
The vendor drivers known to be iODBC-only on macOS are:
Driver
Fronts
MySQL Connector/ODBC for macOS
MySQL and every MySQL-wire database
OpenLink Virtuoso
Virtuoso
Arrow Flight SQL ODBC (Dremio's build)
Flight SQL, InfluxDB 3, Dremio
If you need one of these, build a second copy of adbcBridge against iODBC and
use the iODBC drivers through it, keeping the unixODBC-built bridge for
everything else. One bridge build per driver manager.
# Build iODBC from source first (e.g. from the openlink/iODBC tag:
# ./configure --disable-gui && make install), then:
cmake -S . -B build-iodbc -DCMAKE_BUILD_TYPE=Release \
-DODBC_INCLUDE_DIR=<iodbc>/include -DODBC_LIBRARY=<iodbc>/lib/libiodbc.dylib
cmake --build build-iodbc
ctest --test-dir build-iodbc
Then set ADBC_ODBC_DRIVER to build-iodbc/libadbc_driver_odbc.dylib when you
use an iODBC driver. The exact per-driver preparation steps (clearing the
download quarantine flag, adding rpaths, re-signing) and the reasons behind them
are in TROUBLESHOOTING.md, under the two macOS iODBC
sections, and the measured facts are in
bench/BENCHMARKS-macos.md.
Troubleshooting: Two symptoms mean you have an iODBC driver loaded through
a unixODBC-built bridge. First, every call fails with an empty diagnostic
like [H000] [ (0) (SQLDriverConnect). Second, the process dies with SIGABRT
— no message — on the first SQL statement that errors on the server (a DROP
of a missing table, say). unixODBC's own isql dies the same way. The fix is
the iODBC-built bridge above.
2. Example ODBC drivers
Install vendor drivers per database. For the ones that are UTF-16 (unixODBC)
drivers, Homebrew is the easiest route:
brew install sqliteodbc # SQLite, through unixODBC
brew install psqlodbc # PostgreSQL, through unixODBC
Microsoft's msodbcsql18 for SQL Server ships as an arm64 tarball you relink to
your driver manager. The MySQL, Virtuoso and Flight SQL drivers are the
iODBC-only cases from the table above. After installing a driver, confirm the
manager registered it:
odbcinst -q -d # driver names in odbcinst.ini
3. adbcBridge
From the GitHub Release (prebuilt library)
The macOS release asset is adbcbridge-<tag>-osx-arm64.tar.gz, containing
osx-arm64/libadbc_driver_odbc.dylib. It is built against unixODBC, for Apple
Silicon.
shasum -a 256 -c SHA256SUMS --ignore-missing # SHA256SUMS is on the same release page
tar xzf adbcbridge-v0.1.3-osx-arm64.tar.gz
# → osx-arm64/libadbc_driver_odbc.dylib
From the Python wheel (bundles the library)
The macOS wheel is tagged macosx_14_0_arm64 — that is, it targets macOS
14.0 or later on Apple Silicon (MACOSX_DEPLOYMENT_TARGET is 14.0; the
release build passes _PYTHON_HOST_PLATFORM=macosx-14.0-arm64, which the wheel
tag normalises to).
pip install adbcbridge # from PyPI
# or a downloaded release wheel:
pip install adbcbridge-0.1.3-py3-none-macosx_14_0_arm64.whl
pip install adbc-driver-manager pyarrow
Note (the universal caveat): the wheel is arm64 only, not a
universal2 (fat) wheel. Python interpreters from python.org are often
universal2 builds; the release build tags the wheel macosx_14_0_arm64
explicitly so packaging tools do not demand a nonexistent x86-64 slice. On an
Intel Mac, or under an x86-64 (Rosetta) Python, this wheel will not match —
build from source instead.
ADBCBRIDGE_MANIFEST_DIR is given as an absolute path because its default
(etc/adbc/drivers, relative to the prefix) would put the manifest in
~/.local/etc/adbc/drivers, a directory the ADBC driver manager does not
search, so driver="odbc" would not resolve.
To build against iODBC instead, pass -DODBC_INCLUDE_DIR and -DODBC_LIBRARY
as shown in the iODBC section.
Via install.sh
./install.sh
On macOS install.sh writes the library under ~/.local/lib and the manifest
into the ADBC user config directory, which on macOS is
~/Library/Application Support/ADBC/Drivers/odbc.toml. The PREFIX,
MANIFEST_DIR, BUILD_DIR, BUILD_TYPE and JOBS overrides from the
Linux page apply unchanged.
The ADBC driver manifest
The manifest works exactly as on Linux,
with two macOS specifics:
The user config directory is ~/Library/Application Support/ADBC/Drivers
(not ~/.config). install.sh and the driver manager both use it.
Once the manifest is in place, every ADBC client loads the bridge as
driver="odbc" with no path and no environment variable.
Environment variables
The same variables the Linux page lists
apply on macOS. ADBC_ODBC_DRIVER is especially useful here: point it at the
unixODBC-built .dylib for most work, and at the build-iodbc one when you use
an iODBC driver.
import adbc_driver_manager.dbapi as dbapi
with dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=/tmp/t.db;"}) as conn:
print(conn.adbc_get_info()["driver_name"])
Known limitations
Apple Silicon only for the prebuilt library and wheel; no Intel build is
published.
Two driver managers, two bridge builds. A driver built against iODBC needs
an iODBC-built bridge; a driver built against unixODBC needs the unixODBC-built
bridge. You cannot mix a driver and a bridge across the two managers.
The verified count on macOS is 45 of the 53-database workload; the
remaining eight lack an obtainable driver or a runnable server on this platform
(for example, some drivers ship no macOS build, and some server images do not
run under Docker Desktop's virtual machine). The per-database detail, including
the macOS column, is in COMPATIBILITY.md.
The same driver-load and static-TLS caveats as Linux apply; see
TROUBLESHOOTING.md.
Installing on Windows
Windows differs from Linux and macOS in one fundamental way: the ODBC driver
manager is part of the operating system. You do not install one. Everything
else — vendor drivers, then adbcBridge — follows the same shape as the other
platforms, with the Windows-specific details called out below.
1. The driver manager: built into Windows
Windows ships its own ODBC driver manager as odbc32.dll (the name is
historical; it is the 64-bit driver manager on a 64-bit Windows). There is
nothing to install and nothing named unixODBC or iODBC involved.
Two consequences matter for adbcBridge:
The narrow ODBC path is the ANSI code page, not UTF-8. On Linux and macOS
the narrow (SQLCHAR) ODBC entry points carry UTF-8 bytes untouched. The
Windows driver manager instead transcodes every narrow string through the
process's ANSI code page (code page 1252 on a Western install). adbcBridge
handles this by routing statement text, catalog names, column and type names,
diagnostics, and character columns through the wide (W) entry points on
Windows, so text is correct end to end. You do not configure anything; this is
simply why the internal code path differs from the other platforms.
Drivers are registered in the registry, by MSI installers. You cannot
register a driver just by naming its DLL path in a connection string the way
unixODBC tolerates — see the next section.
Manage installed drivers and DSNs with the built-in ODBC Data Source
Administrator (odbcad32.exe, reachable from the Start menu as "ODBC Data
Sources (64-bit)").
2. Installing vendor drivers (MSI installers)
On Windows, ODBC drivers come as MSI installer packages that register the
driver in the registry. Install the driver for each database you need — for
example Microsoft's msodbcsql18 for SQL Server (Windows already ships a SQL
Server ODBC driver), the MySQL Connector/ODBC MSI, the PostgreSQL psqlODBC MSI,
and so on — then confirm it appears in the ODBC Data Source Administrator.
Troubleshooting: IM002 when you name a DLL by path. Unlike unixODBC,
the Windows driver manager will not accept a driver referenced only by its
DLL path — Driver=C:\path\to\driver.dll;… is refused with SQLSTATE IM002
("Data source name not found and no default driver specified"). The driver
must be registered (installed by its MSI) and named by its registered driver
name. This is a driver-manager difference, not an adbcBridge limitation; the
YDB row in bench/BENCHMARKS-windows.md
records a concrete case.
3. adbcBridge
The library name
On Windows the library is libadbc_driver_odbc.dll — note the lib prefix
is kept on Windows too (the build sets it deliberately, so the file is
libadbc_driver_odbc.dll, not adbc_driver_odbc.dll).
From the GitHub Release (prebuilt library)
The Windows release asset is adbcbridge-<tag>-win-x64.tar.gz, containing
win-x64/libadbc_driver_odbc.dll, built for 64-bit x86 with MSVC.
The release page also carries SHA256SUMS; Get-FileHash adbcbridge-v0.1.3-win-x64.tar.gz -Algorithm SHA256
in PowerShell gives the value to compare. Security
covers checking the file's GPG signature, and the provenance and SBOM that releases after v0.1.0 add.
From the Python wheel
The Windows wheel bundles the DLL. Install it, plus the ADBC client:
pip install adbcbridge # from PyPI
# or, from a downloaded release wheel:
pip install adbcbridge-0.1.3-py3-none-win_amd64.whl
pip install adbc-driver-manager pyarrow
From source, and the cmake --install layout
You need CMake and a C toolchain (see MSVC vs MinGW). The
driver manager comes from the Windows SDK's odbc32; there is nothing extra to
install for it.
cmake --install lays the files out like this under the prefix:
C:\adbcbridge\
bin\libadbc_driver_odbc.dll the DLL (Windows puts shared libs in bin\)
lib\ import library, if produced
etc\adbc\drivers\odbc.toml the driver manifest
The manifest's [Driver.shared] platform key is windows_amd64:
Paths in the manifest are TOML literal strings, so forward slashes work on
Windows and no backslash escaping is needed. Once the manifest is in a directory
the ADBC driver manager searches (%LOCALAPPDATA%\ADBC\Drivers or
%PROGRAMDATA%\ADBC\Drivers), driver="odbc" resolves by name.
🪟 64- vs 32-bit builds
Windows keeps separate 32-bit and 64-bit ODBC worlds: a 64-bit process needs a
64-bit driver manager and 64-bit drivers, and a 32-bit process needs the 32-bit
ones. The released library is 64-bit x86. If you build for 32-bit, every
piece — the bridge, the driver manager, and the vendor driver — must be 32-bit
too. A 32-bit driver in a 64-bit process (or the reverse) fails to load.
MSVC vs MinGW
The releases are built with MSVC (the Visual Studio C toolchain). Building
with MinGW (a GCC toolchain for Windows) also works, but note one manifest
detail: the driver manager appends a _mingw suffix to the platform key for a
MinGW toolchain (windows_amd64_mingw), and CMake only recognises a build as
MinGW when the compiler is a GNU one. If you build with MinGW, the manifest key
must be windows_amd64_mingw for by-name discovery to match; a mismatched key
means driver="odbc" will not find the library.
Note: The Go binding is a special case on Windows — ADBC's Go driver
manager is a cgo package, and MSVC cannot serve cgo. Building anything that
imports the Go module needs a GCC (MinGW-w64) on PATH with CGO_ENABLED=1.
See languages/go.md.
Prefetch and parallel ingest on Windows
Two features are compiled out on Windows because both are built on pthreads,
which is a POSIX facility (the guard is _WIN32):
the prefetch pipeline (adbc.odbc.prefetch), which overlaps ODBC fetches
with Arrow conversion on a background thread; and
parallel bulk ingest (adbc.odbc.ingest_connections), which spreads one
ingest over several connections. On Windows ingest_connections is clamped to
1.
Everything else — queries, types, parameters, single-connection bulk ingest,
metadata, error mapping — works. Because these two are absent, a Windows read or
ingest measures a materially different code path from the Linux one; keep that in
mind when comparing performance across platforms. A Win32 port of both (using
SRWLOCK, CONDITION_VARIABLE and _beginthreadex) is on the roadmap — see
ROADMAP.md.
Tip: NO_SSPS=1 for MySQL Connector/ODBC against non-MySQL servers. The
Windows MySQL Connector/ODBC needs NO_SSPS=1 (no server-side prepared
statements) in the connection string when you point it at a server that speaks
the MySQL wire protocol but is not MySQL itself — otherwise it fails with No
data supplied for parameters in prepared statement. This is a known driver
behaviour on Windows, recorded in
UPSTREAM.md and
bench/BENCHMARKS-windows.md.
Verifying the install
Use the built-in ODBC Data Source Administrator (odbcad32.exe) to confirm your
vendor driver is registered, then run the same five-line Python check as the
other platforms:
pip install adbc-driver-manager pyarrow
import adbc_driver_manager.dbapi as dbapi
with dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=C:/temp/t.db;"}) as conn:
print(conn.adbc_get_info()["driver_name"])
It prints ADBC ODBC Driver. (Native delegation is not implemented on Windows,
so the bridge always takes the ODBC path here; see
Native delegation.)
Known limitations
No prefetch and no parallel ingest (pthreads compiled out on _WIN32), as
above.
Native delegation is unavailable on Windows — adbc.odbc.delegate=auto
always takes the ODBC path, and always fails with a clear message.
Driver registration is stricter: a driver must be installed by its MSI and
named by its registered name; a bare DLL path is refused with IM002.
64-bit release only; a 32-bit build requires a 32-bit driver manager and
32-bit drivers throughout.
Windows-specific driver findings (for example ANSI-code-page conversion issues
inside certain vendor drivers) are recorded in
bench/BENCHMARKS-windows.md and
UPSTREAM.md.
Your first query
This page runs a complete session against SQLite through adbcBridge — connect,
query, insert with parameters, bulk-ingest a table — in Python, and then shows
the same connect-and-query program in Rust, C#, Java, Go and C. SQLite is used
because its ODBC driver is tiny and needs no server; swap the connection string
for any other database once this works.
If you have not installed adbcBridge yet, start with
Installing on Linux,
macOS or Windows. All examples assume
the driver is discoverable by name (driver="odbc"), which an install gives you.
Python: the full session
adbcBridge is loaded through the ADBC driver manager. Install that client and
pyarrow:
pip install adbc-driver-manager pyarrow
Then run this program. Each numbered step builds on the last.
import adbc_driver_manager.dbapi as dbapi
import pyarrow as pa
# 1. Connect through the bridge to a SQLite file. `driver="odbc"` resolves the
# bridge by name from the manifest an install wrote; `uri` is an ODBC
# connection string.
conn = dbapi.connect(
driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=/tmp/first.db;"},
)
# 2. Run a query and get the result as a pyarrow.Table.
with conn.cursor() as cur:
cur.execute("SELECT 42 AS answer, 'hello' AS greeting")
table = cur.fetch_arrow_table() # pyarrow.Table
print(table)
# 3. Create a table and insert rows with parameters. ODBC uses `?` placeholders;
# executemany() binds one row per tuple.
with conn.cursor() as cur:
cur.execute("CREATE TABLE people (id INTEGER, name TEXT)")
cur.executemany(
"INSERT INTO people (id, name) VALUES (?, ?)",
[(1, "Ada"), (2, "Grace")],
)
conn.commit() # connections are transactional by default
# 4. Bulk-ingest a whole Arrow table in one call. mode="create" makes a new
# table from the Arrow schema; use mode="append" to add to an existing one.
more = pa.table({"id": [3, 4], "name": ["Alan", "Edsger"]})
with conn.cursor() as cur:
cur.adbc_ingest("more_people", more, mode="create")
conn.commit()
# 5. Read it all back.
with conn.cursor() as cur:
cur.execute("SELECT id, name FROM people ORDER BY id")
print(cur.fetch_arrow_table().to_pydict())
conn.close()
Load the bridge by name; the uri is the ODBC connection string.
cur.execute(sql, parameters=…)
Run one statement; parameters use ?.
cur.executemany(sql, rows)
Run one parameterised statement once per row.
cur.fetch_arrow_table()
Return the current result set as a pyarrow.Table.
cur.adbc_ingest(name, table, mode=…)
Bulk-load an Arrow table; mode is "create", "append", or "create_append".
conn.commit()
Commit the transaction (autocommit is off by default).
Tip: The Python package adbcbridge (from the wheel) wraps step 1 as
adbcbridge.connect(uri="Driver=SQLite3;Database=/tmp/first.db;"), which also
locates the bundled library for you and returns the same
adbc_driver_manager.dbapi.Connection. Everything from step 2 on is
identical. See languages/python.md.
Troubleshooting: If step 1 fails with Can't open lib … : file not found
for a file that is there, or cannot allocate memory in static TLS block, see
TROUBLESHOOTING.md — those are ODBC-driver load
issues (often a static-TLS conflict from importing pyarrow first), not bugs in
your program.
The same first query in every other language
Each block below connects to the same SQLite database and runs SELECT 42 AS
answer. The result is Arrow either way — a record batch or reader. Parameters
and bulk ingest work in every binding too; each language's page has the detail.
Rust
use adbc_core::options::OptionDatabase;
use adbc_core::{Connection, Database, Driver, Statement};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut driver = adbcbridge::load()?;
let uri = "Driver=SQLite3;Database=/tmp/first.db;";
let database = driver.new_database_with_opts([(OptionDatabase::Uri, uri.into())])?;
let mut connection = database.new_connection()?;
let mut statement = connection.new_statement()?;
statement.set_sql_query("SELECT 42 AS answer")?;
for batch in statement.execute()? {
println!("{} row(s)", batch?.num_rows());
}
Ok(())
}
Detail, including the bundled feature that compiles the driver from source:
languages/rust.md.
C
using AdbcBridge;
using Apache.Arrow;
using Apache.Arrow.Adbc;
using Apache.Arrow.Ipc;
using AdbcConnection connection = Driver.Connect("Driver=SQLite3;Database=first.db;");
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT 42 AS answer";
IArrowArrayStream stream = (IArrowArrayStream)statement.ExecuteQuery().Stream;
while (await stream.ReadNextRecordBatchAsync() is RecordBatch batch)
{
Console.WriteLine(batch.Length);
}
The C path uses the ADBC driver manager's flat C API directly and links against
the ADBC driver manager library; "driver" set to "odbc" resolves the bridge
by name, exactly as the higher-level bindings do under the hood.
Detail, including how to consume the ArrowArrayStream and check every
AdbcStatusCode: languages/c.md.
Next steps
Change the connection string to reach a real database:
Connection strings.
Tune batch size, decimals, prefetch and ingest:
Options.
See what type each SQL column becomes in Arrow:
Types.
Check whether your database is verified and what quirks it needs:
COMPATIBILITY.md.
Python
adbcBridge lets any ODBC
(Open Database Connectivity) data source be used as an
ADBC (Arrow Database Connectivity) database, so
results arrive as Apache Arrow — columnar, zero-copy into pandas, Polars or
pyarrow — rather than row-by-row Python objects.
The adbcbridge package is a thin convenience layer. It finds the driver shared
library (libadbc_driver_odbc.so / .dylib / .dll) and hands it to the ADBC
driver manager. adbcbridge.connect() returns a plain
adbc_driver_manager.dbapi.Connection, so the whole ADBC DBAPI 2.0 surface is
available and nothing is wrapped or hidden.
This page assumes you are comfortable with Python but new to ODBC and ADBC. Terms
are explained on first use.
unixODBC on Linux, unixODBC or iODBC on macOS, the built-in Driver Manager on Windows
An ODBC driver for your database
e.g. libsqlite3odbc.so, psqlodbcw.so, MySQL Connector/ODBC
adbcBridge is the ODBC client — it does not include the database's own ODBC
driver. Install that separately and point the connection string at it.
Installation
The python/ directory of the repository holds the adbcbridge package, a thin
pip-installable wrapper that locates the shared library for you and hands it to
the ADBC driver manager. Wheels are published on the project's GitHub Releases.
Install the wheel for your platform:
pip install adbcbridge
That installs the wheel from PyPI. The same
wheels are attached to the GitHub Release (adbcbridge-0.1.3-py3-none-<platform>.whl),
so an offline machine can install one directly:
The published wheel bundles the compiled adbcBridge driver library
(libadbc_driver_odbc.*) directly inside the package, so you do not build
anything. It is a plain C shared library with no Python extension module, which
is why a single wheel per platform serves every supported Python version — the
wheel is tagged py3-none-<platform>.
The wheel does not bundle the ODBC driver manager itself (libodbc). That is
deliberate: a foreign libodbc would change which odbcinst.ini your ODBC
drivers are read from. auditwheel (Linux) and delocate (macOS) run with
libodbc excluded, so the wheel uses the system driver manager already on your
machine.
Building a wheel yourself
Wheels are pure Python unless a driver library is present at build time, in
which case it is bundled into the wheel and the wheel is tagged for the current
platform:
The Linux wheels are built for manylinux_2_28; auditwheel repair writes the
compressed tag set (manylinux2014, manylinux_2_17, manylinux_2_28) into
the file name, which is why the Linux names are long.
A source distribution (sdist) is also published. Installed on a platform with no
matching wheel it produces a pure-Python package with no bundled library; at run
time adbcbridge.driver_path() then falls back to a driver manifest or a
system-wide install (see Loading the driver).
The command-line tool
The package installs an adbcbridge command (python -m adbcbridge is the same
thing). Use it to confirm which library the package resolved, to list the ODBC
drivers on the machine, or to run a query without writing a script:
adbcbridge query "Driver=SQLite3;Database=my.db;" "SELECT * FROM t" # --format table|csv|schema, --limit N, -p PARAM
adbcbridge drivers # ODBC drivers registered in odbcinst.ini (-v adds descriptions)
adbcbridge driver-path # which libadbc_driver_odbc.* would be used
query takes the connection string and the SQL as its two arguments. Each
-p VALUE supplies one ? parameter, in order, always sent as a string;
--limit N prints at most N rows; --format is table (default), csv or
schema; --driver-path PATH overrides the auto-detected library.
Loading the driver
There are three ways to get the driver in front of the driver manager. The
adbcbridge package is the simplest.
1. The adbcbridge package (recommended)
import adbcbridge
with adbcbridge.connect(uri="Driver=SQLite3;Database=my.db;") as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM t")
table = cur.fetch_arrow_table() # pyarrow.Table
connect() locates the library, opens the ODBC driver named in the connection
string, then hands everything to adbc_driver_manager. Its signature:
Full ODBC connection string, e.g. "Driver=SQLite3;Database=my.db;"
dsn
A DSN (Data Source Name) from odbc.ini; used instead of, or alongside, uri
username
Sent as UID=
password
Sent as PWD=
driver_path
Path to libadbc_driver_odbc.*; defaults to adbcbridge.driver_path(). The manifest name "odbc" also works, since the value goes straight to the driver manager
autocommit
False (default) runs transactionally; True commits each statement
Extra keyword options become database options: a bare name is prefixed with
adbc.odbc. (batch_size=4096 sets adbc.odbc.batch_size), a dotted name is
passed through as given, and True/False become the strings "true"/"false":
the ADBC_ODBC_DRIVER environment variable (a value that is set but does not
exist is an error, not a silent fallback);
a copy bundled inside the installed package;
the ADBC driver manifest named odbc (odbc.toml), in the directories the
driver manager searches — $ADBC_DRIVER_PATH, <sys.prefix>/etc/adbc/drivers,
~/.config/adbc/drivers, /etc/adbc/drivers, and the platform equivalents;
common install locations: <sys.prefix>/lib, /usr/local/lib, /usr/lib,
/usr/lib/<arch>-linux-gnu, and a build/ tree next to a source checkout.
If nothing matches it raises adbcbridge.DriverNotFoundError.
2. adbc_driver_manager with an explicit path
If you would rather drive the ADBC layer yourself, point it at the library. The
Driver= in the connection string can be a registered name or, as here, the
path of the ODBC driver library itself:
import adbc_driver_manager.dbapi as dbapi
conn = dbapi.connect(
driver="/path/to/libadbc_driver_odbc.so",
db_kwargs={"uri": "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;"},
)
with conn.cursor() as cur:
cur.execute("SELECT * FROM my_table")
table = cur.fetch_arrow_table()
Options are set on the database through db_kwargs (see Options).
3. By name, via the driver manifest
If adbcBridge was installed with cmake --install (which writes an odbc.toml
manifest into an ADBC driver-search directory), any binding can ask for the
driver as "odbc":
import adbc_driver_manager.dbapi as dbapi
conn = dbapi.connect(
driver="odbc", # resolved via <prefix>/etc/adbc/drivers/odbc.toml
db_kwargs={"uri": "Driver=SQLite3;Database=my.db;"},
)
Troubleshooting: if driver="odbc" fails with
dlsym(AdbcDriverInit) failed: .../libodbc.so: undefined symbol: AdbcDriverInit,
then no manifest was found and the driver manager fell back to loading a shared
library literally named odbc — which on Unix is unixODBC's own driver manager,
not adbcBridge. Check that the directory holding odbc.toml is one the driver
manager searches and that the path recorded inside it exists.
The import-order rule (Linux)
import adbcbridge deliberately does not import pyarrow —
adbc_driver_manager.dbapi (and with it pyarrow) is imported only when you call
connect(), after the ODBC driver named in the connection string has been
opened. That order matters for a handful of ODBC drivers — MySQL Connector/ODBC
is the one seen in the wild — that need libstdc++'s thread-locals in static
thread-local storage, which importing pyarrow first makes impossible.
connect() opens the ODBC driver first, so this is handled for you.
If you drive adbc_driver_manager or pyodbc yourself, do the same as the very
first thing in your program, before anything imports pyarrow, pandas, or
adbc_driver_manager.dbapi:
import adbcbridge
adbcbridge.preload_odbc_driver("MySQL ODBC 9.4 Unicode Driver") # or a full path
import adbc_driver_manager.dbapi # imports pyarrow; now harmless
preload_odbc_driver() does on its own what connect() does automatically, for
programs that use adbc_driver_manager or pyodbc directly. Its signature:
adbcbridge.preload_odbc_driver(driver=None, *, uri=None, dsn=None, strict=False)
-> str | None # the library path that was loaded, or None
driver is a driver path or a driver name from odbcinst.ini; leave it out and
the driver is taken from the Driver= of uri, or from the Driver of the
dsn section of odbc.ini. It is best-effort by default — a driver that will
not preload is left to fail, with its real reason, at connection time; pass
strict=True to have it raise OSError when it cannot resolve or load the
library. On Windows it is a no-op, since static TLS is an ELF concept. Set
ADBCBRIDGE_PRELOAD=0 to switch off the automatic preload inside connect().
See docs/TROUBLESHOOTING.md for the full explanation.
Connection strings
An ODBC connection string is a ;-separated list of KEY=VALUE attributes. The
one attribute every connection needs is Driver=, which is either:
the path to the ODBC driver library
(Driver=/usr/lib/x86_64-linux-gnu/odbc/psqlodbcw.so;…), or
the name of a driver registered in odbcinst.ini
(Driver=SQLite3;… or Driver={PostgreSQL Unicode};…).
A value that contains a semicolon must be wrapped in braces: Driver={My Driver};.
Use a DSN instead of an inline driver with DSN=my_source; (the DSN's own
attributes come from odbc.ini).
Username/password passed to connect() are appended as UID=/PWD=; you can
also put them in the string directly.
Examples
The attribute structures below are the ones adbcBridge's compatibility matrix
uses (tests/compat/test_matrix.py); host, port and credentials are placeholders.
For Driver=, substitute either the path to your ODBC driver library or its
registered odbcinst.ini name — the matrix supplies a path. Where a well-known
registered name is used elsewhere in this project it is shown; otherwise
<...driver> marks the name to fill in.
The port numbers above are the matrix's test-harness ports; use your server's
actual port. In Python you can pass the string directly, or as the
adbc.odbc.connection_string
option (an alias of uri) when using adbc_driver_manager directly:
# adbcbridge package
conn = adbcbridge.connect(uri="Driver=SQLite3;Database=my.db;")
# adbc_driver_manager, uri form
conn = dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=my.db;"})
# adbc_driver_manager, connection_string alias
conn = dbapi.connect(driver="odbc",
db_kwargs={"adbc.odbc.connection_string":
"Driver=SQLite3;Database=my.db;"})
Options
Options are ADBC key–value settings. adbcBridge's own options are prefixed
adbc.odbc.; it also honours the ADBC-standard uri, dsn, username,
password, autocommit, and ingest options.
Options have a scope — the level at which the driver reads them:
database — set at connect time (db_kwargs, or connect(**options));
connection — set on the connection (conn_kwargs, or on
conn.adbc_connection);
statement — set on the statement (cur.adbc_statement.set_options(...)).
Where an option lists more than one scope, a narrower scope inherits the wider
one's value unless overridden.
adbcBridge options (adbc.odbc.*)
Key
Scope
Values
Default
Purpose
adbc.odbc.connection_string
database
string
—
ODBC connection string; alias of the standard uri
adbc.odbc.batch_size
database, connection, statement
integer > 0
1024
Rows per Arrow batch produced by the reader
adbc.odbc.rowset_bytes
database
integer > 0
8388608 (8 MiB)
Ceiling on a reader's bound rowset buffers. The rowset holds batch_size rows unless that would exceed this, in which case it holds as many as fit
adbc.odbc.max_bind_bytes
database
integer > 0
32768 (32 KiB)
Widest value bound at the width the driver declares for it. Wider columns are bound at long_bind_bytes instead, or read with SQLGetData where the driver cannot re-read a clipped value
adbc.odbc.long_bind_bytes
database
integer > 0
2048 (2 KiB)
Width to bind a column whose declared width is not a real bound — a TEXT/NVARCHAR(MAX)/LONGTEXT/bytea column, which drivers describe by what the type could hold (past max_bind_bytes), or by nothing at all (a width of 0, which is how psqlodbc describes PostgreSQL's bytea). Values longer than this are read again in full, so this trades nothing but speed. Where enough of them turn out not to fit — the reader watches the first 256 rows and compares the bytes it had to re-read against what the block cursor is worth — it stops binding that column for the rest of the result set and reads it with SQLGetData instead, since a buffer the values do not fit in costs more than it saves
adbc.odbc.decimal_as_string
database
"true" enables
false
Return DECIMAL/NUMERIC columns as Arrow strings (exact digits, no precision loss)
adbc.odbc.prefetch
database, connection, statement
integer 0–8
0 (off)
Rowsets kept in flight on a background fetch thread, so SQLFetch for the next one overlaps the Arrow conversion of the current one. 1 is double-buffering. See Prefetch
adbc.odbc.partitions
statement
integer 0–256
0 (auto)
How many partitions execute_partitions splits a query into. 0 chooses from the table's size (its block count, its row estimate, or the span of its key, whichever the chosen strategy has — one partition per 64 MiB, at most 8); 1 never splits. A count the driver cannot honour is reduced, never faked: it will not hand out a slice that reads nothing. See Partitioned reads
adbc.odbc.array_binding
statement
"true" / "false"
"true"
Bind each Arrow batch as a column-wise ODBC parameter array (one execute per batch). false forces row-at-a-time. Drivers that mishandle arrays fall back automatically
adbc.odbc.rows_per_insert
statement
integer 0–2147483647
0 (auto)
Row-groups per multi-row INSERT for bulk ingest. 1 disables the rewrite. See Bulk ingest
adbc.odbc.ingest_connections
statement
integer 1–64
1
Connections a bulk ingest may fan out over. N > 1 trades atomicity for speed. See Bulk ingest
adbc.odbc.sqllen_32bit
database, connection, statement
"true"/"1" / "false"/"0"
autodetect
Force the 32-bit-SQLLEN driver quirk on or off. Autodetected from SQL_DRIVER_NAME (on for IBM Db2's libdb2.so, which also drives Informix, and for MDB Tools), so you normally never set it. Turn it on for any other ODBC driver that was built with a 32-bit SQLLEN/SQLULEN on a 64-bit platform — the giveaway is undetected NULLs, garbage string lengths, and row counts of 4294967295
adbc.odbc.tune
database
"true" / "false"
"true"
May adbcBridge add ODBC connection keywords of its own where it recognises the target driver? See Connection keywords set for you for the complete list; false sends your connection string through untouched
adbc.odbc.driver_name
connection (read-only)
string
—
The SQL_DRIVER_NAME of the backing ODBC driver
Native delegation options (adbc.odbc.delegate*)
When a first-class native ADBC driver exists for the target (PostgreSQL, SQLite,
DuckDB, Snowflake, BigQuery, Flight SQL), adbcBridge can hand the whole database
over to it. All of these are database-scoped and frozen once the database is
initialised. See Native delegation for how the
choice is made.
Key
Values
Default
Purpose
adbc.odbc.delegate
auto / never / always
auto
Whether to delegate to a native driver. auto falls back to ODBC if none is available; always makes a missing native driver an error
adbc.odbc.delegate.driver
string
—
Force a specific native driver: a bare name (postgresql) or manifest name; a filesystem path only with allow_paths
adbc.odbc.delegate.search_path
path list
—
Extra directories to search for native drivers (:-separated; ; on Windows); needs allow_paths
adbc.odbc.delegate.allow_paths
"true" / "false"
false
Permit the two options above to name filesystem paths
adbc.odbc.delegate.last_error
(read-only)
—
Why delegation did not happen (empty if it did)
adbc.odbc.delegated_to
(read-only)
—
The native driver serving this database/connection, or odbc (empty before the database is initialised)
Note:adbc.odbc.delegate.driver accepts a path only when
adbc.odbc.delegate.allow_paths is true, because a database option that names a
shared library is a code-execution primitive for any host that forwards
caller-supplied options.
ADBC-standard options adbcBridge honours
Key
Scope
Notes
uri
database
ODBC connection string (alias of adbc.odbc.connection_string)
dsn
database
DSN name from odbc.ini; appended to the connection string as DSN=...
username, password
database
Appended as UID=/PWD=
adbc.connection.autocommit
connection
"true"/"false"; default autocommit is on at the ODBC level, but connect() runs transactionally unless you pass autocommit=True
Note:adbc.odbc.decimal_as_string and adbc.ingest.temporary treat only the
literal "true" as enabling; any other value (including a typo) is read as false,
with no error. adbc.odbc.array_binding, adbc.odbc.tune and
adbc.odbc.sqllen_32bit validate strictly and reject anything but
"true"/"false" (or "1"/"0").
Running queries
To a pyarrow Table
fetch_arrow_table() materialises the whole result set into a pyarrow.Table:
with conn.cursor() as cur:
cur.execute("SELECT id, name, created FROM users WHERE active = ?", (True,))
table = cur.fetch_arrow_table()
print(table.num_rows, table.schema)
Streaming: a record-batch reader
For a result set larger than memory, stream it batch by batch.
fetch_record_batch() returns a pyarrow.RecordBatchReader; iterate it and each
RecordBatch holds up to adbc.odbc.batch_size rows:
with conn.cursor() as cur:
cur.execute("SELECT * FROM huge_table")
reader = cur.fetch_record_batch()
for batch in reader: # each batch is a pyarrow.RecordBatch
process(batch) # e.g. compute, write to Parquet, ...
To pandas or Polars
The result is Arrow, so conversion is cheap. The DBAPI cursor offers helpers:
cur.execute("SELECT * FROM sales")
df = cur.fetch_df() # pandas.DataFrame (needs pandas installed)
cur.execute("SELECT * FROM sales")
pl = cur.fetch_polars() # polars.DataFrame (needs polars installed and adbc-driver-manager >= 1.6)
Equivalently, convert a fetched Arrow table yourself:
Both libraries speak ADBC natively — pandas since 2.2, Polars since 0.19 — but neither
ships a driver: you hand them a connection, and an adbcBridge connection is one. That
gives any ODBC database the columnar path those libraries otherwise reserve for the
few databases with a native ADBC driver (without it, pandas goes through SQLAlchemy and
a row-by-row DBAPI cursor, and Polars falls back to the same cursor for anything
ConnectorX does not speak — ConnectorX has no ODBC).
import adbcbridge
import pandas as pd
import polars as pl
conn = adbcbridge.connect(uri="Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=:memory:;")
# pandas: read_sql / to_sql take the ADBC connection directly -- no SQLAlchemy, no pyodbc
df = pd.read_sql("SELECT 1 AS one, 'héllo 🚀' AS s, 2.5 AS f", conn)
df = pd.read_sql("SELECT * FROM sales", conn, dtype_backend="pyarrow") # keep Arrow-backed columns
pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}).to_sql("t", conn, index=False) # ADBC bulk ingest
# Polars: read_database / write_database with engine="adbc"
pl_df = pl.read_database("SELECT * FROM sales", connection=conn)
pl.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}).write_database("t", connection=conn, engine="adbc")
to_sql and write_database go through adbc_ingest, so the modes, per-driver row
batching and array binding described under Bulk ingest apply, and the
table is created from the frame's Arrow schema. What does not reach adbcBridge is
pl.read_database_uri(..., engine="adbc"): Polars maps the URI scheme to a package
name (sqlite:// → adbc_driver_sqlite, postgresql:// → adbc_driver_postgresql),
and there is no scheme for an ODBC connection string — use the connection-object form
above. Verified with pandas 3.0.5 and Polars 1.44.1 over the SQLite ODBC driver.
Parameters
Parameters use the ODBC ? placeholder, positional and in order.
A single execution
Pass a tuple of values:
cur.execute("SELECT name FROM parent WHERE id = ?", (2,))
row = cur.fetchone()
Batched: executemany
executemany() binds many parameter sets at once. adbcBridge sends one
SQLExecute per Arrow batch of parameters (a column-wise parameter array) rather
than one round trip per row, where the driver supports it:
For maximum throughput bind a pyarrow.RecordBatch (one column per ?, one row
per execution) through the statement, then execute:
import pyarrow as pa
batch = pa.record_batch({"a": pa.array([1, 2, 3]),
"b": pa.array(["x", "y", "z"])})
cur.adbc_statement.set_sql_query("INSERT INTO t VALUES (?, ?)")
cur.adbc_statement.bind(batch)
cur.adbc_statement.execute_update()
Column types map to SQL types by the Arrow type of each bound column. An untyped
Arrow null column binds as SQL NULL.
Bulk ingest
adbc_ingest() loads a whole pyarrow.Table (or RecordBatch, or reader) into a
table, creating it if asked. It is the fast path for loading data and understands
several write modes.
import pyarrow as pa
tbl = pa.table({"a": pa.array([1, 2, 3]),
"b": pa.array(["p", "q", "r"])})
with conn.cursor() as cur:
cur.adbc_ingest("my_table", tbl, mode="create")
conn.commit()
Modes
mode
Behaviour
create
Create the table from the Arrow schema, then insert. Fails if it exists
append
Insert into an existing table
replace
Drop the table if present, recreate it, then insert
create_append
Create the table if absent, then insert either way
How rows are sent, and per-driver probing
By default adbcBridge sends one INSERT INTO t VALUES (…),(…),… per K rows
inside a single transaction, rather than one statement per row. K is probed
against the driver at run time and remembered on the connection — SQLite's
999-variable limit, ClickHouse's refusal to execute large prepared row-groups, and
Oracle's rejection of the multi-row form (it falls back to
INSERT ALL … SELECT 1 FROM dual) are all discovered automatically. Firebird,
which has no multi-row VALUES, gets a UNION ALL of typed one-row SELECTs.
Two statement options tune this:
adbc.odbc.rows_per_insert — 0 (default) picks K automatically, 1 turns
the multi-row rewrite off (one INSERT per row, or ODBC parameter arrays), any
other value asks for that many row-groups.
adbc.odbc.array_binding — "true" (default) binds each batch as a column-wise
ODBC parameter array where the driver honours it; drivers that mishandle arrays
fall back automatically, and DuckDB and clickhouse-odbc default it off.
PostgreSQL goes one step further automatically: it sends a whole column of a
batch as a single array parameter and lets the server expand it with unnest(...).
This is on by default, needs no option, and is used only against genuine
PostgreSQL servers (not forks) and only for types it can render exactly.
Parallel ingest
adbc.odbc.ingest_connections (default 1) spreads one ingest over N
connections, each with its own transaction. This trades atomicity for speed: N
connections are N transactions, so a failure can leave some batches committed and
others not. Use N > 1 only where a partially populated table on failure is
acceptable, or where the caller drops and retries. It also quietly stays on one
connection when the caller is inside its own transaction (the CREATE TABLE would
be invisible to the workers), and is forced to 1 on Windows.
Commits are batched: when the connection is in autocommit and more than one row is
bound, adbcBridge turns autocommit off for the ingest and commits once at the end
(rolling back on failure), instead of paying a commit per row. A transaction you
opened yourself is left alone.
Metadata
The connection exposes the database catalog through ADBC's metadata calls.
Takes optional catalog_filter= and db_schema_filter= arguments to disambiguate.
adbc_get_info — driver and server facts
info = conn.adbc_get_info() # dict-like: vendor name/version, driver name, ...
adbc_get_info() is also how you see who served a connection: the driver name is
ADBC ODBC Driver for the ODBC path, or the native driver's name (e.g.
ADBC PostgreSQL Driver) when the connection was delegated.
Transactions and autocommit
By default adbcbridge.connect() opens a transactional connection: changes are
held until you call conn.commit(), and conn.rollback() discards them.
conn = adbcbridge.connect(uri="Driver=PostgreSQL Unicode;Server=...;")
with conn.cursor() as cur:
cur.execute("INSERT INTO t VALUES (1)")
cur.execute("INSERT INTO t VALUES (2)")
conn.commit() # both, or neither if an error rolled back
Pass autocommit=True to connect() (or set the connection option
adbc.connection.autocommit to "true") to commit each statement immediately:
Note: not every ODBC driver supports transactions. Against one that does not,
run in autocommit.
Partitioned reads
One connection reading a large table is one CPU decoding it. ADBC's partition
contract lets you split a query into N opaque descriptors and read each on its
own connection, in parallel, and concatenate the pieces. adbcBridge implements both
halves.
import concurrent.futures, pyarrow
import adbcbridge
CONN = "Driver=PostgreSQL Unicode;Server=127.0.0.1;Database=adbc;Uid=adbc;Pwd=adbc;"
def open_conn():
return adbcbridge.connect(uri=CONN, delegate="never")
with open_conn() as conn, conn.cursor() as cur:
cur.adbc_statement.set_options(**{"adbc.odbc.partitions": "8"})
cur.adbc_statement.set_sql_query("SELECT id, val, txt, dt FROM bench")
descriptors, schema, _ = cur.adbc_statement.execute_partitions()
def read(descriptor): # one connection per partition
with open_conn() as conn:
stream = conn.adbc_connection.read_partition(descriptor)
return pyarrow.RecordBatchReader._import_from_c(stream.address).read_all()
with concurrent.futures.ThreadPoolExecutor(len(descriptors)) as pool:
table = pyarrow.concat_tables(list(pool.map(read, descriptors)))
adbc.odbc.partitions controls the split count: 0 (default) chooses from the
table's size, 1 never splits. The driver splits only what it can prove is safe —
a bare SELECT <columns> FROM <one table> on PostgreSQL-wire servers with a heap
or a usable integer primary key — and otherwise returns a single descriptor
carrying the original query, which is always correct and never slower than not
partitioning. The strategies and their measurements are in
Partitioned reads.
Note: partitions are read on separate connections and therefore under separate
snapshots. Against a table being written concurrently, the union of the slices is
not a point-in-time view. Against a table that is not being written, the slices
reproduce the unpartitioned read exactly.
Prefetch
adbc.odbc.prefetch=1 overlaps ODBC's SQLFetch with the conversion into Arrow:
a background thread fetches the next rowset while the calling thread converts the
current one. Higher values keep more rowsets in flight, up to 8.
It is off by default, because whether it is safe depends on the ODBC driver
underneath and no driver can be asked. It engages only when every column is bound
at a width that cannot truncate; a column that would need re-reading (a TEXT or
NVARCHAR(MAX)), or a driver that fetches one row at a time, turns it off
silently for that query, changing nothing about the rows or the errors.
In practice it is worth very little: most ODBC drivers have already buffered
the whole result set client-side by the time SQLFetch is called, so there is no
socket wait to hide. For a big PostgreSQL read, partitioning
is the mechanism that pays. The measurements are in
Prefetch.
Errors
Database and driver errors are raised as subclasses of the DBAPI Error
hierarchy, which adbc_driver_manager populates from the underlying ADBC error.
The exception carries:
a message — the driver's diagnostic, prefixed with the failing ODBC call;
a SQLSTATE — the five-character SQL:2003 status code (e.g. 42S02 for
"table not found"), when the driver provides one;
a vendor code — the database-specific integer error code, when provided.
from adbc_driver_manager import dbapi
try:
cur.execute("SELECT * FROM no_such_table")
except dbapi.Error as exc:
print("message:", exc)
# SQLSTATE, vendor code and status details are carried on the ADBC error;
# inspect exc.args / the exception attributes your driver-manager version exposes
The DBAPI exception hierarchy (dbapi.Error and its subclasses
DatabaseError, ProgrammingError, IntegrityError, ...) is also exposed as
attributes of the connection (conn.Error, conn.DatabaseError, ...).
An error does not poison the statement or connection: a valid query afterwards
still works. adbcBridge also augments the common unixODBC
Can't open lib '<path>' : file not found message — reported for any load
failure, not only a missing file — with the real reason (see
docs/TROUBLESHOOTING.md).
Performance tips
Read in Arrow, stay in Arrow. Use fetch_arrow_table() /
fetch_record_batch() and hand the columnar data to pandas, Polars or Parquet
without a Python-object round trip.
Tune adbc.odbc.batch_size (default 1024) up for wide, simple rows to
amortise per-batch overhead; the rowset buffer is still capped by
adbc.odbc.rowset_bytes.
Bulk-load with adbc_ingest(), not row-by-row INSERTs. The multi-row
rewrite alone is often 10–100× faster; see the measured numbers in the project's
bench/BENCHMARKS.md.
For large reads, partition across connections rather than reaching for
prefetch — partitioning is what pays for a big scan.
Let adbcBridge delegate to a native ADBC driver where one exists
(adbc.odbc.delegate defaults to auto): native PostgreSQL/SQLite/DuckDB
drivers build Arrow directly and are faster than anything over ODBC.
decimal_as_string=True avoids precision loss on DECIMAL/NUMERIC
columns whose declared scale the driver reports imprecisely.
DB-API 2.0 usage
connect() returns a standard PEP 249 DBAPI
2.0 connection from adbc_driver_manager.dbapi, so ordinary cursor code works
unchanged:
import adbcbridge
conn = adbcbridge.connect(uri="Driver=SQLite3;Database=my.db;")
try:
cur = conn.cursor()
cur.execute("SELECT id, name FROM users WHERE id > ?", (10,))
for row in cur.fetchall(): # row-oriented, PEP 249 style
print(row)
cur.close()
conn.commit()
finally:
conn.close()
The row-oriented fetchone()/fetchmany()/fetchall() methods work, but for
anything data-heavy the Arrow methods (fetch_arrow_table, fetch_record_batch,
fetch_df, fetch_polars — the last needs adbc-driver-manager >= 1.6) are both
faster and lossless.
You can also drive the connection directly instead of through the adbcbridge
package:
import adbc_driver_manager.dbapi as dbapi
conn = dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=my.db;"})
Known limitations
The ODBC driver manager and the database's ODBC driver are not bundled. The
wheel ships only adbcBridge itself; you must have unixODBC/iODBC/the Windows
Driver Manager and the target database's ODBC driver installed.
Static-TLS import order on Linux. A few ODBC drivers (MySQL Connector/ODBC)
cannot load after pyarrow has been imported. The adbcbridge package handles
this for you; if you drive adbc_driver_manager yourself, preload the driver
first. See docs/TROUBLESHOOTING.md.
Native delegation is not available on Windows. There adbc.odbc.delegate:
auto always takes the ODBC path and always fails with a clear message.
Parallel ingest (ingest_connections > 1) is not atomic and is forced to
1 on Windows and whenever the caller is inside its own transaction.
Prefetch is off by default and often negligible, because most ODBC drivers
buffer the whole result set client-side.
Partitioning falls back to a single partition for anything but a provably
splittable SELECT — a WHERE/JOIN/ORDER BY, quoting or comments in the
SQL, bound parameters, no heap and no usable integer primary key, and so on.
Behaviour varies by ODBC driver. Type mappings, transaction support,
NULL-parameter handling and reported row counts are all the underlying driver's,
not adbcBridge's.
Complete worked example
A self-contained script: connect, create and bulk-load a table, query it into
Arrow and pandas, run a parameterised query, and read the catalog.
# SPDX-License-Identifier: Apache-2.0
"""End-to-end adbcBridge example against a local SQLite database."""
import pyarrow as pa
import adbcbridge
CONN = "Driver=SQLite3;Database=example.db;"
with adbcbridge.connect(uri=CONN) as conn:
# 1. Bulk-load a pyarrow.Table into a fresh table.
data = pa.table({
"id": pa.array([1, 2, 3, 4], pa.int64()),
"name": pa.array(["alice", "bob", "carol", "dan"]),
"score": pa.array([9.5, 7.0, None, 8.25], pa.float64()),
})
with conn.cursor() as cur:
cur.adbc_ingest("people", data, mode="replace")
conn.commit()
# 2. Query the whole table into Arrow, then pandas.
with conn.cursor() as cur:
cur.execute("SELECT id, name, score FROM people ORDER BY id")
table = cur.fetch_arrow_table()
print(table)
print(table.to_pandas())
# 3. A parameterised query.
with conn.cursor() as cur:
cur.execute("SELECT name FROM people WHERE score >= ?", (8.0,))
print([row[0] for row in cur.fetchall()]) # ['alice', 'dan']
# 4. Stream a (here small) result set batch by batch.
with conn.cursor() as cur:
cur.execute("SELECT * FROM people")
for batch in cur.fetch_record_batch():
print("batch of", batch.num_rows, "rows")
# 5. Inspect the catalog.
with conn.cursor() as cur:
schema = conn.adbc_get_table_schema("people")
print(schema)
Run it after installing the package and a SQLite ODBC driver:
pip install adbcbridge
python example.py
Rust
adbcBridge is a plain-C ADBC (Arrow Database Connectivity) driver that reaches
any data source with an ODBC (Open Database Connectivity) driver. This page
covers using it from Rust through the adbcbridge crate.
The driver itself is a C shared library (libadbc_driver_odbc.so on Linux,
.dylib on macOS, .dll on Windows). The adbcbridge crate does two small
things:
Finds a copy of that library on your machine (driver_path), and
Loads it into the ADBC driver manager (load), after which the ordinary
adbc_core traits — Driver,
Database, Connection, Statement — run every query.
Everything past loading is standard ADBC: adbcBridge adds no query API of its
own, only the ODBC connection string and a handful of adbc.odbc.* options.
Abbreviations.ADBC — Arrow Database Connectivity, a columnar database
API that returns Apache Arrow data. ODBC — Open Database Connectivity, the
older row-oriented C API that most databases ship a driver for. Driver
manager — the library that loads a driver by name or path (unixODBC or iODBC
on Unix, odbc32 on Windows for ODBC; adbc_driver_manager for ADBC).
What the crate is
Crate name
adbcbridge
Version
0.1.3 (early)
Edition
2021
License
Apache-2.0
Dependencies
adbc_core = "0.24", adbc_driver_manager = "0.24"
Build dependency
cc = "1.0.83" (only with the bundled feature)
Unsafe code
none — the crate is #![forbid(unsafe_code)]
The crate re-exports adbc_core, adbc_driver_manager and
adbc_driver_manager::ManagedDriver, so you can name those types without
pinning their versions yourself. The crate's own README is
rust/README.md; the crate is not required — see
Without the crate for loading the driver through
adbc_driver_manager alone.
Feature flags
Feature
Default
Effect
bundled
on
build.rs compiles the driver from the C sources shipped inside the crate (csrc/, a verbatim copy of the repository's src/, include/ and vendor/nanoarrow/) and links it against the platform's ODBC driver manager.
With bundled off (--no-default-features) the crate only locates a
libadbc_driver_odbc library that is already on the machine; it compiles
nothing and links nothing.
What the bundled build links
The compiled driver links the platform's ODBC driver manager, not any one
database's ODBC driver:
Platform
Driver manager linked
Prerequisite
Debian / Ubuntu
unixODBC (libodbc)
apt install unixodbc-dev
Fedora / RHEL
unixODBC (libodbc)
dnf install unixODBC-devel
macOS
unixODBC (libodbc)
brew install unixodbc
Windows
odbc32
ships with the Windows SDK; nothing to install
The compile mirrors the project's CMake build: C11 (GNU dialect where the
compiler has one), symbol visibility hidden, optimisation never below -O2
even in a debug Cargo profile.
Three environment variables override where the build looks for the driver
manager (useful for iODBC or a non-standard prefix):
Variable
Purpose
Example
ADBCBRIDGE_ODBC_LIB
driver-manager library base name
iodbc
ADBCBRIDGE_ODBC_INCLUDE_DIR
directory holding sql.h, sqlext.h
/opt/iodbc/include
ADBCBRIDGE_ODBC_LIB_DIR
directory holding the driver-manager library
/opt/iodbc/lib
Troubleshooting: the bundled build cannot find the ODBC headers. If the
build fails compiling with a message about sql.h/sqlext.h, install the
driver-manager development files (unixodbc-dev / unixODBC-devel /
unixodbc from Homebrew) or point ADBCBRIDGE_ODBC_INCLUDE_DIR at them.
Alternatively build with --no-default-features and supply the driver
library yourself (see Without the bundled build).
From a release .crate file. Each release publishes the packaged crate as
target/package/*.crate on the GitHub Release (the release workflow runs
cargo package --allow-dirty and uploads it). Unpack it and depend on it by
path, or add it to a local registry.
You almost always want adbc_core alongside adbcbridge: it defines the
Driver, Database, Connection and Statement traits and the option enums
that the examples below use.
Locating and loading the library
The crate exposes exactly three functions.
// Absolute path of the driver library, or an Error explaining what was tried.
pub fn driver_path() -> Result<std::path::PathBuf, adbcbridge::Error>;
// driver_path() + load it into the ADBC driver manager.
pub fn load() -> Result<adbcbridge::ManagedDriver, adbcbridge::Error>;
// Load a library you have already located, skipping the lookup.
pub fn load_from(path: impl AsRef<std::path::Path>)
-> Result<adbcbridge::ManagedDriver, adbcbridge::Error>;
load and load_from use the entry point AdbcDriverInit and request ADBC
version 1.1.0 (AdbcVersion::V110; the driver implements both the 1.0.0 and
the 1.1.0 ABI). The result is an ordinary
adbc_driver_manager::ManagedDriver.
Lookup order
driver_path returns the first hit from, in order:
the ADBCBRIDGE_LIBRARY environment variable, then ADBC_ODBC_DRIVER
(either set to something that is not a file is an error, not a silent
miss);
the copy compiled into the crate's build directory by the bundled feature;
the ADBC driver manifest named odbc (odbc.toml) in the directories the
ADBC driver manager searches: ADBC_DRIVER_PATH, the active VIRTUAL_ENV /
CONDA_PREFIX, the per-user directory ($XDG_CONFIG_HOME/adbc/drivers, or
~/Library/Application Support/ADBC/Drivers on macOS), and the system ones
under /etc and /usr;
common install directories ($VIRTUAL_ENV/lib, /usr/local/lib,
/usr/lib, /opt/adbcbridge/lib, /opt/homebrew/lib, the Debian multiarch
lib directories) plus a CMake build/ tree next to a source checkout.
Tip.ADBC_ODBC_DRIVER is the same variable the project's Python package,
benchmarks and C test suite read, so setting it once makes the library
resolvable from every language.
Without the bundled build
With --no-default-features the crate skips step 2 (there is no compiled copy)
and relies on steps 1, 3 and 4. Install the driver some other way — for example
cmake --install build in a checkout of the driver, or unpacking a release
tarball — or set ADBC_ODBC_DRIVER to its path.
Without the crate
adbcbridge is a convenience. The driver is a plain C shared library, so the
adbc_driver_manager crate can load it directly, with no dependency on
adbcbridge at all:
use adbc_core::options::{AdbcVersion, OptionDatabase};
use adbc_core::{Connection, Database, Driver, Statement};
use adbc_driver_manager::ManagedDriver;
let mut driver = ManagedDriver::load_dynamic_from_filename(
"/path/to/libadbc_driver_odbc.so",
Some(b"AdbcDriverInit"),
AdbcVersion::V110,
)?;
let uri = "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;";
let database = driver.new_database_with_opts([(OptionDatabase::Uri, uri.into())])?;
let mut connection = database.new_connection()?;
let mut statement = connection.new_statement()?;
statement.set_sql_query("SELECT * FROM my_table")?;
for batch in statement.execute()? {
let batch = batch?; // arrow_array::RecordBatch
println!("{} rows", batch.num_rows());
}
adbcbridge::load()? replaces the load_dynamic_from_filename call — it is
exactly what load_from does once driver_path has found the library. The
entry point is passed explicitly; the library also exports
AdbcDriverOdbcInit, the name the driver manager derives from the file name
when no entry point is given, and either works. The
tests/rust/ crate is a runnable example built this
way: a standalone smoke test that depends only on published crates, plus
examples/readme_snippet.rs, which holds the snippet above so cargo test
fails if it ever stops compiling.
Connection strings
adbcBridge passes the ODBC connection string through as the ADBC uri
option (OptionDatabase::Uri). Write it exactly as unixODBC or the Windows
driver manager would take it — a Driver=… string or a DSN=… string:
Set these on the database (via new_database_with_opts) unless the table
says otherwise. Connection strings and identity go under the standard ADBC keys;
driver-specific tuning goes under adbc.odbc.*.
Key
Meaning
uri (OptionDatabase::Uri)
full ODBC connection string (Driver=…;Server=…;)
dsn
DSN name from odbc.ini (appended as DSN=…)
username, password
appended as UID= / PWD=
adbc.odbc.batch_size
rows per Arrow batch (default 1024)
adbc.odbc.max_bind_bytes
widest value bound at the width the driver declares for it, in bytes (default 32768); wider ones are bound at long_bind_bytes or read with SQLGetData
adbc.odbc.long_bind_bytes
width, in bytes, to bind a column whose declared width is not a real bound — a TEXT/NVARCHAR(MAX)/LONGTEXT/bytea column (default 2048). Trades only speed: longer values are re-read in full. Where too many do not fit, the reader stops binding that column and uses SQLGetData instead
adbc.odbc.rowset_bytes
ceiling on a reader's bound rowset buffers, in bytes (default 8388608)
adbc.odbc.decimal_as_string
true to return DECIMAL/NUMERIC as strings
adbc.odbc.partitions
how many partitions ExecutePartitions splits a query into — 0 (default) chooses from the table's size, 1 never splits. Set on the statement
adbc.odbc.prefetch
rowsets kept in flight on a background fetch thread — 0 (default) off, 1 double-buffering, up to 8. Settable on the database, connection or statement. (Compiled out on Windows; see Known limitations.)
adbc.odbc.delegate
auto (default) / never / always — native delegation (see below)
adbc.odbc.delegate.driver
force a specific native driver: a bare name (postgresql) or manifest name; a path only with allow_paths
adbc.odbc.delegate.search_path
extra directories to search for native drivers (:-separated); needs allow_paths
adbc.odbc.delegate.allow_paths
true to let the two options above name filesystem paths (default false)
adbc.odbc.delegate.last_error
read-only: why delegation did not happen
adbc.odbc.delegated_to
read-only: the native driver serving this database/connection, or odbc
adbc.odbc.tune
true (default) / false — may the driver add ODBC connection keywords of its own where it recognises the target driver? false sends your connection string through untouched
adbc.odbc.sqllen_32bit
true/false to force the 32-bit-SQLLEN driver quirk (autodetected for IBM Db2; you normally never set it). Also settable on the connection and statement
adbc.odbc.rows_per_insert
rows of parameters per INSERT for bulk ingest — 0 (default) picks a group size automatically, 1 turns the rewrite off, any other value asks for that many. Instead of executing INSERT INTO t VALUES (?,?) once per row, ingest prepares INSERT INTO t VALUES (?,?),(?,?),… with K row-groups and binds K rows' worth of ordinary parameters per execute, which divides the round trips by K. Against PostgreSQL, where a batch instead goes as one array parameter per column, the same value sets the rows one such statement carries (default 10,000) and 1 turns that off too. See Bulk ingest. Set on the statement
adbc.odbc.ingest_connections
connections a bulk ingest may spread itself over — 1 (default) keeps it on the caller's own connection in a single transaction. N > 1 (up to 64) opens N further connections, hands each a share of the bound stream's batches and lets each run the ordinary ingest path into the same table. This trades atomicity for speed: N connections are N transactions, so a failure can leave some batches committed. See Parallel ingest. Not in the Windows build (the fan-out is pthreads, compiled out on _WIN32 — see the roadmap and Known limitations); ingest stays on one connection there. Set on the statement
adbc.odbc.array_binding
true (default) binds each Arrow batch as a column-wise ODBC parameter array, so a multi-row bind (and ingest on a driver where arrays are the faster of the two) issues one SQLExecute per batch instead of one per row; false forces row-at-a-time. Drivers that do not honour SQL_ATTR_PARAMSET_SIZE, or that cannot account for every parameter set they were handed, fall back automatically; drivers whose parameter arrays silently drop or misapply values (DuckDB, clickhouse-odbc, MonetDB, Firebird's OdbcFb, Virtuoso, Apache Ignite, QuestDB via psqlodbc — quirk no_param_arrays) default to false and can be forced back on with this option. Reported rows-affected is identical in both modes. Set on the statement
Native delegation
Where a native ADBC driver exists for the target (PostgreSQL, SQLite, DuckDB,
Snowflake, BigQuery, Flight SQL), adbcBridge can hand the whole database over to
it for native speed from the same install. adbc.odbc.delegate=auto (the
default) delegates when it recognises the target; never keeps adbcBridge on
its own ODBC path; always requires delegation. Set adbc.odbc.delegate=never
when you specifically want to exercise the ODBC path.
A first query: SELECT 1
use adbc_core::options::OptionDatabase;
use adbc_core::{Connection, Database, Driver, Statement};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut driver = adbcbridge::load()?;
let uri = "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=:memory:;";
let database = driver.new_database_with_opts([(OptionDatabase::Uri, uri.into())])?;
let mut connection = database.new_connection()?;
let mut statement = connection.new_statement()?;
statement.set_sql_query("SELECT 1 AS one")?;
for batch in statement.execute()? {
let batch = batch?;
println!("{} row(s)", batch.num_rows());
}
Ok(())
}
Statement::execute returns a RecordBatchReader — an iterator that yields
Result<RecordBatch> (both from the arrow-array
crate — add arrow-array = "59" to your dependencies, as the examples do;
adbc_core does not re-export it). Iterate it to stream Arrow batches;
collect it if you want the whole result at once.
Reading into Arrow
use arrow_array::RecordBatch;
statement.set_sql_query("SELECT id, val, txt FROM my_table")?;
let reader = statement.execute()?; // impl RecordBatchReader
// Stream batch by batch:
let mut total = 0usize;
for batch in reader {
let batch: RecordBatch = batch?;
total += batch.num_rows();
}
// Or collect all of it:
// let batches: Vec<RecordBatch> = statement.execute()?.collect::<Result<_, _>>()?;
The reader's schema() gives the Arrow schema before you pull any rows.
Parameters
Bind an Arrow RecordBatch of parameter values with Statement::bind (or a
whole stream with bind_stream), then execute. The batch has one column per
? and each row of the batch is one execution:
statement.set_sql_query("INSERT INTO t (a, b) VALUES (?, ?)")?;
statement.prepare()?; // optional; execute prepares if you do not
statement.bind(params_batch)?; // one RecordBatch, its rows = parameter sets
statement.execute_update()?; // returns rows affected
execute_update runs a statement for its side effect and returns the affected
row count; execute runs it for its result set. A query you wrote is executed
as written, whatever is bound to it — only the INSERT that bulk ingest
generates is ever rewritten (see Bulk ingest).
A multi-row bind and bulk ingest also batch their commits: when the connection
is in autocommit and more than one row is bound, adbcBridge turns autocommit
off for the duration and commits once at the end (rolling back if the execute
fails), instead of paying a commit per row. A transaction the caller opened
themselves is left alone, and a driver that refuses to leave autocommit is
simply left autocommitting — slower, but correct.
Bulk ingest
Bulk ingest writes an Arrow batch (or stream) into a table, generating the DDL
and batching the rows. Set the target table and ingest mode as statement
options, bind the data, and run execute_update:
use adbc_core::options::{IngestMode, OptionStatement};
let mut statement = connection.new_statement()?;
statement.set_option(OptionStatement::TargetTable, "my_table".into())?;
statement.set_option(OptionStatement::IngestMode, IngestMode::Create.into())?;
statement.bind(batch)?; // the RecordBatch to ingest
statement.execute_update()?;
connection.commit()?; // if autocommit is off
Under the hood adbcBridge builds the INSERT itself, so it can pack K rows
into one statement — INSERT INTO t ("a", "b") VALUES (?, ?), (?, ?), … with K
row-groups — rather than executing one statement per row. K rows' worth of
scalar parameters go in per SQLExecute, over the same SQLBindParameter calls
and the same per-driver parameter handling as one row at a time; no parameter
arrays are involved, so it works on every driver that can bind ordinary
parameters, including those whose parameter arrays are unusable (DuckDB,
MonetDB, clickhouse-odbc, QuestDB via psqlodbc) and those where an array is no
cheaper than a loop (MySQL Connector/ODBC). It is the default for ingest;
parameter arrays are kept ahead of it for four drivers only: MariaDB
Connector/ODBC before 3.2, whose arrays go out as a single
COM_STMT_BULK_EXECUTE (3.2 and later is switched to the multi-row form,
because its arrays segfault on a NULL date and misreport row counts against
MySQL); Vertica's own driver, whose arrays become a native bulk load;
Altibase's, which applies a bound array in one round trip (778–816k rows/s
against 30k for the multi-row form); and SAP HANA's client, where HANA has no
multi-row VALUES at all, so without arrays ingest falls back to one execute
per row (3,506 rows/s against 296,082). On PostgreSQL adbcBridge goes a step
further and sends each column of a batch as a single array parameter, with the
multi-row form as the fallback — see PostgreSQL: one array parameter per
column.
K is chosen from a parameter budget (2000 parameters, at most 1000 row-groups —
SQL Server's limits are 2100 and 1000) and clipped by the driver's
SQL_MAX_STATEMENT_LEN. ODBC has no "maximum parameters" question to ask, so
the real ceiling is probed: a SQLPrepare (or, for a driver that only objects
later, the first SQLExecute) that is refused halves K and asks again, and the
answer is remembered on the connection. A server with no multi-row VALUES at
all is found the same way, by a two-row SQLPrepare before anything is written,
and ingest carries on in whatever form the server does take (Oracle's
INSERT ALL, Firebird's typed UNION ALL SELECT, SQLite's 999-variable build,
Cloud Spanner's declared 950-parameter ceiling); the per-server table is in
Bulk ingest. A failure part way
through is unchanged by any of this: the whole ingest is one transaction, so it
commits completely or leaves nothing behind. Tune with
adbc.odbc.rows_per_insert, adbc.odbc.array_binding and (trading atomicity
for speed) adbc.odbc.ingest_connections; see the options table.
To turn autocommit off first:
use adbc_core::options::OptionConnection;
connection.set_option(OptionConnection::AutoCommit, "false".into())?;
Metadata
adbcBridge implements the standard ADBC metadata calls on the adbc_coreConnection trait — get_info, get_objects, get_table_types and
get_table_schema — backed by ODBC catalog functions and returning Arrow. Use
them exactly as you would with any ADBC driver; adbcBridge adds nothing of its
own here.
Errors
Failures from driver_path/load are an adbcbridge::Error
(#[non_exhaustive]):
Variant
Meaning
BadEnvironment { variable, value }
ADBCBRIDGE_LIBRARY or ADBC_ODBC_DRIVER is set but does not name a file
NotFound { searched }
no driver library was found; searched lists every path tried, and the Display text repeats them
Adbc(adbc_core::error::Error)
the library was found but the ADBC driver manager could not load it
Once loaded, query and connection failures surface as
adbc_core::error::Error, which carries the structured ODBC diagnostics
(SQLSTATE plus the native error code) that adbcBridge maps from the driver.
Comparison with odbc-api / arrow-odbc
The repository's Rust benchmark (bench/rust/) measures the same
ingest-and-fetch workload three ways over the same ODBC driver: through
adbcBridge (the ADBC path), through odbc-api
into a column-wise rowset buffer (no Arrow), and through
arrow-odbc into Arrow RecordBatches.
The harness pins arrow-odbc = "25", odbc-api = "29" and
arrow-array/arrow-schema = "59".
This is a measured comparison, not a claim: the per-database numbers live in
bench/RUST_BENCHMARKS.md and the cross-language
tables in bench/LANGUAGE_BENCHMARKS.md.
One property the harness itself documents: odbc-api and arrow-odbc both ask
the caller to cap text-column width (some drivers describe a VARCHAR(20) as
holding 65,536 or 2,147,483,647 characters), and the benchmark caps both sides
the same way that adbcBridge's adbc.odbc.max_bind_bytes does, so the reads are
bounded identically.
Known limitations
Early release (0.1.3). The API surface is small and may change. The
ADBC Driver Foundry validation suite runs against the driver from
tests/validation/ (latest results in tests/validation/RESULTS.md; driver
defects it found are still being worked through); prebuilt driver tarballs
ship on GitHub Releases and the crate is on crates.io.
Windows: no prefetch, no parallel ingest. The prefetch pipeline
(adbc.odbc.prefetch) and the ingest fan-out (adbc.odbc.ingest_connections)
both use POSIX threads and are compiled out on Windows. On Windows those
options have no effect; queries and single-connection ingest work normally.
Windows: a driver that throws a C++ exception across the FFI boundary
aborts the process. This is a measured finding
(bench/LANGUAGE_BENCHMARKS-windows.md):
the DuckDB ODBC driver throws a C++ exception out of SQLExecute, and Rust
cannot unwind a foreign exception, so the process ends with fatal runtime
error: Rust cannot catch foreign exceptions, aborting. This is a property of
that ODBC driver crossing the FFI boundary, not of adbcBridge, and it is not
something the crate can catch; it was seen only with the DuckDB driver on
Windows in that campaign. Other drivers on Windows were unaffected.
You still need an ODBC driver for your database. adbcBridge is the bridge,
not the database driver: the driver manager still has to find the ODBC driver
your Driver=…/DSN=… names.
Complete worked example
A self-contained program that loads adbcBridge, opens SQLite in memory over
ODBC, creates a table, ingests three rows from an Arrow batch, and reads them
back — all through the ADBC traits.
use std::sync::Arc;
use adbc_core::options::{IngestMode, OptionConnection, OptionDatabase, OptionStatement};
use adbc_core::{Connection, Database, Driver, Statement};
use arrow_array::{Int32Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Find and load the driver.
let mut driver = adbcbridge::load()?;
// 2. Open a connection. adbc.odbc.delegate=never keeps us on the ODBC path
// even if a native ADBC SQLite driver happens to be installed.
let uri = "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=:memory:;";
let database = driver.new_database_with_opts([
(OptionDatabase::Uri, uri.into()),
(OptionDatabase::Other("adbc.odbc.delegate".into()), "never".into()),
])?;
let mut connection = database.new_connection()?;
connection.set_option(OptionConnection::AutoCommit, "false".into())?;
// 3. Build a small Arrow batch.
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("name", DataType::Utf8, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["ada", "grace", "linus"])),
],
)?;
// 4. Bulk-ingest it into a new table.
let mut ingest = connection.new_statement()?;
ingest.set_option(OptionStatement::TargetTable, "people".into())?;
ingest.set_option(OptionStatement::IngestMode, IngestMode::Create.into())?;
ingest.bind(batch)?;
ingest.execute_update()?;
drop(ingest);
connection.commit()?;
// 5. Read the rows back into Arrow.
let mut query = connection.new_statement()?;
query.set_sql_query("SELECT id, name FROM people ORDER BY id")?;
let mut rows = 0usize;
for batch in query.execute()? {
rows += batch?.num_rows();
}
println!("read {rows} rows back");
Ok(())
}
Tip. If load() returns NotFound, set ADBC_ODBC_DRIVER to the path of
libadbc_driver_odbc.{so,dylib,dll}, or build with the default bundled
feature so the driver is compiled into the crate. The error's message lists
every location it checked.
.NET (C#)
adbcBridge is a plain-C11 ADBC driver that talks to any ODBC data source. ADBC
(Arrow Database Connectivity) is a database API whose result sets are Apache
Arrow record batches; ODBC (Open Database Connectivity) is the older, row-based
C API that almost every database ships a driver for. The driver is one shared
library — libadbc_driver_odbc.so on Linux, .dylib on macOS,
libadbc_driver_odbc.dll on Windows.
The AdbcBridge NuGet package is a thin .NET wrapper around that library. It
does two things: it finds the shared library on the machine (or unpacks the
copy shipped inside the package), and it hands it toApache.Arrow.Adbc,
the Apache Arrow ADBC binding for .NET. Everything after that — connections,
statements, Arrow record batches — is the Apache.Arrow.Adbc API.
This page assumes you are comfortable in C# but new to ODBC and ADBC.
The package always contains the managed wrapper. It may also carry the
native driver as a runtime asset, depending on how it was packed:
The package attached to a GitHub Release is packed with the native library
present, laid out as runtimes/<rid>/native/libadbc_driver_odbc.{so,dylib,dll}
for each RID (runtime identifier — .NET's os-arch platform tag). The RIDs
built for releases are linux-x64, linux-arm64, osx-arm64 and win-x64.
A package packed without native assets is managed-only; it relies on finding
the library elsewhere on the machine (see
How the library is located).
Tip: runtimes/<rid>/native/ is the standard NuGet layout for native assets. A
framework-dependent net8.0 build copies the whole runtimes/ tree next to
your application; a RID-specific publish (dotnet publish -r <rid>) flattens the
one matching library beside the application. Either way the wrapper finds it
automatically.
Requirements
An ODBC driver manager, which the native library links against:
- Linux: unixODBC (provides libodbc.so.2); on Debian/Ubuntu, unixodbc.
- macOS: unixODBC or iODBC.
- Windows: built into the operating system.
The ODBC driver for your database (for example the SQLite ODBC driver
libsqlite3odbc.so, or psqlodbc for PostgreSQL). adbcBridge bridges to
whichever ODBC drivers are installed; it does not contain them.
The .NET SDK/runtime for one of the target frameworks.
Install
From nuget.org
dotnet add package AdbcBridge --version 0.1.3
The package carries the native library for linux-x64, linux-arm64, osx-arm64
and win-x64, so nothing else needs installing beyond an ODBC driver for your database.
From a GitHub Release (local NuGet source)
The same .nupkg is attached to each release. On a machine without registry access,
download it, register the folder that holds it as a package source, then add the package:
# 1. Download AdbcBridge.0.1.2.nupkg from the Release into ./localnuget/
mkdir -p localnuget
# (place AdbcBridge.0.1.2.nupkg in ./localnuget/)
# 2. Register the folder as a NuGet source.
dotnet nuget add source "$PWD/localnuget" --name adbcbridge-local
# 3. Add the package to your project.
dotnet add package AdbcBridge --version 0.1.3 --source adbcbridge-local
cd csharp
dotnet build
dotnet test # needs SQLITE_ODBC_DRIVER, skips otherwise
dotnet pack AdbcBridge -c Release # managed-only package
dotnet pack AdbcBridge -c Release -p:NativeRoot=/abs/path/to/native # with native assets
dotnet test runs AdbcBridge.Tests, which holds two tests. The first is the
real thing: find the driver, load it, connect to SQLite through the ODBC driver
SQLITE_ODBC_DRIVER names (Driver=<value>;Database=:memory:;), run
SELECT 1 and read the Arrow batch. It is skipped with a message when
SQLITE_ODBC_DRIVER is unset. The second points ADBCBRIDGE_LIBRARY at a path
that does not exist and checks that DriverNotFoundException names the
variable, the path, and every place searched. The
driver itself is found through Driver.Path(), so either set
ADBC_ODBC_DRIVER, or build the repo (cmake --build build) and let the
build-tree lookup find it.
NativeRoot must be a directory laid out as <rid>/libadbc_driver_odbc.{so,dylib,dll}:
Every library found there is packed under runtimes/<rid>/native/. Missing RIDs
are simply skipped; a NativeRoot that is not a directory, or that holds no
library at all, fails the pack.
The three entry points
Everything the wrapper adds lives on the static class AdbcBridge.Driver:
using AdbcBridge;
using Apache.Arrow.Adbc;
// 1. Where is the native library? Returns an absolute path, or throws
// DriverNotFoundException listing every place it looked.
string path = Driver.Path();
// 2. Load it: returns the Apache.Arrow.Adbc.AdbcDriver produced by
// CAdbcDriverImporter.Load(path). Dispose it when done.
using AdbcDriver driver = Driver.Load();
// 3. Or skip straight to an open connection from an ODBC connection string.
using AdbcConnection connection = Driver.Connect("Driver=SQLite3;Database=my.db;");
Method
Returns
Notes
Driver.Path()
string
Absolute path of the native library. Throws DriverNotFoundException if not found.
Loads the driver, opens an AdbcDatabase with uri set to connectionString, and connects.
Driver.RuntimeIdentifier()
string
The os-arch RID (linux-x64, …) the native asset is filed under for this process.
Driver.Connect keeps the AdbcDatabase and AdbcDriver it opened alive for
as long as the returned AdbcConnection is; dispose the connection when done and
the rest is released with it. A uri entry in options is overridden by
connectionString.
The constants used along the way are public:
Constant
Value
Driver.LibraryVariable
ADBCBRIDGE_LIBRARY
Driver.DriverVariable
ADBC_ODBC_DRIVER
Driver.ManifestPathVariable
ADBC_DRIVER_PATH
Driver.ManifestName
odbc.toml
Without the wrapper: CAdbcDriverImporter directly
Driver.Load() / Driver.Connect(connectionString) are thin: they find the
library and call Apache.Arrow.Adbc.C.CAdbcDriverImporter.Load(path). With only
the upstream package (see Install) you make
that call yourself, with the path of libadbc_driver_odbc.so (.dylib,
.dll), and open the database and connection by hand:
using Apache.Arrow;
using Apache.Arrow.Adbc;
using Apache.Arrow.Adbc.C;
using Apache.Arrow.Ipc;
using AdbcDriver driver = CAdbcDriverImporter.Load("/path/to/libadbc_driver_odbc.so");
string uri = "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;";
using AdbcDatabase database = driver.Open(new Dictionary<string, string> { ["uri"] = uri });
using AdbcConnection connection = database.Connect(null);
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT * FROM my_table";
QueryResult result = statement.ExecuteQuery();
IArrowArrayStream stream = result.Stream!;
while (await stream.ReadNextRecordBatchAsync() is RecordBatch batch)
{
Console.WriteLine($"{batch.Length} rows");
}
CAdbcDriverImporter.Loaddlopens the shared library and calls its
AdbcDriverInit export, so no ADBC driver manager or manifest is involved (the
ODBC driver manager from Requirements is still needed at run
time). Pass a second argument to use a different entry point: the library also
exports AdbcDriverOdbcInit, and
CAdbcDriverImporter.Load(path, "AdbcDriverOdbcInit") works the same.
Everything from Running a query onwards applies unchanged —
the AdbcConnection is the same type whichever way you obtained it. The one
thing you give up is How the library is located;
DriverNotFoundException is the wrapper's, so a wrong path here surfaces as
the importer's own load error instead.
The smoke test in tests/csharp/ is a runnable example
of exactly this path — it depends only on Apache.Arrow.Adbc and xunit, not on
the AdbcBridge package — and it also compiles the snippets above so they
cannot rot. With the .NET 8 SDK and an ODBC driver manager installed:
cd tests/csharp
SQLITE_ODBC_DRIVER=/path/to/libsqlite3odbc.so dotnet test
ADBC_ODBC_DRIVER names the driver under test and defaults to
build/libadbc_driver_odbc.so at the repository root; set it to test an
installed copy. tests/csharp/README.md has the
containerised variant and what each test asserts.
How the library is located
Driver.Path() returns the first hit, in this order. The environment variables
are the two ways to point it at a specific build.
#
Where it looks
Detail
1
ADBCBRIDGE_LIBRARY
The library's full path. Set but not a file is an error, not a fall-through.
2
ADBC_ODBC_DRIVER
The same variable the Python package and the test suite use. Same error rule.
3
Native asset in this package
runtimes/<rid>/native/libadbc_driver_odbc.* next to the AdbcBridge assembly, or flattened beside it by a RID-specific publish.
4
ADBC driver manifest odbc.toml
In the directories the ADBC driver manager searches: ADBC_DRIVER_PATH first, then ~/.config/adbc/drivers ($XDG_CONFIG_HOME/adbc/drivers), /etc/adbc/drivers, /usr/local/etc/adbc/drivers, /usr/share/adbc/drivers, /usr/local/share/adbc/drivers. On macOS, ~/Library/Application Support/ADBC/Drivers and /Library/Application Support/ADBC/Drivers; on Windows, %LOCALAPPDATA%\ADBC\Drivers and %PROGRAMDATA%\ADBC\Drivers. This is what install.sh and cmake --install write.
5
Common install directories
~/.local/lib, /usr/local/lib, /usr/lib (and their lib64 and <arch>-linux-gnu variants), /opt/adbcbridge/lib, /opt/homebrew/lib. On Windows, %LOCALAPPDATA%\adbcbridge\bin and %ProgramFiles%\adbcbridge\bin.
6
A CMake build/ tree
build/, build/Release, build/Debug, build/RelWithDebInfo, build/MinSizeRel in any directory above the application, the assembly, or the current directory — so a source checkout works right after cmake --build build.
When nothing matches, Driver.Path() throws AdbcBridge.DriverNotFoundException.
Its Message and its Searched (IReadOnlyList<string>) property list every
location that was examined, in order, and its LibraryName is the file name it
looked for.
Troubleshooting: if Driver.Path() throws, read the Searched list — it names
every directory tried. The quickest fix is to set ADBC_ODBC_DRIVER to the
absolute path of your libadbc_driver_odbc.*, or install the driver with
./install.sh / cmake --install so the odbc.toml manifest is written.
Connection strings
The first argument to Driver.Connect is a plain ODBC connection string. It
becomes the ADBC database's uri option. Two forms work:
DSN-less (names the ODBC driver directly):
text
Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;
Driver= takes either a driver name registered in odbcinst.ini (for
example Driver=SQLite3;) or the path of the ODBC driver library.
DSN (names a data source from odbc.ini):
text
DSN=warehouse;UID=me;PWD=secret;
Real connection strings from the project's compatibility matrix:
Substitute the driver name or path for the value in angle brackets.
Driver options
Pass driver options as the second argument to Driver.Connect (an
IReadOnlyDictionary<string,string>), or set them directly on an AdbcDatabase
if you drove Driver.Load() yourself. Keys and string values:
using AdbcConnection connection = Driver.Connect(
"Driver=SQLite3;Database=my.db;",
new Dictionary<string, string> { ["adbc.odbc.batch_size"] = "4096" });
The database also understands the generic ADBC options uri (the full ODBC
connection string — this is what connectionString becomes), dsn (a DSN name,
appended as DSN=…), and username / password (appended as UID= / PWD=).
The adbc.odbc.* options below come from the driver:
Option
Meaning
adbc.odbc.batch_size
Rows per Arrow batch (default 1024).
adbc.odbc.max_bind_bytes
Widest value bound at the width the driver declares for it, in bytes (default 32768). Wider values are bound at long_bind_bytes, or read with SQLGetData where that is not possible.
adbc.odbc.long_bind_bytes
Width, in bytes, at which to bind a column whose declared width is not a real bound — TEXT/NVARCHAR(MAX)/LONGTEXT/bytea and similar (default 2048). Values longer than this are re-read in full, so this trades only speed.
adbc.odbc.rowset_bytes
Ceiling on a reader's bound rowset buffers, in bytes (default 8388608). Holds batch_size rows unless that would cost more than this.
adbc.odbc.decimal_as_string
true to return DECIMAL/NUMERIC as strings.
adbc.odbc.partitions
How many partitions AdbcStatementExecutePartitions splits a query into — 0 (default) chooses from the table's size, 1 never splits. Set on the statement.
adbc.odbc.prefetch
Rowsets kept in flight on a background fetch thread — 0 (default) is off, 1 is double buffering, up to 8. Settable on the database, connection, or statement.
Force a specific native driver: a bare name (postgresql) or manifest name; a path only with allow_paths.
adbc.odbc.delegate.search_path
Extra directories to search for native drivers (:-separated); needs allow_paths.
adbc.odbc.delegate.allow_paths
true to let the two options above name filesystem paths (default false).
adbc.odbc.delegate.last_error
Read-only: why delegation did not happen.
adbc.odbc.delegated_to
Read-only: the native driver serving this database/connection, or odbc.
adbc.odbc.tune
true (default) / false — may the driver add ODBC connection keywords of its own where it recognises the target driver? false sends your connection string through untouched.
adbc.odbc.sqllen_32bit
true/false to force the 32-bit-SQLLEN driver quirk on or off. Autodetected (on for IBM Db2), so you normally never set it. Also settable on the connection and statement.
Native delegation (a note)
Some databases have a first-class native ADBC driver (PostgreSQL, SQLite,
DuckDB, Snowflake, BigQuery, Flight SQL). When adbc.odbc.delegate is auto
(the default) and adbcBridge recognises such a target, it hands the whole
database over to that native driver for native speed. Set
adbc.odbc.delegate=never to force the ODBC path (this is what the benchmark
harness does so its numbers describe the ODBC driver), or always to make a
missing native driver an error instead of a fall-through. Delegation is not
implemented on Windows — there auto always takes the ODBC path.
Running a query
Driver.Connect gives you an Apache.Arrow.Adbc.AdbcConnection. Create a
statement, set its SQL, execute, and drain the resulting Arrow stream:
using Apache.Arrow;
using Apache.Arrow.Adbc;
using Apache.Arrow.Ipc; // IArrowArrayStream
using AdbcConnection connection = Driver.Connect("Driver=SQLite3;Database=my.db;");
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT id, name FROM customers";
QueryResult result = statement.ExecuteQuery();
IArrowArrayStream stream = (IArrowArrayStream)result.Stream;
while (await stream.ReadNextRecordBatchAsync() is RecordBatch batch)
{
Console.WriteLine($"batch of {batch.Length} rows");
// batch.Column(0), batch.Schema.GetFieldByIndex(0).Name, ...
}
statement.ExecuteQuery() returns a QueryResult; its Stream is an
IArrowArrayStream (Apache.Arrow.Ipc).
stream.Schema is the Arrow Schema; stream.ReadNextRecordBatchAsync()
yields RecordBatch values until it returns null.
For DML that returns no rows, use statement.ExecuteUpdate() instead of
ExecuteQuery().
Tip: dispose (or read to completion on) the IArrowArrayStream on the same
thread that created it, before disposing the statement. If the stream's release
callback runs later on the .NET finalizer thread, an ODBC driver whose client
library is thread-affine can crash inside SQLCloseCursor. A using on the
stream is the simplest guarantee.
Transactions: connection.AutoCommit defaults to on. Set it to false and call
connection.Commit() / connection.Rollback() to control transactions
yourself.
Bulk ingest
Bulk ingest writes an Arrow RecordBatch into a table. The option keys come from
Apache.Arrow.Adbc.AdbcOptions.Ingest:
using Apache.Arrow;
using Apache.Arrow.Adbc;
RecordBatch batch = /* build your batch */;
using AdbcStatement statement = connection.CreateStatement();
statement.SetOption(AdbcOptions.Ingest.TargetTable, "my_table");
statement.SetOption(AdbcOptions.Ingest.Mode, AdbcOptions.IngestMode.Create);
statement.Bind(batch, batch.Schema);
statement.ExecuteUpdate();
// With AutoCommit off, commit when the ingest is done:
connection.Commit();
AdbcOptions.Ingest.Mode accepts AdbcOptions.IngestMode.Create (create the
table and insert), among the other ADBC ingest modes.
The driver generates the CREATE TABLE DDL from the Arrow schema, then sends
one multi-row INSERT per batch of rows inside a single transaction. It works
on every ODBC driver that can bind a parameter. adbc.odbc.rows_per_insert
overrides the rows-per-INSERT the driver chooses.
Parameters
The driver supports parameter binding (Bind / BindStream). Parameters are
bound as an Arrow RecordBatch, one column per ? and one row per execution;
Bind takes the batch and its schema. To run a parameterised statement, bind a
single-row (or multi-row) RecordBatch whose columns are the parameter values,
then execute:
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT * FROM customers WHERE id = ?";
statement.Bind(parameterBatch, parameterBatch.Schema); // one column per '?'
QueryResult result = statement.ExecuteQuery();
Building the batch by hand, for a two-row INSERT with a NULL in the second
row:
using Apache.Arrow.Types;
Schema parameters = new Schema.Builder()
.Field(new Field("id", Int64Type.Default, nullable: true))
.Field(new Field("name", StringType.Default, nullable: true))
.Build();
RecordBatch batch = new RecordBatch(
parameters,
new IArrowArray[]
{
new Int64Array.Builder().Append(1).Append(2).Build(),
new StringArray.Builder().Append("ada").AppendNull().Build(),
},
length: 2);
using AdbcStatement insert = connection.CreateStatement();
insert.SqlQuery = "INSERT INTO my_table (id, name) VALUES (?, ?)";
insert.Prepare();
insert.Bind(batch, parameters);
Console.WriteLine($"{insert.ExecuteUpdate().AffectedRows} rows inserted");
Binding a multi-row batch applies the statement once per row — as a column-wise ODBC
parameter array where the driver handles them (adbc.odbc.array_binding, on by default),
row by row otherwise. Bulk ingest takes its own route, a multi-row INSERT rewrite by
default — see bulk ingest. The placeholder syntax (?, $1,
:name, …) is whatever the underlying ODBC driver and database accept.
Metadata
The ADBC connection exposes the standard metadata calls, backed by the ODBC
driver's catalog functions: GetInfo, GetObjects (catalogs, schemas, tables,
columns), GetTableTypes, and GetTableSchema. Each returns Arrow data through
the same Apache.Arrow.Adbc.AdbcConnection you already have. Consult the
Apache.Arrow.Adbc API for the exact method signatures on your version
(0.24.0).
Errors
Operations throw Apache.Arrow.Adbc.AdbcException. The driver maps each ODBC
diagnostic into it as a structured error carrying the SQLSTATE (the
five-character ODBC status code) and the driver's native error code, so you
can branch on the database's own error identity rather than parsing message
text. The precise property names belong to the Apache.Arrow.Adbc API; catch
AdbcException and inspect it:
try
{
using AdbcConnection connection = Driver.Connect("Driver=SQLite3;Database=my.db;");
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT * FROM does_not_exist";
statement.ExecuteQuery();
}
catch (AdbcException ex)
{
Console.Error.WriteLine(ex.Message); // includes SQLSTATE + native code
}
AdbcBridge.DriverNotFoundException is separate: it is thrown only when the
native library itself cannot be located (see
How the library is located), before any database
work begins.
Target frameworks and ADO.NET
The package targets netstandard2.0 and net8.0.
On net8.0, native assets under runtimes/<rid>/native/ are resolved
automatically by the build.
On netstandard2.0 consumers running on .NET Framework, runtimes/ assets
are not copied by the build; install the driver on the machine, or set
ADBCBRIDGE_LIBRARY.
The AdbcBridge package does not provide an ADO.NET (System.Data)
provider — it is a locator and loader for the ADBC driver, and its surface is the
Apache.Arrow.Adbc types (AdbcConnection, AdbcStatement, and Arrow record
batches), not IDbConnection / IDbCommand.
Known limitations
The ODBC stack is not bundled. You still need an ODBC driver manager
(unixODBC/iODBC on Unix, built in on Windows) and the ODBC driver for your
database. The native library links against the driver manager
(libodbc.so.2 on Linux).
Native assets are present only if the package was packed with them. The
release .nupkg carries linux-x64, linux-arm64, osx-arm64 and win-x64.
Any other platform, or a managed-only package, must find the library through
the environment, a manifest, or an install directory.
netstandard2.0 on .NET Framework does not get runtimes/ native assets
copied automatically; provide the library yourself.
Native delegation is not available on Windows — auto always takes the
ODBC path there and always fails with a message.
Thread affinity. Release the Arrow result stream on the thread that created
it (a using), not on the finalizer thread — some ODBC client libraries are
thread-affine.
Apache.Arrow.Adbc version. The package is built against 0.24.0; use a
compatible version in your project.
Complete worked example
A minimal console application that finds the driver, connects to an in-memory
SQLite database through the SQLite ODBC driver, runs a query, and reads the Arrow
result.
HelloAdbcBridge.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Brings in System, System.Linq, System.Collections.Generic, etc.,
which the Program.cs below uses (.Select, Dictionary, top-level await). -->
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- From nuget.org; for a release .nupkg instead, add a local source:
dotnet nuget add source "$PWD/localnuget" --name adbcbridge-local -->
<PackageReference Include="AdbcBridge" Version="0.1.3" />
</ItemGroup>
</Project>
Program.cs
// SPDX-License-Identifier: Apache-2.0
using AdbcBridge;
using Apache.Arrow;
using Apache.Arrow.Adbc;
using Apache.Arrow.Ipc;
// The SQLite ODBC driver: a name from odbcinst.ini (e.g. "SQLite3") or the path
// of libsqlite3odbc.so. Point SQLITE_ODBC_DRIVER at it, or edit the fallback.
string sqlite = Environment.GetEnvironmentVariable("SQLITE_ODBC_DRIVER") ?? "SQLite3";
try
{
// 1. Find + load + connect, in one call. The string is an ODBC connection string.
using AdbcConnection connection = Driver.Connect(
$"Driver={sqlite};Database=:memory:;",
new Dictionary<string, string> { ["adbc.odbc.batch_size"] = "1024" });
// 2. Run a query.
using AdbcStatement statement = connection.CreateStatement();
statement.SqlQuery = "SELECT 42 AS answer, 'hi' AS greeting";
// 3. Drain the Arrow stream on this thread.
QueryResult result = statement.ExecuteQuery();
using IArrowArrayStream stream = (IArrowArrayStream)result.Stream!;
Console.WriteLine("columns: " +
string.Join(", ", stream.Schema.FieldsList.Select(f => f.Name)));
while (await stream.ReadNextRecordBatchAsync() is RecordBatch batch)
{
var answers = (Int32Array)batch.Column(0);
for (int i = 0; i < batch.Length; i++)
{
Console.WriteLine($"row {i}: answer = {answers.GetValue(i)}");
}
}
}
catch (DriverNotFoundException ex)
{
Console.Error.WriteLine("adbcBridge library not found:");
Console.Error.WriteLine(ex.Message); // lists every place searched
Environment.Exit(1);
}
catch (AdbcException ex)
{
Console.Error.WriteLine("database error: " + ex.Message);
Environment.Exit(1);
}
Run it
# The SQLite ODBC driver must be installed; name or path in SQLITE_ODBC_DRIVER.
export SQLITE_ODBC_DRIVER=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so
dotnet run
Expected output:
columns: answer, greeting
row 0: answer = 42
Java
adbcBridge is a plain-C11 ADBC driver that talks to any ODBC data source. ADBC
(Arrow Database Connectivity) is a database API whose result sets are Apache
Arrow batches; ODBC (Open Database Connectivity) is the older, row-based C API
that almost every database ships a driver for. The driver is one shared
library — libadbc_driver_odbc.so on Linux, .dylib on macOS,
libadbc_driver_odbc.dll on Windows.
The org.adbcbridge:adbcbridge Maven artifact is a thin wrapper around that
library. It finds the shared library on the machine (or unpacks the copy
bundled inside the jar), then hands it to the ADBC driver manager
(adbc-driver-jni) and gives you an AdbcDatabase on an ODBC connection
string. Everything after that is the plain
Arrow ADBC Java API:
connections, statements, ArrowReader, VectorSchemaRoot.
This page assumes you are comfortable in Java but new to ODBC and ADBC.
The jar always contains the wrapper classes. It may also bundle the native
driver, depending on how it was built:
The jar attached to a GitHub Release bundles the native library for several
platforms under /org/adbcbridge/native/<os>-<arch>/. The layouts built for
releases are linux-x86_64, linux-aarch64, macos-aarch64 and
windows-x86_64. At run time the wrapper extracts the one matching this JVM
into a temporary directory, once per JVM, keeping its file name.
A jar built without a natives directory is pure Java; it relies on finding the
library elsewhere on the machine (see
How the library is located).
The jar pulls in these dependencies at run time:
Dependency
Version
Role
org.apache.arrow.adbc:adbc-core
0.24.0
The ADBC Java API.
org.apache.arrow.adbc:adbc-driver-jni
0.24.0
The ADBC driver manager; bundles the native manager for Linux, macOS and Windows.
org.apache.arrow:arrow-vector
19.0.0
Arrow vectors and VectorSchemaRoot.
org.apache.arrow:arrow-memory-netty
19.0.0
The off-heap allocator backend (runtime scope).
Note: adbc-driver-jni 0.24.0 and Arrow 19.0.0 are built for Java 11, and so
is this library. The Arrow version must match the one ADBC 0.24.0 was built
against (19.0.0).
Tip: Netty is the default allocator backend. If you prefer the "unsafe"
allocator, exclude arrow-memory-netty and add arrow-memory-unsafe instead.
Requirements
An ODBC driver manager, which the native library links against:
- Linux: unixODBC (provides libodbc.so.2); on Debian/Ubuntu, unixodbc.
- macOS: unixODBC or iODBC.
- Windows: built into the operating system.
The ODBC driver for your database (for example the SQLite ODBC driver
libsqlite3odbc.so). adbcBridge bridges to whichever ODBC drivers are
installed; it does not contain them.
A JDK, version 11 or later (17+ needs the flag below).
The --add-opens flag
Arrow's off-heap allocator reaches into java.nio. On JDK 17 and later you
must start the JVM with:
--add-opens=java.base/java.nio=ALL-UNNAMED
and, with the Netty allocator (the default), also:
-Dio.netty.tryReflectionSetAccessible=true
Without them the first allocation fails with an InaccessibleObjectException
or RuntimeException: Failed to initialize MemoryUtil.
Put the flag in your launcher script, in JAVA_TOOL_OPTIONS, or in your build
plugin's argument list. For example, with the Surefire test plugin:
adbc-core, adbc-driver-jni, arrow-vector and arrow-memory-netty come in
transitively; the jar carries the native driver for Linux x86_64 and aarch64,
macOS arm64 and Windows x64. Published from the release tag by the
Publish to Maven Central workflow (java/PUBLISHING.md); the same jar, with
its sources and javadoc jars, is attached to the
GitHub release as well.
From a GitHub Release jar (offline, or a build you made yourself)
mvn install:install-file puts a downloaded jar into the local repository, but
generates a dependency-less POM, so declare the runtime dependencies alongside:
<dependencies>
<dependency>
<groupId>org.adbcbridge</groupId>
<artifactId>adbcbridge</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Only needed for a locally installed jar; Maven Central resolves them transitively. -->
<dependency>
<groupId>org.apache.arrow.adbc</groupId>
<artifactId>adbc-driver-jni</artifactId>
<version>0.24.0</version>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-netty</artifactId>
<version>19.0.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
Building the jar yourself
From a source checkout:
cd java
mvn package # jar + sources + javadoc jars in target/
mvn test # needs SQLITE_ODBC_DRIVER, skipped otherwise
mvn package -Dadbcbridge.natives=/path/to/natives # bundle native drivers into the jar
adbcbridge.natives points at a directory laid out as
<os>-<arch>/libadbc_driver_odbc.*:
java/natives/ is the default location and is gitignored. Without such a
directory the jar is pure Java and finds the driver on the machine instead.
java/README.md covers the wrapper's own build and
the Maven Central publishing profile.
The three entry points
Everything the wrapper adds lives on the final class org.adbcbridge.AdbcBridge:
import org.adbcbridge.AdbcBridge;
import org.apache.arrow.adbc.core.*;
import org.apache.arrow.memory.RootAllocator;
// 1. Where is the native library? Absolute path, or DriverNotFoundException.
String path = AdbcBridge.driverPath();
try (RootAllocator allocator = new RootAllocator()) {
// 2. An AdbcDriver (backed by the ADBC driver manager) wired to that path.
AdbcDriver driver = AdbcBridge.driver(allocator);
// 3. Or open a database on a connection string outright.
try (AdbcDatabase database =
AdbcBridge.open(allocator, "DSN=warehouse;UID=me;PWD=secret;", null)) {
// ... database.connect(), etc.
}
}
Method
Returns
Notes
AdbcBridge.driverPath()
String
Absolute path of the native library, cached for the life of the JVM. Throws DriverNotFoundException.
AdbcBridge.driver(BufferAllocator allocator)
AdbcDriver
An AdbcDriver backed by the ADBC driver manager (JniDriver) and wired to driverPath(). Find failures surface here, not at first open.
Opens a database with connectionString as the ODBC uri; options may be null. Throws AdbcException.
Public constants:
Constant
Value
AdbcBridge.DRIVER_NAME
odbc
AdbcBridge.ENV_LIBRARY
ADBCBRIDGE_LIBRARY
AdbcBridge.ENV_DRIVER
ADBC_ODBC_DRIVER
AdbcBridge.PROPERTY_LIBRARY
adbcbridge.library
AdbcBridge.open sets AdbcDriver.PARAM_URI (uri) to the connection string
and passes every other entry of the options map to the driver as a database
option. A uri entry in options is overridden by connectionString.
Without the wrapper: adbc-driver-jni directly
The wrapper is a convenience, not a requirement. adbc-driver-jni bundles the
native ADBC driver manager, so Java loads libadbc_driver_odbc.so the same way
every other binding does — with only the upstream artifacts on the classpath:
Point JniDriver.PARAM_DRIVER (jni.driver) at the library and put the ODBC
connection string in AdbcDriver.PARAM_URI (uri); from open onwards it is
the same API as above:
PARAM_DRIVER takes a path, a bare library name, or a driver-manifest name
("odbc" — the odbc.toml manifest that cmake --install writes, step 5 of
How the library is located); the native driver
manager resolves all three. AdbcBridge.driver(allocator) is exactly this
wiring with driverPath() as the path.
PARAM_URI is only interpreted by the driver manager when it looks like
scheme://… and no driver is set. An ODBC connection string is neither, so it
reaches adbcBridge untouched.
The snippet above is compiled — never run, its paths are placeholders — as
tests/java/src/test/java/org/adbcbridge/smoke/ReadmeSnippet.java, so it
cannot silently rot; SmokeTest.java beside it runs the same sequence against
SQLite. tests/java/README.md has the container
command that runs those tests with no JDK, Maven or unixODBC on the host.
How the library is located
AdbcBridge.driverPath() returns the first hit, in this order, and caches it:
#
Where it looks
Detail
1
adbcbridge.library system property
The library's path. Set but not a file is an error, not a fall-through.
2
ADBCBRIDGE_LIBRARY environment variable
Same rule.
3
ADBC_ODBC_DRIVER environment variable
The variable the rest of the repository uses. Same rule.
4
A copy bundled in the jar
/org/adbcbridge/native/<os>-<arch>/, extracted once per JVM to a temporary directory (deleted on exit, best effort).
5
ADBC driver manifest odbc.toml
In the directories the ADBC driver manager searches: ADBC_DRIVER_PATH, an active VIRTUAL_ENV/CONDA_PREFIX (etc/adbc/drivers, share/adbc/drivers), ~/.config/adbc/drivers ($XDG_CONFIG_HOME/adbc/drivers), /etc/adbc/drivers, /usr/local/etc/adbc/drivers, /usr/share/adbc/drivers, /usr/local/share/adbc/drivers; the macOS and Windows equivalents on those platforms. Written by cmake --install.
6
Common install locations
/usr/local/lib, /usr/lib, /opt/adbcbridge/lib, Homebrew, lib/lib64 of an active virtualenv or conda prefix, and the <arch>-linux-gnu multiarch directories on Linux.
7
A CMake build/ tree
build/ and build/{Release,Debug,RelWithDebInfo,MinSizeRel} walked up from where the class was loaded and from the working directory — so a source checkout works right after cmake --build build.
When nothing matches, driverPath() throws
org.adbcbridge.DriverNotFoundException. Its message lists every place searched,
in order, and getSearched() returns the same list (List<String>).
Troubleshooting: if driverPath() throws, read getSearched() — it names every
location tried. The quickest fix is to set ADBC_ODBC_DRIVER (or the
adbcbridge.library system property) to the absolute path of your
libadbc_driver_odbc.*, or install the driver with cmake --install build so
the odbc.toml manifest is written.
Connection strings
The second argument to AdbcBridge.open is a plain ODBC connection string,
which becomes the ADBC database's uri. Two forms work:
DSN-less (names the ODBC driver directly):
text
Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;
Driver= takes a driver name from odbcinst.ini (Driver=SQLite3;) or
the path of the ODBC driver library.
DSN (names a data source from odbc.ini):
text
DSN=warehouse;UID=me;PWD=secret;
Real connection strings from the project's compatibility matrix:
Substitute the driver name or path for the value in angle brackets.
Driver options
Pass driver options as the third argument to AdbcBridge.open, a
Map<String,Object> whose keys are ADBC option names and whose values are
usually strings:
The database also understands the generic ADBC options uri (the full ODBC
connection string), dsn, and username / password.
The adbc.odbc.* options below come from the driver:
Option
Meaning
adbc.odbc.batch_size
Rows per Arrow batch (default 1024).
adbc.odbc.max_bind_bytes
Widest value bound at the width the driver declares for it, in bytes (default 32768). Wider values use long_bind_bytes or SQLGetData.
adbc.odbc.long_bind_bytes
Width, in bytes, at which to bind a column whose declared width is not a real bound — TEXT/NVARCHAR(MAX)/LONGTEXT/bytea and similar (default 2048). Longer values are re-read in full, trading only speed.
adbc.odbc.rowset_bytes
Ceiling on a reader's bound rowset buffers, in bytes (default 8388608).
adbc.odbc.decimal_as_string
true to return DECIMAL/NUMERIC as strings.
adbc.odbc.partitions
Partitions to split a query into for executePartitions — 0 (default) chooses from the table's size, 1 never splits. Set on the statement.
adbc.odbc.prefetch
Rowsets kept in flight on a background fetch thread — 0 (default) off, 1 double buffering, up to 8. Settable on the database, connection, or statement.
Force a specific native driver: a bare name (postgresql) or manifest name; a path only with allow_paths.
adbc.odbc.delegate.search_path
Extra directories to search for native drivers (:-separated); needs allow_paths.
adbc.odbc.delegate.allow_paths
true to let the two options above name filesystem paths (default false).
adbc.odbc.delegate.last_error
Read-only: why delegation did not happen.
adbc.odbc.delegated_to
Read-only: the native driver serving this database/connection, or odbc.
adbc.odbc.tune
true (default) / false — may the driver add ODBC connection keywords of its own where it recognises the target driver? false sends your connection string through untouched.
adbc.odbc.sqllen_32bit
true/false to force the 32-bit-SQLLEN driver quirk on or off. Autodetected (on for IBM Db2), so you normally never set it. Also settable on the connection and statement.
Native delegation (a note)
Some databases have a first-class native ADBC driver (PostgreSQL, SQLite,
DuckDB, Snowflake, BigQuery, Flight SQL). When adbc.odbc.delegate is auto
(the default) and adbcBridge recognises such a target, it hands the whole
database over to that native driver for native speed. Set
adbc.odbc.delegate=never to force the ODBC path, or always to make a missing
native driver an error. Delegation is not implemented on Windows — there auto
always takes the ODBC path.
Running a query
AdbcBridge.open gives you an AdbcDatabase. From there, the flow is the plain
ADBC Java API: connect, create a statement, set its SQL, execute, and read Arrow
batches through an ArrowReader:
statement.executeQuery() returns an AdbcStatement.QueryResult
(close it — try-with-resources).
result.getReader() is an ArrowReader; loadNextBatch() returns false
when the result set is exhausted, and getVectorSchemaRoot() is the current
batch.
For DML that returns no rows, call statement.executeUpdate() instead.
Transactions: call connection.setAutoCommit(false), then
connection.commit() / connection.rollback() to control transactions
yourself. Autocommit is on by default.
Bulk ingest
Bulk ingest writes an Arrow VectorSchemaRoot into a table.
connection.bulkIngest returns a bound statement; provide the data and execute:
import org.apache.arrow.adbc.core.BulkIngestMode;
import org.apache.arrow.vector.VectorSchemaRoot;
try (VectorSchemaRoot root = /* build your batch */;
AdbcStatement statement = connection.bulkIngest("my_table", BulkIngestMode.CREATE)) {
statement.bind(root);
statement.executeUpdate();
}
// With autocommit off, commit when the ingest is done:
connection.commit();
BulkIngestMode.CREATE creates the table from the Arrow schema and inserts.
The driver generates the CREATE TABLE DDL, then sends one multi-row INSERT
per batch of rows inside a single transaction. It works on every ODBC driver
that can bind a parameter. adbc.odbc.rows_per_insert overrides the
rows-per-INSERT the driver chooses.
Parameters
The driver supports parameter binding (Bind / BindStream). Parameters are
bound as a VectorSchemaRoot, one column per ? and one row per execution:
prepare, bind the root, then executeQuery() or executeUpdate():
statement.setSqlQuery("SELECT * FROM customers WHERE id = ?");
statement.prepare();
statement.bind(parameterRoot); // one column per '?', one row per execution
try (AdbcStatement.QueryResult result = statement.executeQuery()) {
// ...
}
Binding a multi-row root applies the statement once per row — as a column-wise ODBC
parameter array where the driver handles them (adbc.odbc.array_binding, on by default),
row by row otherwise. Bulk ingest takes its own route, a multi-row INSERT rewrite by
default — see bulk ingest. The placeholder syntax (?, $1,
:name, …) is whatever the underlying ODBC driver and database accept.
Metadata
The ADBC connection exposes the standard metadata calls, backed by the ODBC
driver's catalog functions: getInfo, getObjects (catalogs, schemas, tables,
columns), getTableTypes, and getTableSchema. Each returns Arrow data through
the AdbcConnection you already have. Consult the
ADBC Java API for the exact
signatures on your version (0.24.0).
Errors
Operations throw org.apache.arrow.adbc.core.AdbcException (AdbcBridge.open
declares it). The driver maps each ODBC diagnostic into a structured error
carrying the SQLSTATE (the five-character ODBC status code) and the driver's
native error code, so you can branch on the database's own error identity
rather than parsing message text. AdbcException also carries an ADBC status
code; the exact accessor names belong to the adbc-core API, so consult it for
the structured fields. Catch the exception and inspect it:
try (AdbcConnection connection = database.connect();
AdbcStatement statement = connection.createStatement()) {
statement.setSqlQuery("SELECT * FROM does_not_exist");
statement.executeQuery();
} catch (AdbcException ex) {
System.err.println(ex.getMessage()); // includes SQLSTATE + native code
// ex also exposes the ADBC status code and vendor/SQLSTATE fields; see adbc-core.
}
org.adbcbridge.DriverNotFoundException is a separate RuntimeException, thrown
only when the native library itself cannot be located (see
How the library is located), before any database
work begins.
JDBC comparison (measured)
The project's benchmark harness (bench/java/) runs the same workload two ways
from Java: over adbcBridge (through adbc-driver-jni), and over the database's
ordinary JDBC driver as a no-Arrow floor. The JDBC path uses a prepared INSERT
with addBatch/executeBatch for ingest and a plain ResultSet for reads. A
JDBC URL is derived automatically for SQLite (sqlite-jdbc) and PostgreSQL
(postgresql); other databases need a <DB>_JDBC environment variable and
otherwise show —.
Figures below are from bench/LANGUAGE_BENCHMARKS.md on the project's Linux
reference host: 10,000-row ingest and 100,000-row fetch of
(id int32, val float64, txt utf8, dt date32), rows per second, median of 3
timings after a warmup, with native delegation off (adbc.odbc.delegate=never)
so every row really travels over ODBC.
Database
adbcBridge ingest
JDBC ingest
adbcBridge fetch
JDBC fetch
SQLite
743,890
265,672
1,309,780
1,371,841
PostgreSQL
450,072
222,359
1,617,704
3,261,189
Read these as ratios, not absolutes: the servers run locally, the host is not
idle, and the JDBC column is a different code path (its own bulk API), not a
like-for-like Arrow comparison. On this host and workload, adbcBridge ingest was
faster than batched JDBC on both databases; fetch was close on SQLite and slower
than the PostgreSQL JDBC driver's row reader. Your numbers will differ with
hardware, driver, and load.
Note: the "JDBC" here is only the native comparison client. adbcBridge itself
speaks ODBC, exactly as the Python, Rust and Go bindings do; it does not use
JDBC to reach the database.
Known limitations
The ODBC stack is not bundled. You still need an ODBC driver manager
(unixODBC/iODBC on Unix, built in on Windows) and the ODBC driver for your
database. The native library links against the driver manager
(libodbc.so.2 on Linux).
Bundled natives are present only if the jar was built with them. The
release jar carries linux-x86_64, linux-aarch64, macos-aarch64 and
windows-x86_64. Any other platform, or a pure-Java jar, must find the library
through the property, an environment variable, a manifest, or an install
directory.
Extracted DLL cleanup on Windows. The bundled library is extracted to a
temporary directory and deleted on JVM exit as a best effort; Windows will not
let a loaded DLL be removed, so a copy may remain.
install:install-file produces a dependency-less POM. When installing from
the release jar, declare the runtime dependencies yourself (see
Install); they come transitively once the artifact is on Maven
Central.
Native delegation is not available on Windows — auto always takes the
ODBC path there and always fails with a message.
Version pinning. The jar is built against ADBC 0.24.0 and Arrow
19.0.0; the Arrow version must match the one ADBC was built against.
Complete worked example
A minimal program that finds the driver, connects to a temporary SQLite database
through the SQLite ODBC driver, runs a query, and reads the Arrow result.
pom.xml (dependencies and the classpath file the run step reads)
<dependencies>
<dependency>
<groupId>org.adbcbridge</groupId>
<artifactId>adbcbridge</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Only needed when adbcbridge was installed from a downloaded jar;
from Maven Central they resolve transitively. -->
<dependency>
<groupId>org.apache.arrow.adbc</groupId>
<artifactId>adbc-driver-jni</artifactId>
<version>0.24.0</version>
</dependency>
<dependency>
<groupId>org.apache.arrow</groupId>
<artifactId>arrow-memory-netty</artifactId>
<version>19.0.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<!-- Writes the runtime classpath to target/classpath.txt during `mvn package`,
which the run command below reads. -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>build-classpath</id>
<phase>package</phase>
<goals>
<goal>build-classpath</goal>
</goals>
<configuration>
<outputFile>${project.build.directory}/classpath.txt</outputFile>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
Main.java
// SPDX-License-Identifier: Apache-2.0
package com.example;
import java.nio.file.Files;
import org.adbcbridge.AdbcBridge;
import org.adbcbridge.DriverNotFoundException;
import org.apache.arrow.adbc.core.AdbcConnection;
import org.apache.arrow.adbc.core.AdbcDatabase;
import org.apache.arrow.adbc.core.AdbcException;
import org.apache.arrow.adbc.core.AdbcStatement;
import org.apache.arrow.memory.BufferAllocator;
import org.apache.arrow.memory.RootAllocator;
import org.apache.arrow.vector.VectorSchemaRoot;
import org.apache.arrow.vector.ipc.ArrowReader;
public final class Main {
public static void main(String[] args) throws Exception {
// The SQLite ODBC driver: a name from odbcinst.ini (e.g. "SQLite3") or the
// path of libsqlite3odbc.so. Point SQLITE_ODBC_DRIVER at it.
String sqlite = System.getenv().getOrDefault("SQLITE_ODBC_DRIVER", "SQLite3");
String dbFile = Files.createTempFile("adbcbridge-", ".db").toString();
String uri = "Driver=" + sqlite + ";Database=" + dbFile + ";";
try {
// 1. Locate the native library up front (optional; open() does it too).
System.out.println("driver: " + AdbcBridge.driverPath());
try (BufferAllocator allocator = new RootAllocator();
AdbcDatabase database = AdbcBridge.open(allocator, uri, null);
AdbcConnection connection = database.connect();
AdbcStatement statement = connection.createStatement()) {
// 2. Run a query.
statement.setSqlQuery("SELECT 42 AS answer, 'hi' AS greeting");
// 3. Read the Arrow result.
try (AdbcStatement.QueryResult result = statement.executeQuery()) {
ArrowReader reader = result.getReader();
while (reader.loadNextBatch()) {
VectorSchemaRoot batch = reader.getVectorSchemaRoot();
System.out.println("rows: " + batch.getRowCount());
System.out.println(batch.contentToTSVString());
}
}
}
} catch (DriverNotFoundException e) {
System.err.println("adbcBridge library not found:");
System.err.println(e.getMessage()); // lists every place searched
System.exit(1);
} catch (AdbcException e) {
System.err.println("database error: " + e.getMessage());
System.exit(1);
}
}
}
Expected output (the temporary path and library path will differ):
driver: /path/to/libadbc_driver_odbc.so
rows: 1
answer greeting
42 hi
Go
adbcBridge is a plain-C ADBC (Arrow Database Connectivity) driver that reaches
any data source with an ODBC (Open Database Connectivity) driver. This page
covers using it from Go through the github.com/singhpratech/adbcbridge/go
module.
The module is a thin layer over the ADBC Go driver manager
(github.com/apache/arrow-adbc/go/adbc/drivermgr): it finds the shared
library (DriverPath), hands its path to the driver manager (NewDriver),
and fills in the ODBC connection string (Open). Everything past that —
adbc.Database, adbc.Connection, adbc.Statement — is the ordinary ADBC Go
API. adbcBridge adds no query API of its own, only the ODBC connection string
and a handful of adbc.odbc.* options.
Abbreviations.ADBC — Arrow Database Connectivity, a columnar database
API returning Apache Arrow data. ODBC — Open Database Connectivity, the
older row-oriented C API most databases ship a driver for. cgo — Go's
foreign-function mechanism for calling C; the ADBC Go driver manager is a cgo
package, so a C compiler is required to build anything that imports this
module.
The module's own go/README.md is the shorter reference
for the same API and describes how to run its tests.
cgo and a C compiler
The ADBC Go driver manager is a cgo package. Building anything that imports this
module needs a C compiler on PATH:
OS
Compiler
Notes
Linux
gcc or clang
usually already present
macOS
clang
the Xcode command-line tools are enough
Windows
a GCC such as mingw-w64
MSVC cannot serve cgo; set CGO_ENABLED=1
Troubleshooting: undefined: drivermgr.Driver on Windows. ADBC's
drivermgr is cgo-only and MSVC cannot compile cgo. Without a GCC on PATH,
Go silently sets CGO_ENABLED=0, the package compiles to a stub, and the
build fails with undefined: drivermgr.Driver. That is a missing toolchain,
not a version mismatch. Install mingw-w64 (for example
winget install BrechtSanders.WinLibs.POSIX.UCRT), then build with
CGO_ENABLED=1. adbc.h's dllexport warnings under GCC are harmless.
Install
go get github.com/singhpratech/adbcbridge/go
The module contains no shared library: go get fetches source, and a .so
in the module cache is neither platform-selected nor executable-installed.
DriverPath finds a library that was put on the machine some other way — see
Where the library comes from — or you embed one
at build time (see Embedding the library).
First program
package main
import (
"context"
"fmt"
adbcbridge "github.com/singhpratech/adbcbridge/go"
"github.com/apache/arrow-go/v18/arrow/memory"
)
func main() {
ctx := context.Background()
// An ODBC connection string, exactly as unixODBC / the Windows driver
// manager would take it: "Driver=...;Database=...;" or "DSN=...;".
db, err := adbcbridge.Open(ctx, memory.DefaultAllocator,
"Driver=SQLite3;Database=my.db;",
map[string]string{"adbc.odbc.prefetch": "1"}) // extra options, optional
if err != nil {
panic(err)
}
defer db.Close()
cnxn, err := db.Open(ctx)
if err != nil {
panic(err)
}
defer cnxn.Close()
stmt, err := cnxn.NewStatement()
if err != nil {
panic(err)
}
defer stmt.Close()
if err := stmt.SetSqlQuery("SELECT 1 AS one"); err != nil {
panic(err)
}
rdr, _, err := stmt.ExecuteQuery(ctx)
if err != nil {
panic(err)
}
defer rdr.Release()
for rdr.Next() {
fmt.Println(rdr.RecordBatch())
}
if err := rdr.Err(); err != nil {
panic(err)
}
}
API
Function / method
What it does
DriverPath() (string, error)
Absolute path of the adbcBridge shared library (libadbc_driver_odbc), found as described below. The error is a *DriverNotFoundError whose Searched field lists every place tried.
An adbc.Driver (concretely *adbcbridge.Driver, wrapping drivermgr.Driver) whose NewDatabase(opts) adds the resolved path under the "driver" option. Pass "uri" and any adbc.odbc.* options yourself.
NewDriver + NewDatabase with connectionString stored under "uri" and options (may be nil) passed through. The database is initialised, not connected: call Open(ctx) on it for an adbc.Connection.
create an adbc.Database; the resolved path is added under "driver" unless you set "driver" yourself
alloc is the allocator ADBC Go drivers conventionally take. drivermgr v1.8.0
imports Arrow data through the C Data Interface and uses no allocator, so it is
kept only for API parity; passing nil selects memory.DefaultAllocator.
Exported constants
Constant
Value
Meaning
OptionKeyDriver
"driver"
drivermgr option naming the shared library to load
file name of the ADBC driver manifest that names the driver odbc
Embedded
true/false
whether this build carries an embedded copy (-tags adbcbridge_embed)
Where the library comes from
The module finds the library the same way the other language packages do — an
environment variable, a copy carried by the package, the ADBC odbc.toml
manifest, then the usual install directories — so one installed driver serves
Go, Python, Java and Rust alike. DriverPath returns the first hit from, in
order:
the ADBC_ODBC_DRIVER environment variable, then ADBCBRIDGE_LIBRARY — an
explicit value that does not exist is an error, not a silent fallback;
the copy embedded in the binary, if built with -tags adbcbridge_embed
(extracted to the user cache directory on first use);
the ADBC driver manifest odbc.toml in the directories the ADBC driver
manager searches: $ADBC_DRIVER_PATH, the user directory
(~/.config/adbc/drivers — $XDG_CONFIG_HOME if set —,
~/Library/Application Support/ADBC/Drivers, %LOCALAPPDATA%\ADBC\Drivers),
the system ones (/etc/adbc/drivers, /usr/local/etc/adbc/drivers,
/usr/share/adbc/drivers, /usr/local/share/adbc/drivers), and
etc/adbc/drivers / share/adbc/drivers under $VIRTUAL_ENV or
$CONDA_PREFIX;
common install locations (/usr/local/lib, /usr/lib, /opt/adbcbridge/lib,
/opt/homebrew/lib and their lib64; /usr/lib/<arch>-linux-gnu;
%ProgramFiles%\adbcbridge\{bin,lib}; the lib directories of $VIRTUAL_ENV
/ $CONDA_PREFIX) and a CMake build/ tree next to a source checkout of this
package.
The library file goes by these names, most-preferred first:
OS
File name
Linux
libadbc_driver_odbc.so
macOS
libadbc_driver_odbc.dylib
Windows
adbc_driver_odbc.dll, then libadbc_driver_odbc.dll
Tip. The driver's install.sh (run in a checkout) writes the odbc.toml
manifest into the user directory, so DriverPath resolves without any
environment variable after it. For a build tree, cmake --build build is
found by step 4 when a replace directive or go.work points at ./go.
If nothing matches, DriverPath — and so NewDriver and Open — return a
*DriverNotFoundError listing each place checked and why it did not match.
Connection strings
The connection string is an ordinary ODBC one, stored under "uri" — a
Driver=… string or a DSN=… string:
With Open, the connection string is passed as the third argument and wins over
any "uri" in the options map. With NewDriver + NewDatabase, put it in the
options map under OptionKeyURI ("uri") yourself.
Options
Pass driver options in the map given to Open (or NewDatabase). Standard ADBC
keys carry the connection and identity; adbc.odbc.* keys tune the driver. They
go through unchanged.
Key
Meaning
uri
full ODBC connection string (Driver=…;Server=…;)
dsn
DSN name from odbc.ini (appended as DSN=…)
username, password
appended as UID= / PWD=
adbc.odbc.batch_size
rows per Arrow batch (default 1024)
adbc.odbc.max_bind_bytes
widest value bound at its declared width, in bytes (default 32768)
adbc.odbc.long_bind_bytes
width, in bytes, for a column with no real declared bound (TEXT/NVARCHAR(MAX)/bytea); default 2048
adbc.odbc.rowset_bytes
ceiling on a reader's bound rowset buffers, in bytes (default 8388608)
adbc.odbc.decimal_as_string
true to return DECIMAL/NUMERIC as strings
adbc.odbc.partitions
partitions ExecutePartitions splits a query into — 0 (default) auto, 1 never. Set on the statement
adbc.odbc.prefetch
rowsets kept in flight on a background fetch thread — 0 (default) off, up to 8. Settable on database/connection/statement. (Compiled out on Windows; see Known limitations.)
adbc.odbc.delegate
auto (default) / never / always — native delegation
adbc.odbc.delegate.driver
force a specific native driver (bare or manifest name; a path only with allow_paths)
adbc.odbc.delegate.search_path
extra directories to search for native drivers (:-separated); needs allow_paths
adbc.odbc.delegate.allow_paths
true to let the two options above name filesystem paths (default false)
adbc.odbc.delegate.last_error
read-only: why delegation did not happen
adbc.odbc.delegated_to
read-only: the native driver serving this database/connection, or odbc
adbc.odbc.tune
true (default) / false — may the driver add ODBC connection keywords of its own where it recognises the target?
adbc.odbc.sqllen_32bit
force the 32-bit-SQLLEN driver quirk on/off (autodetected for IBM Db2). Also settable on connection/statement
adbc.odbc.rows_per_insert
rows of parameters per INSERT for bulk ingest — 0 (default) auto, 1 off
adbc.odbc.ingest_connections
connections a bulk ingest may spread over — 1 (default) single transaction; N > 1trades atomicity for speed(compiled out on Windows)
adbc.odbc.array_binding
true (default) binds each Arrow batch as a column-wise parameter array; false forces row-at-a-time
Native delegation
Where a native ADBC driver exists for the target (PostgreSQL, SQLite, DuckDB,
Snowflake, BigQuery, Flight SQL), adbc.odbc.delegate=auto (the default) hands
the whole database over to it for native speed from the same install. never
keeps adbcBridge on its own ODBC path; always requires delegation.
Queries with the arrow-go RecordReader
Statement.ExecuteQuery(ctx) returns an array.RecordReader (from
github.com/apache/arrow-go/v18/arrow/array), the number of rows if known, and
an error. Drive it with Next / RecordBatch / Err, and always Release it:
rdr, _, err := stmt.ExecuteQuery(ctx)
if err != nil {
return err
}
defer rdr.Release()
total := int64(0)
for rdr.Next() {
rec := rdr.RecordBatch() // arrow.RecordBatch for this chunk
total += rec.NumRows()
// rec.Column(i) is an arrow.Array; rec.Schema() is the schema
}
return rdr.Err() // checked after the loop ends
rdr.Schema() gives the Arrow schema before you pull any rows. For a statement
run for its side effect (DML), use ExecuteUpdate(ctx), which returns the
affected row count instead of a reader.
Parameters
Bind an Arrow record of parameter values with Statement.Bind(ctx, rec) (or a
whole stream with BindStream), then execute. Each row of the record is one set
of parameters:
if err := stmt.SetSqlQuery("INSERT INTO t (a, b) VALUES (?, ?)"); err != nil {
return err
}
if err := stmt.Bind(ctx, rec); err != nil { // rec: arrow.Record, its rows = parameter sets
return err
}
_, err := stmt.ExecuteUpdate(ctx) // rows affected
return err
Bulk ingest
Set the target table and ingest mode as statement options, bind the Arrow
record, and run ExecuteUpdate. adbcBridge generates the DDL and batches the
rows into multi-row INSERTs inside one transaction:
import "github.com/apache/arrow-adbc/go/adbc"
stmt, err := cnxn.NewStatement()
if err != nil {
return err
}
defer stmt.Close()
if err := stmt.SetOption(adbc.OptionKeyIngestTargetTable, "my_table"); err != nil {
return err
}
if err := stmt.SetOption(adbc.OptionKeyIngestMode, adbc.OptionValueIngestModeCreate); err != nil {
return err
}
if err := stmt.Bind(ctx, rec); err != nil { // the arrow.Record to ingest
return err
}
if _, err := stmt.ExecuteUpdate(ctx); err != nil {
return err
}
return cnxn.Commit(ctx) // if autocommit is off
To turn autocommit off, set it on the connection (which implements
adbc.PostInitOptions):
Tune ingest with adbc.odbc.rows_per_insert, adbc.odbc.array_binding and
adbc.odbc.ingest_connections (see the options table).
Metadata
adbcBridge implements the standard ADBC metadata calls on the adbc.Connection
interface — GetInfo, GetObjects, GetTableTypes and GetTableSchema —
backed by ODBC catalog functions and returning Arrow. Use them exactly as with
any ADBC Go driver; adbcBridge adds nothing of its own here.
Errors
Type
When
*adbcbridge.DriverNotFoundError
DriverPath / NewDriver / Open could not find the shared library. Fields: Library (the file name looked for on this OS) and Searched (every place checked, each with a note on why it did not match). The Error() text ends with how to fix it.
adbc.Error
query, connection and statement failures from the driver manager, carrying the structured ODBC diagnostics (SQLSTATE plus the native error code) adbcBridge maps from the driver.
Detect a missing driver with errors.As:
_, err := adbcbridge.DriverPath()
var notFound *adbcbridge.DriverNotFoundError
if errors.As(err, ¬Found) {
fmt.Println("looked in:", notFound.Searched)
}
Embedding the library
For a self-contained binary, copy the driver library into
go/internal/native/<goos>_<goarch>/libadbc_driver_odbc.so # linux
go/internal/native/<goos>_<goarch>/libadbc_driver_odbc.dylib # darwin
go/internal/native/<goos>_<goarch>/adbc_driver_odbc.dll # windows
— for example go/internal/native/linux_amd64/libadbc_driver_odbc.so — and
build with:
go build -tags adbcbridge_embed ./...
The tagged build //go:embeds that tree. On first use, DriverPath writes the
copy for the running platform to the user cache directory ($XDG_CACHE_HOME /
~/.cache, ~/Library/Caches, %LOCALAPPDATA% — or the temporary directory if
there is none) under adbcbridge/native/<content hash>/, and returns that path.
The extraction is atomic and keyed by the library's content, so upgrading the
binary never loads a stale copy and concurrent processes converge on the same
file. Step 1 of the lookup order still wins, so ADBC_ODBC_DRIVER can override
the embedded copy for debugging.
The internal/native/ tree is empty in the repository (only a .gitkeep) and
the library files are git-ignored. Without the tag the tree is not read at all;
with the tag but an empty tree the build still succeeds and DriverPath simply
moves on to the manifest and install directories. The constant
adbcbridge.Embedded reports whether an embedded build was compiled. Embedding
only helps when the module is built from a checkout (a replace directive, a
go.work, or a vendored copy) — go get fetches the published source, which
contains no library.
The extracted library still needs an ODBC driver manager (unixODBC / iODBC / the
Windows one) and the ODBC driver for your database, exactly as any other way of
loading it does.
Known limitations
cgo is mandatory. A pure-Go build (CGO_ENABLED=0) cannot use this
module; the ADBC driver manager is a cgo package. On Windows this means a GCC
toolchain (mingw-w64) and CGO_ENABLED=1; see cgo and a C
compiler.
No database/sql interface. This module exposes the ADBC Go API
(adbc.Database / adbc.Connection / adbc.Statement), not a
database/sql driver. If you need database/sql against ODBC, that is a
separate third-party driver, unrelated to adbcBridge.
Windows: no prefetch, no parallel ingest. The prefetch pipeline
(adbc.odbc.prefetch) and the ingest fan-out (adbc.odbc.ingest_connections)
use POSIX threads and are compiled out on Windows; those options have no
effect there. Queries and single-connection ingest work normally.
A third-party database/sql ODBC driver was measured to crash on
Windows. The repository's Go benchmark compares adbcBridge against
github.com/alexbrainman/odbc (a separate database/sql ODBC driver, not
part of adbcBridge). On Windows that library was measured to fault inside the
ODBC driver — Exception 0xc0000005 (access violation) in SQLGetDiagRec —
taking the process down, so the benchmark ran its comparison columns with that
path disabled
(bench/LANGUAGE_BENCHMARKS-windows.md).
This is a measured property of that separate library, not of adbcBridge, and
it does not affect adbcBridge's own ADBC path, which ran on Windows in the
same campaign.
You still need an ODBC driver for your database. adbcBridge is the bridge,
not the database driver: the driver manager still has to find the ODBC driver
your Driver=…/DSN=… names.
Complete worked example
A self-contained program that opens SQLite over ODBC, creates a table via bulk
ingest from an Arrow record, and reads the rows back — all through the ADBC Go
API. Build it with a C compiler on PATH (and CGO_ENABLED=1 on Windows).
package main
import (
"context"
"fmt"
adbcbridge "github.com/singhpratech/adbcbridge/go"
"github.com/apache/arrow-adbc/go/adbc"
"github.com/apache/arrow-go/v18/arrow"
"github.com/apache/arrow-go/v18/arrow/array"
"github.com/apache/arrow-go/v18/arrow/memory"
)
func main() {
ctx := context.Background()
// 1. Open the database. adbc.odbc.delegate=never keeps us on the ODBC path
// even if a native ADBC SQLite driver happens to be installed.
db, err := adbcbridge.Open(ctx, memory.DefaultAllocator,
"Driver=SQLite3;Database=example.db;",
map[string]string{"adbc.odbc.delegate": "never"})
if err != nil {
panic(err)
}
defer db.Close()
cnxn, err := db.Open(ctx)
if err != nil {
panic(err)
}
defer cnxn.Close()
// 2. Turn autocommit off so the ingest and its commit are one transaction.
if err := cnxn.(adbc.PostInitOptions).SetOption(
adbc.OptionKeyAutoCommit, adbc.OptionValueDisabled); err != nil {
panic(err)
}
// 3. Build a small Arrow record.
schema := arrow.NewSchema([]arrow.Field{
{Name: "id", Type: arrow.PrimitiveTypes.Int32},
{Name: "name", Type: arrow.BinaryTypes.String},
}, nil)
bld := array.NewRecordBuilder(memory.DefaultAllocator, schema)
defer bld.Release()
bld.Field(0).(*array.Int32Builder).AppendValues([]int32{1, 2, 3}, nil)
bld.Field(1).(*array.StringBuilder).AppendValues([]string{"ada", "grace", "linus"}, nil)
rec := bld.NewRecord()
defer rec.Release()
// 4. Bulk-ingest it into a new table.
ingest, err := cnxn.NewStatement()
if err != nil {
panic(err)
}
if err := ingest.SetOption(adbc.OptionKeyIngestTargetTable, "people"); err != nil {
panic(err)
}
if err := ingest.SetOption(adbc.OptionKeyIngestMode, adbc.OptionValueIngestModeCreate); err != nil {
panic(err)
}
if err := ingest.Bind(ctx, rec); err != nil {
panic(err)
}
if _, err := ingest.ExecuteUpdate(ctx); err != nil {
panic(err)
}
ingest.Close()
if err := cnxn.Commit(ctx); err != nil {
panic(err)
}
// 5. Read the rows back into Arrow.
query, err := cnxn.NewStatement()
if err != nil {
panic(err)
}
defer query.Close()
if err := query.SetSqlQuery("SELECT id, name FROM people ORDER BY id"); err != nil {
panic(err)
}
rdr, _, err := query.ExecuteQuery(ctx)
if err != nil {
panic(err)
}
defer rdr.Release()
total := int64(0)
for rdr.Next() {
total += rdr.RecordBatch().NumRows()
}
if err := rdr.Err(); err != nil {
panic(err)
}
fmt.Printf("read %d rows back\n", total)
}
Tip. If Open returns a *DriverNotFoundError, set ADBC_ODBC_DRIVER to
the path of libadbc_driver_odbc.{so,dylib} / adbc_driver_odbc.dll, run the
driver's install.sh in a checkout (which also writes the odbc.toml
manifest), or embed a copy with -tags adbcbridge_embed. The error lists
every location it checked.
C and C++
adbcBridge is a plain C11 shared library (libadbc_driver_odbc.so / .dylib /
.dll) that implements the ADBC (Arrow Database
Connectivity) C ABI over any ODBC
driver. From C or C++ you drive it through the ADBC 1.1.0 API: a struct AdbcDriver
vtable of function pointers, and the Arrow C data interface
(ArrowArray / ArrowSchema / ArrowArrayStream) for the data itself.
This page shows the whole surface on one page — loading the driver, the
database → connection → statement lifecycle, error handling, consuming results,
setting options, bulk ingest, and a full compilable example. It assumes C but not
prior ADBC or ODBC experience.
The only header you need to compile against is the ADBC C API, vendored in the
repository at:
include/arrow-adbc/adbc.h
It declares the status codes, the AdbcError, AdbcDatabase, AdbcConnection,
AdbcStatement and AdbcDriver structs, the option-key macros, and the
AdbcDriverInit entry-point signature. It is a single self-contained header with
no dependencies of its own.
For the Arrow C data interface types (ArrowArray, ArrowSchema,
ArrowArrayStream) you need a consumer library. The repository vendors
nanoarrow at vendor/nanoarrow/
(nanoarrow.h and nanoarrow.c), which is dependency-free and what the driver's
own C tests use. Arrow C++ works equally well (see C++ notes).
Note: the ADBC driver manager header (adbc_driver_manager.h) and library
are a separate package (part of the upstream Arrow ADBC distribution) and are
not vendored here. If you want to load the driver by name through the manager you
must install that yourself — see the two loading options below.
It fills a caller-provided struct AdbcDriver (the vtable) for the requested ADBC
version — pass ADBC_VERSION_1_1_0. There are two ways to reach it.
Option A — dlopen the library directly (no driver manager)
This is the fully self-contained route, and exactly what the driver's own smoke
test does. You open the shared library, resolve AdbcDriverInit, and call it:
From here every ADBC call is a member of driver: driver.DatabaseNew(...),
driver.StatementExecuteQuery(...), and so on. When you are finished, release the
vtable with driver.release(&driver, &error) and, on POSIX, dlclose(handle).
Note:AdbcDriverInit respects the requested version. Called with
ADBC_VERSION_1_0_0 it leaves the 1.1.0-only entry points (such as
StatementExecuteSchema and ErrorGetDetailCount) as NULL; an unknown version
returns a non-OK status with a message.
Option B — the ADBC driver manager, by name or path
If you have the ADBC driver manager installed, link against it and let it load the
library — by absolute path, or by the manifest name odbc when adbcBridge was
installed with cmake --install (which writes an odbc.toml manifest):
#include <arrow-adbc/adbc.h>
#include <arrow-adbc/adbc_driver_manager.h> /* from the driver-manager package */
struct AdbcDriver driver;
struct AdbcError error = ADBC_ERROR_INIT;
memset(&driver, 0, sizeof(driver));
AdbcStatusCode status = AdbcLoadDriver(
"/path/to/libadbc_driver_odbc.so", /* or "odbc" to use the manifest */
"AdbcDriverInit",
ADBC_VERSION_1_1_0,
&driver,
&error);
The rest of the code is identical to Option A. This page uses Option A in its
examples so they compile with nothing but the vendored header and libdl.
Error handling
Every ADBC call takes a struct AdbcError* as its last argument and returns an
AdbcStatusCode. ADBC_STATUS_OK (0) is success; anything else means the
AdbcError has been populated.
Always zero-initialise the error with ADBC_ERROR_INIT before first use:
struct AdbcError error = ADBC_ERROR_INIT;
On a non-OK status, the fields you read are:
Field
Meaning
error.message
Human-readable diagnostic (owned by the error; freed by release)
error.sqlstate
Five-character SQL:2003 SQLSTATE, or "\0\0\0\0\0" if none
error.vendor_code
Database-specific integer code, when provided
error.release
Callback that frees the error; call it, then re-zero the struct
Because ADBC 1.1.0 extends the struct, the driver only touches the 1.1.0 fields
(private_data, private_driver) when vendor_code equals
ADBC_ERROR_VENDOR_CODE_PRIVATE_DATA — which is what ADBC_ERROR_INIT sets. A
correct release-and-reset looks like this:
The status codes are ADBC_STATUS_OK, _UNKNOWN, _NOT_IMPLEMENTED,
_NOT_FOUND, _ALREADY_EXISTS, _INVALID_ARGUMENT, _INVALID_STATE,
_INVALID_DATA, _INTEGRITY, _INTERNAL, _IO, _CANCELLED, _TIMEOUT,
_UNAUTHENTICATED, _UNAUTHORIZED (values 0–14).
For richer error metadata, ADBC 1.1.0 adds ErrorGetDetailCount /
ErrorGetDetail on the vtable, and AdbcErrorFromArrayStream() retrieves an error
attached to a result stream.
An error does not poison a handle: a statement or connection is fully usable again
after a failed call.
Lifecycle: database, connection, statement
ADBC objects nest: one database holds configuration and yields one or more
connections; each connection yields statements. Every object is
New-ed, configured, Init-ed, used, then Release-d, in that order, and
released children before their parents.
The full sequence, with the vtable calls in order:
Key points, all exercised by the driver's C tests:
Configuration goes on the database. At minimum set uri (the ODBC
connection string) or dsn. DatabaseInit without either fails with
ADBC_STATUS_INVALID_ARGUMENT.
StatementExecuteQuery's out argument is optional. Pass a non-NULL
ArrowArrayStream* to receive the result set; pass NULL to run a statement for
its side effect only (DDL/DML), reading the affected-row count from
rows_affected (ODBC reports -1 when it has no meaningful count).
A statement is reusable. Set a new query and execute again; you may
StatementPrepare once and StatementExecuteQuery repeatedly.
Result streams outlive their statement. You may StatementRelease (or
execute a second query on the same statement) while an earlier stream is still
open; the stream stays independently readable until you release it. Release
order between a statement and its open streams is up to you.
Consuming an ArrowArrayStream
A result set arrives as a struct ArrowArrayStream — the Arrow C stream interface.
You call get_schema once, then get_next until it returns an array whose
release is NULL (end of stream). Each array is one record batch of up to
adbc.odbc.batch_size rows.
Using nanoarrow to read the values:
#include "nanoarrow/nanoarrow.h"
struct ArrowSchema schema;
memset(&schema, 0, sizeof(schema));
if (stream.get_schema(&stream, &schema) != 0) {
const char* msg = stream.get_last_error ? stream.get_last_error(&stream) : NULL;
/* handle error: msg */
}
struct ArrowArrayView view;
struct ArrowError na_error;
ArrowArrayViewInitFromSchema(&view, &schema, &na_error);
int64_t total = 0;
while (1) {
struct ArrowArray array;
memset(&array, 0, sizeof(array));
if (stream.get_next(&stream, &array) != 0) { /* error via get_last_error */ break; }
if (array.release == NULL) break; /* end of stream */
ArrowArrayViewSetArray(&view, &array, &na_error);
for (int64_t i = 0; i < view.length; i++) {
if (!ArrowArrayViewIsNull(view.children[0], i)) {
int64_t v = ArrowArrayViewGetIntUnsafe(view.children[0], i);
struct ArrowStringView s = ArrowArrayViewGetStringUnsafe(view.children[1], i);
/* use v and s.data / s.size_bytes */
}
}
total += array.length;
ArrowArrayRelease(&array);
}
ArrowArrayViewReset(&view);
ArrowSchemaRelease(&schema);
stream.release(&stream); /* releasing the stream is your responsibility */
The result schema is a struct (schema.format == "+s"); its n_children are the
columns, each children[c] carrying the column name and Arrow format string.
An exhausted stream keeps reporting end-of-stream, and get_schema keeps handing
out an independently owned schema copy — both are guaranteed by the stream
contract.
StatementExecuteSchema (ADBC 1.1.0) returns just the result schema of a query
without running it, useful for preparing consumers ahead of the data.
Setting options
Options are string key–value pairs, set at the level the driver reads them —
database, connection, or statement — with the corresponding SetOption. Integer
options can also be set with the typed SetOptionInt and read back with
GetOptionInt.
/* database-scoped: connection string, decimals as strings */
driver.DatabaseSetOption(&database, "uri", conn_str, &error);
driver.DatabaseSetOption(&database, "adbc.odbc.decimal_as_string", "true", &error);
/* statement-scoped: rows per Arrow batch, as an integer */
driver.StatementSetOptionInt(&statement, "adbc.odbc.batch_size", 4096, &error);
int64_t batch_size = 0;
driver.StatementGetOptionInt(&statement, "adbc.odbc.batch_size", &batch_size, &error);
adbcBridge's own options are prefixed adbc.odbc.; it also honours the standard
uri, dsn, username, password, adbc.connection.autocommit, and the
adbc.ingest.* options. The complete adbc.odbc.* table, with each key's scope,
values and default, is in the Python page's Options section —
the keys and semantics are the same from C. A few worth naming here:
Key
Scope
Default
Purpose
adbc.odbc.batch_size
database, connection, statement
1024
Rows per Arrow batch
adbc.odbc.decimal_as_string
database
false
Return DECIMAL/NUMERIC as strings
adbc.odbc.max_bind_bytes
database
32768
Widest column given a full bound buffer
adbc.odbc.long_bind_bytes
database
2048
Bind width for unbounded (TEXT/bytea) columns
adbc.odbc.prefetch
database, connection, statement
0
Background-fetch rowsets in flight (0–8)
adbc.odbc.rows_per_insert
statement
0 (auto)
Row-groups per multi-row INSERT for ingest
An unknown option returns a non-OK status with a message rather than being ignored.
Bulk ingest
To load an Arrow batch or stream into a table, set the ingest options on a
statement, bind the data, and execute. adbcBridge generates the CREATE TABLE
(for the create modes) and the batched INSERTs.
The mode macros are ADBC_INGEST_OPTION_MODE_CREATE, _APPEND, _REPLACE and
_CREATE_APPEND. StatementBind binds a single ArrowArray (with its
ArrowSchema); StatementBindStream binds an ArrowArrayStream and streams it in
batches. Under the hood adbcBridge sends one multi-row INSERT per K rows in a
single transaction, probing K against the driver at run time;
adbc.odbc.rows_per_insert overrides the choice and adbc.odbc.array_binding
selects column-wise ODBC parameter arrays where the driver honours them.
StatementBind / StatementBindStream also supply the parameters for a
parameterised query (one column per ?, one row per execution) — the same
mechanism, without the ingest options.
A complete, compilable example
A single file that loads the driver, opens a SQLite database over ODBC, creates and
populates a table, runs a query, and prints the row count. It uses only the
vendored adbc.h and nanoarrow, and libdl.
/* SPDX-License-Identifier: Apache-2.0 */
/* example.c — minimal adbcBridge C program (POSIX). */
#include <dlfcn.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <arrow-adbc/adbc.h>
#include "nanoarrow/nanoarrow.h"
typedef AdbcStatusCode (*AdbcDriverInitFunc)(int, void*, struct AdbcError*);
static void die(const char* what, struct AdbcError* err) {
fprintf(stderr, "%s: %s\n", what, err && err->message ? err->message : "(no message)");
if (err && err->release) err->release(err);
exit(1);
}
#define OK(call) do { \
AdbcStatusCode s_ = (call); \
if (s_ != ADBC_STATUS_OK) die(#call, &error); \
} while (0)
int main(int argc, char** argv) {
const char* lib = getenv("ADBC_ODBC_DRIVER");
if (!lib) lib = "libadbc_driver_odbc.so";
const char* uri = (argc > 1) ? argv[1]
: "Driver=SQLite3;Database=example.db;";
void* handle = dlopen(lib, RTLD_NOW | RTLD_LOCAL);
if (!handle) { fprintf(stderr, "dlopen: %s\n", dlerror()); return 1; }
AdbcDriverInitFunc init = (AdbcDriverInitFunc)dlsym(handle, "AdbcDriverInit");
if (!init) { fprintf(stderr, "dlsym: %s\n", dlerror()); return 1; }
struct AdbcError error = ADBC_ERROR_INIT;
struct AdbcDriver driver;
memset(&driver, 0, sizeof(driver));
OK(init(ADBC_VERSION_1_1_0, &driver, &error));
struct AdbcDatabase database;
memset(&database, 0, sizeof(database));
OK(driver.DatabaseNew(&database, &error));
OK(driver.DatabaseSetOption(&database, "uri", uri, &error));
OK(driver.DatabaseInit(&database, &error));
struct AdbcConnection connection;
memset(&connection, 0, sizeof(connection));
OK(driver.ConnectionNew(&connection, &error));
OK(driver.ConnectionInit(&connection, &database, &error));
/* DDL/DML: pass NULL for the out-stream, read the affected-row count. */
struct AdbcStatement stmt;
const char* ddl[] = {
"CREATE TABLE IF NOT EXISTS t (i INTEGER, s TEXT)",
"INSERT INTO t VALUES (1, 'one'), (2, 'two'), (3, 'three')",
};
for (size_t k = 0; k < sizeof(ddl) / sizeof(ddl[0]); k++) {
memset(&stmt, 0, sizeof(stmt));
OK(driver.StatementNew(&connection, &stmt, &error));
OK(driver.StatementSetSqlQuery(&stmt, ddl[k], &error));
int64_t affected = -1;
OK(driver.StatementExecuteQuery(&stmt, NULL, &affected, &error));
OK(driver.StatementRelease(&stmt, &error));
}
/* A SELECT: consume the ArrowArrayStream. */
memset(&stmt, 0, sizeof(stmt));
OK(driver.StatementNew(&connection, &stmt, &error));
OK(driver.StatementSetSqlQuery(&stmt, "SELECT i, s FROM t ORDER BY i", &error));
struct ArrowArrayStream stream;
memset(&stream, 0, sizeof(stream));
OK(driver.StatementExecuteQuery(&stmt, &stream, NULL, &error));
struct ArrowSchema schema;
memset(&schema, 0, sizeof(schema));
if (stream.get_schema(&stream, &schema) != 0) die("get_schema", NULL);
printf("columns: %lld\n", (long long)schema.n_children);
int64_t total = 0;
while (1) {
struct ArrowArray array;
memset(&array, 0, sizeof(array));
if (stream.get_next(&stream, &array) != 0) die("get_next", NULL);
if (array.release == NULL) break;
total += array.length;
ArrowArrayRelease(&array);
}
printf("rows: %lld\n", (long long)total);
ArrowSchemaRelease(&schema);
stream.release(&stream);
OK(driver.StatementRelease(&stmt, &error));
OK(driver.ConnectionRelease(&connection, &error));
OK(driver.DatabaseRelease(&database, &error));
OK(driver.release(&driver, &error));
dlclose(handle);
return 0;
}
Compiling and linking
The program does not link against the driver — it dlopens it at run time — so you
only need the header search paths and libdl. Point the compiler at the
repository's include/ (for arrow-adbc/adbc.h) and vendor/ (for nanoarrow),
and compile nanoarrow alongside:
cc -std=c11 -I /path/to/adbcbridge/include -I /path/to/adbcbridge/vendor \
example.c /path/to/adbcbridge/vendor/nanoarrow/nanoarrow.c \
-ldl -o example
# Tell it which driver library and database to use, then run:
ADBC_ODBC_DRIVER=/path/to/libadbc_driver_odbc.so ./example
On macOS drop -ldl (it is part of libc); on Linux keep it. If you load the driver
through the ADBC driver manager instead (Option B), add its
include path and link -ladbc_driver_manager in place of the dlopen code.
Building against the repository
The library is built with CMake. Its target and layout:
CMake target
adbc_driver_odbc (a SHARED library)
Output name
libadbc_driver_odbc.so / .dylib / .dll
Public header
include/arrow-adbc/adbc.h
Vendored Arrow consumer
vendor/nanoarrow/ (nanoarrow.h, nanoarrow.c)
C standard
C11
Depends on
an ODBC driver manager (unixODBC / iODBC / Windows odbc32) and Threads
Build it:
sudo apt install unixodbc-dev cmake # Debian/Ubuntu
brew install unixodbc cmake # macOS
# Windows: the ODBC driver manager ships with the OS
cmake -S . -B build && cmake --build build
# -> build/libadbc_driver_odbc.so
CMake finds the ODBC driver manager with find_package(ODBC); to build against a
specific one (for example iODBC on macOS), pass
-DODBC_INCLUDE_DIR=<dir> -DODBC_LIBRARY=<lib>.
cmake --install build --prefix <prefix> installs the library and, unless you pass
-DADBCBRIDGE_INSTALL_MANIFEST=OFF, an odbc.toml driver manifest so applications
can load the driver by the name odbc.
Your own C or C++ program does not link the driver target; it loads
libadbc_driver_odbc.* at run time. What you take from the repository at build time
is the header (include/) and, if you use it, nanoarrow (vendor/). To vendor them
into your own CMake project, add include and vendor to your target's include
directories exactly as the driver's own tests do
(target_include_directories(<your_target> PRIVATE include vendor)).
C++ notes
From C++ the driver is identical — the ADBC ABI is C — but you have richer options
for consuming results.
Arrow C++ imports the result stream directly. Given the ArrowArrayStream
that StatementExecuteQuery filled, hand it to
arrow::ImportRecordBatchReader(&stream) to get an
arrow::RecordBatchReader, then read arrow::RecordBatches with the full Arrow
compute and I/O surface. (Arrow C++ is a separate dependency, not vendored here.)
cpp
#include <arrow/c/bridge.h>
// ... after StatementExecuteQuery(&stmt, &stream, nullptr, &error):
auto reader = arrow::ImportRecordBatchReader(&stream).ValueOrDie();
std::shared_ptr<arrow::RecordBatch> batch;
while (reader->ReadNext(&batch).ok() && batch) {
// use batch
}
arrow::ImportRecordBatchReader takes ownership of the stream, so do not
also call stream.release.
Schemas and arrays similarly import with arrow::ImportSchema and
arrow::ImportArray / arrow::ImportRecordBatch if you consume batches by hand
rather than through a reader.
ADBC_ERROR_INIT has a C++ overload in the header (using AdbcError{...}),
so the same initialisation works. Wrap the release-on-scope-exit pattern in a
small RAII guard if you prefer.
nanoarrow is still available from C++ if you would rather not depend on the
full Arrow C++ build; it is the lighter choice for a small consumer.
Everything else — the lifecycle, options, error handling, ingest — is the same as
the C sections above.
Known limitations
The ADBC driver manager is a separate dependency. Only adbc.h (and
nanoarrow) are vendored here; loading by name/manifest (Option B) needs the
driver-manager package installed. The dlopen route (Option A) needs neither.
The ODBC driver manager and the database's ODBC driver are not part of this
library. You supply unixODBC/iODBC/odbc32 and the target database's ODBC
driver.
Native delegation is not available on Windows, and some advanced paths
(partitioning, prefetch) engage only when the underlying ODBC driver's behaviour
allows it — see the driver's README.md and docs/TROUBLESHOOTING.md.
Behaviour varies by ODBC driver. Type mappings, transaction support and
reported row counts are the underlying driver's. The dlopen-based smoke test and
examples here target SQLite for a dependency-light setup.
The C examples are POSIX (dlopen/dlsym). On Windows use LoadLibrary /
GetProcAddress, or load through the driver manager.
R
adbcBridge from R through the adbcdrivermanager package. R has a smoke test in the repository (tests/r/) and, as of 2026-09-04, no benchmark row.
library(adbcdrivermanager)
drv <- adbc_driver("/path/to/libadbc_driver_odbc.so", entrypoint = "AdbcDriverInit")
db <- adbc_database_init(
drv,
uri = "Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=my.db;"
)
con <- adbc_connection_init(db)
# read_adbc() returns a nanoarrow array stream; as.data.frame() materialises it.
df <- as.data.frame(read_adbc(con, "SELECT * FROM my_table"))
# Parameters are bound as a data frame: a column per '?', a row per execution.
execute_adbc(con, "INSERT INTO my_table (id, name) VALUES (?, ?)",
bind = data.frame(id = 1:2, name = c("ada", "grace")))
# Bulk ingest: create (or append to) a table from a data frame.
write_adbc(df, con, "my_copy")
adbc_connection_release(con)
adbc_database_release(db)
read_adbc() and execute_adbc() accept the database object directly if you
do not need an explicit connection. Results are nanoarrow array streams, so a
large one can be pulled a batch at a time — s <- read_adbc(con, "SELECT ...")
then s$get_next() until it returns NULL — instead of materialised with
as.data.frame(). See tests/r/ for a runnable example and the docker command
that runs it.
Performance, with the conditions attached
Every figure here names the machine and the load it was taken under; the benchmark files in bench/ carry the spreads and the commands that produced them.
Machines behind the numbers
Every figure in this repository was measured on one of four ordinary machines (the three below, plus the 4-core / 8 GB Windows laptop of the first Windows campaign, kept as history in the Windows benchmark files) — no
cloud instances, no dedicated benchmark hosts — and the files say which. Read the
numbers with these in mind:
unixODBC 2.3.12 from source (iODBC from source for iODBC-only vendor drivers)
the OS's own (odbc32), ANSI code page 1252
Compiler
gcc 13.3
Apple clang 21
MSVC 19.44 (VS 2022 Build Tools)
Databases
53 of 53 verified, Docker containers on the same machine (the full fleet often idling alongside); the matrix benchmarks below cover the 46 of the 2026-08-25 campaign
53 of 53 results: 45 pass, 8 without an obtainable driver or runnable server; servers in Docker Desktop, some amd64 images under emulation
53 of 53 results: 48 pass, 3 fail inside the vendor driver, 2 without an obtainable driver; servers in Docker Desktop on WSL2 (20 GB VM cap)
Load during runs
never idle: ~23 GiB of other work resident, 1-minute load typically 2–5, CPU governor powersave after a reboot
1-minute load 2.4–10.5, recorded per entry
up to 8 benchmark runs and ~30 idle containers at once; single samples
What the build lacks
—
—
prefetch pipeline and parallel ingest (pthreads, compiled out on _WIN32)
What this means for the headline figures: the PostgreSQL-vs-native ratio (1.2–1.5× fetch)
holds on the Linux host when it is quiet and drops to 0.97× with 46 containers idling and
0.60× on the M4 Max; per-language rates agree within a band on each machine but are not
comparable across machines; and Windows rows measure a materially different code path
(no prefetch, no fan-out), and were taken with several runs and containers sharing the
box. Each benchmark file opens with the
exact host state of its runs (bench/BENCHMARKS.md, bench/BENCHMARKS-macos.md,
bench/BENCHMARKS-windows.md).
Performance
1,000,000 rows (int, double, varchar(20), date) from SQLite, median of 5 (bench/BENCHMARKS.md):
Path
Time
Relative
adbcBridge fetch_arrow_table()
0.48 s
1.0×
pyodbc fetchall() → pyarrow.Table
1.16 s
2.4× slower
pyodbc fetchall() → pandas.DataFrame
1.32 s
2.8× slower
raw SQLBindCol+SQLFetch, no Arrow (floor)
0.44 s
0.93×
The bridge runs within 7% of the raw ODBC floor; the remaining cost is the ODBC driver itself.
That floor is also the ceiling for a single connection, so beating a native ADBC driver
means doing work it does not: splitting one query across several connections. Against
the native adbc_driver_postgresql, adbcBridge on one connection is 0.36× on 10 M
rows; at eight partitions it reads 1 M rows 1.2–1.5× faster on a quiet host, and at
twelve partitions 10 M rows 1.55× faster — mean of three, a fresh process per run, sides interleaved,
every read checksum-compared against a reference. The same 1 M read on a host with
46 idle containers came in at 0.97×, so the number to plan around is the low end, and
bulk ingest does not clear parity at all (0.73–1.02×: an INSERT writes twice the
WAL of the COPY the native driver uses). See
Partitioned reads and bench/BENCHMARKS.md.
Bulk ingest
adbc_ingest sends one INSERT INTO t VALUES (…),(…),… per K rows rather than one
statement per row, inside a single transaction. K is probed against the driver — SQLite's
999-variable limit, ClickHouse preparing 500 row-groups and then refusing to execute them,
and Oracle rejecting the multi-row form outright (it falls back to INSERT ALL … SELECT 1
FROM dual) are all discovered at run time and remembered on the connection. It works on
every driver that can bind a parameter, including the eight whose parameter arrays are unusable
(no_param_arrays; MySQL Connector/ODBC accepts them and walks them row by row), and adbc.odbc.rows_per_insert overrides the choice. 10,000 rows, rows/s:
Database
one statement per row
multi-row
ClickHouse 26 (300 rows)
16
911
Oracle 23ai
475
23,628
DuckDB
24,057
344,597
CockroachDB 26
1,205
14,259
MonetDB 11.55
12,190
138,902
MySQL 8.4 (50,000 rows)
20,784
200,247
PostgreSQL 16
102,508
312,025
SQLite 3.45 (50,000 rows)
634,417
866,080
StarRocks 4.1.4
10
4,783
Apache Doris 2.1.0
7
2,184
CrateDB 6.4
820
49,986
GreptimeDB 1.1.4
5,171
180,760
Multi-row INSERT batching
adbc_ingest builds its own INSERT, so it can pack K rows into one statement:
INSERT INTO t ("a", "b") VALUES (?, ?), (?, ?), (?, ?), … -- K row-groups
K rows' worth of scalar parameters go in per SQLExecute, over the same
SQLBindParameter calls and the same per-driver parameter handling as one row at a
time — no parameter arrays are involved, so it works on every driver that can bind
ordinary parameters. It is what makes ingest fast on the drivers whose parameter
arrays are unusable (DuckDB, MonetDB, clickhouse-odbc, QuestDB via psqlodbc) and on
the ones where an array is no cheaper than a loop (MySQL Connector/ODBC), and it is
faster than arrays on most of the drivers where arrays do work — so it is the default
for ingest, with parameter arrays kept ahead of it only where they measurably win: MariaDB
Connector/ODBC before 3.2, whose arrays go out as a single COM_STMT_BULK_EXECUTE;
Vertica's driver, which turns an array into one native bulk load; Altibase's, where a bound
array ingests at 778–816k rows/s against 30k for the multi-row form; and SAP HANA's client,
whose dialect has no multi-row VALUES at all and whose bound arrays reach 296,082 rows/s
against 3,506 one row at a time.
Only the INSERT that bulk ingest generates is ever rewritten. A query you wrote is
executed as written, whatever is bound to it.
PostgreSQL is a step ahead of this: there a batch goes as one array parameter per column
rather than as K row-groups of cells, and the multi-row form is what the ingest falls back
to. See PostgreSQL: one array parameter per
column.
K is chosen from a parameter budget (2000 parameters, at most 1000 row-groups — SQL
Server's limits are 2100 and 1000) and clipped by the driver's SQL_MAX_STATEMENT_LEN.
ODBC has no "maximum parameters" question to ask, so the real ceiling is probed: a
SQLPrepare (or, for a driver that only objects later, the first SQLExecute) that is
refused halves K and asks again, and the answer is remembered on the connection. A
server with no multi-row VALUES at all is found the same way — by a two-row
SQLPrepare before anything is written — and ingest simply carries on as before:
server
what happens
Oracle
on releases without a multi-row VALUES, VALUES (…),(…) is ORA-00933 and the probe re-asks with INSERT ALL INTO t VALUES (…) INTO t VALUES (…) SELECT 1 FROM dual; Oracle 23.26 takes the plain form and the fallback is never reached
SQLite
2000 parameters is over the limit of a 999-variable build; K halves until it prepares
ClickHouse
clickhouse-odbc prepares the first multi-row form and then refuses to execute it; K halves until a form runs
Firebird (OdbcFb)
no multi-row VALUES and no INSERT ALL; the probe re-asks a third time with INSERT INTO t (cols) SELECT CAST(? AS <type>), … FROM RDB$DATABASE UNION ALL SELECT … — a bare ? in a select list has no type Firebird can infer, and the CAST names the type ingest would have created for that column, so it cannot narrow anything the plain form would not
Cloud Spanner (PGAdapter)
950 parameters per statement is a hard limit, and one that cannot be probed: a bigger statement prepares fine and drops the connection at execute, so it is declared and K is capped at 237 four-column rows
A failure part way through is unchanged by any of this: the whole ingest is one
transaction, so it commits completely or leaves nothing behind.
PostgreSQL: one array parameter per column
Against PostgreSQL the driver goes one step further and sends a whole column of a batch
as a single array parameter, letting the server expand it:
INSERT INTO t ("a", "b", "c", "d")
SELECT * FROM unnest(?::bigint[], ?::float8[], ?::text[], ?::date[])
That is one parameter per column however many rows the statement carries (10,000 by
default), instead of one bound cell per value. On 1,000,000 four-column rows it takes the
single-connection ingest from 2.33 s to 1.40 s and the sixteen-connection ingest from
0.58 s to 0.34 s, with about a third less total CPU (all of it on the client; server CPU
is unchanged). It is on by default and needs no option.
It is deliberately narrow, because a wrongly quoted array literal is a data-corruption bug
rather than a slow one:
Only PostgreSQL. psqlodbc drives every PostgreSQL-wire server, so the quirk is keyed
on what the server says it is, not on the driver: version() has to be a PostgreSQL
banner and must not carry a fork's marker. Checked against live servers: on for
PostgreSQL itself, TimescaleDB and Citus (both of which are PostgreSQL, with an
extension); off for CockroachDB, YugabyteDB, CrateDB, RisingWave, Materialize, openGauss
and Apache Cloudberry. QuestDB, ArcadeDB, YDB and Cloud Spanner via PGAdapter are
excluded by the same rule without having been tried. Before the form is used on a
connection the server is also asked to prove it: one statement whose answer pins down
NULL elements, empty elements, and ,, {, }, " and \ inside a quoted element.
(That check on its own is not enough — CockroachDB passes it — which is why the identity
test comes first and unknown servers default to off.)
Only the types it can spell exactly: the integers, float16/32/64, decimal128/256,
bool, date32, time32/64, timestamp and the string types (including
large_string and string_view). A batch with a binary, interval, list, struct, map,
dictionary or null-typed column keeps the multi-row INSERT path in full.
It falls back rather than guessing. A target column PostgreSQL will not
assignment-cast to (an Arrow date against a text column, say) is refused at
SQLPrepare, before anything has been applied, and the ingest replays on the multi-row
path. A single value the renderer cannot spell — a string with an embedded NUL, a date or
timestamp outside years 0001–9999 — stops the array form at that row: the rows before it
are already in, and the rest of the ingest goes the ordinary way. Nothing is written
twice and nothing is dropped.
adbc.odbc.rows_per_insert=1 turns it off along with the multi-row form.
Parallel ingest: trading atomicity for speed
adbc.odbc.ingest_connections spreads one ingest over several connections. One thread
drains the bound stream and hands batches to N workers, each with its own connection,
statement handle and transaction, each running the ordinary ingest path into the same
table — the array form above where the server is PostgreSQL, the multi-row INSERT
everywhere else. On PostgreSQL it takes 1,000,000 rows from 1.40 s to 0.34 s; the curve
flattens at 12–16 workers. (With the multi-row form, which is what every other server
gets, the same load went from 2.20 s to 0.505 s.)
It is opt-in because it is not atomic, and it defaults to 1.N connections are N
transactions. A worker that fails trips the queue, so every worker still running fails its
own read of the stream and rolls its share back — but a worker that had already reached the
end of the queue has already committed, and those rows stay in the table. What the caller
gets is an error naming the worker and the server's complaint; what is left behind is some
unspecified subset of the stream. Never a corrupt or invented row, and never one that
violates a constraint — but not all-or-nothing either. Use N > 1 only where a partially
populated table on failure is acceptable, or where the caller drops and retries. N = 1 is
the default, is atomic: one connection, one transaction.
Two further things to know. Fanning out needs the target table to be visible to the worker
connections, so when the caller is inside its own transaction (autocommit off) the
CREATE TABLE is uncommitted and invisible; the driver quietly keeps the ingest on one
connection instead, which is correct and atomic and merely slower. And it is a
server-side-parallelism play: on a single-writer database it backfires — SQLite's ingest
of 200,000 rows goes from 0.224 s to 0.273 s on four connections, the workers contending
for the one write lock.
Even at its best this does not catch the native PostgreSQL driver, which ingests with
COPY … (FORMAT binary). The array form closes most of the gap — at 1,000,000 rows and
sixteen connections the two are within noise of each other (0.9–1.0x over repeated runs,
best 0.317 s against 0.322 s) (the multi-row form alone reaches 0.77x) — but not all of it, and at 10,000,000 rows it stays about 1.4x behind. The
reason is not the statement shape: an INSERT writes twice the WAL a COPY does
(96.4 MB against 48.8 MB for the same million rows), because COPY batches tuples into
one WAL record per page and INSERT writes one per row. That is what caps the parallel
curve — over half of the backends' time at N=16 is spent waiting on WAL and buffer locks —
and no statement an ODBC driver can send gets underneath it, COPY itself being
unreachable through the ODBC API. See bench/BENCHMARKS.md for the
measurements. A caller who needs native ingest speed against PostgreSQL should let the
driver delegate to adbc_driver_postgresql.
MariaDB (Connector/ODBC before 3.2), Vertica, Altibase and SAP HANA keep ODBC parameter
arrays, which are faster there. Firebird has no
multi-row VALUES in its dialect and takes a UNION ALL of typed one-row SELECTs
instead (5,974 → 7,924 rows/s).
Rust
bench/rust/ runs the same read and bulk-ingest workload from Rust, comparing the bridge
against the odbc-api and
arrow-odbc crates talking to the same ODBC driver.
Per-database results are in bench/RUST_BENCHMARKS.md.
Every language
bench/csharp/, bench/java/ and bench/go/ run that same ingest-and-fetch workload from
C#, Java and Go. bench/LANGUAGE_BENCHMARKS.md puts all five
bindings — Python, Rust, C#, Java and Go — side by side on the same table, so you can see how
much of the cost is the driver and how much is the language's driver manager.
Native delegation
Where a native ADBC driver exists for the database behind your connection string, adbcBridge hands the whole driver over to it.
Native speed where a native ADBC driver exists, ODBC everywhere else, one
install.
Some databases already have a first-class ADBC driver: PostgreSQL, SQLite,
DuckDB, Snowflake, BigQuery, Flight SQL. Those drivers talk the wire protocol
and build Arrow directly, so they are faster than anything that has to go
through ODBC's row-oriented API — 1,000,000 PostgreSQL rows take about 0.2 s through
adbc_driver_postgresql and 0.5 s through adbcBridge on one connection over psqlodbc
(bench/BENCHMARKS.md).
So adbcBridge gets out of the way. When AdbcDatabaseInit recognizes a target
that a native driver handles, it loads that driver, initializes it with the
translated options, and from then on forwards every database, connection and
statement call straight to it. Result sets are the native driver's own
ArrowArrayStream, handed to the caller untouched: delegation costs one
function-pointer hop per ADBC call and nothing at all per row, and delegated
fetches measure the same as calling the native driver directly (the same
0.2 s for the million rows above as the native driver, against 0.5 s over psqlodbc).
The delegated connection is the connection you configured
Rebuilding a native URI out of an ODBC connection string is only safe if every
keyword is accounted for. Dropping SSLmode=verify-full would turn a verified
TLS session into libpq's default (sslmode=prefer, no certificate check)
without a word to anyone, so adbcBridge does not drop anything:
Forwarded as libpq URI parameters: sslmode, sslrootcert, sslcert,
sslkey, sslcrl, sslcompression, sslsni, gssencmode,
channel_binding, krbsrvname/pgkrbsrvname, connect_timeout,
application_name, options, target_session_attrs, require_auth,
load_balance_hosts, and every libpq setting inside psqlodbc's
pqopt={…} block.
Ignored: keywords that only steer the ODBC driver's own client-side
behaviour (UseDeclareFetch, Fetch, Protocol, BoolsAsChar,
TextAsLongVarchar, RowVersioning, sqliteodbc's StepAPI/LongNames, …)
and driver-manager bookkeeping (Description, Trace, UsageCount, …).
Anything else stops delegation.ReadOnly=1, ConnSettings=…,
sqliteodbc's FKSupport/JournalMode/LoadExt, DuckDB's access_mode: in
auto the connection quietly stays on ODBC (with the keyword named in
adbc.odbc.delegate.last_error), in always it is an error. The same goes
for a DSN whose keywords the driver manager cannot enumerate.
Values are percent-encoded into the URI, so a Database= or Server= that
contains ?, & or = stays a database name instead of becoming extra libpq
parameters. Server=/var/run/postgresql becomes ?host=/var/run/postgresql,
Server=::1 becomes [::1], and the ODBC }} escape inside a brace-quoted
value is a literal }.
When delegation does not happen
Delegation is a best-effort optimization: if no native driver is installed, if
it cannot be loaded, or if the target cannot be represented, auto falls back
to ODBC and records why in adbc.odbc.delegate.last_error (always turns the
same situation into an error). Two cases deliberately do not fall back
silently:
A native URI that the native driver rejected reports the native driver's
own error. ODBC cannot parse postgresql://… at all, so falling back would
replace "password authentication failed" with unixODBC's [IM002] Data source
name not found.
A native URI on the ODBC path (delegate=never, or no native driver
installed) is translated into an ODBC connection string for an installed ODBC
driver of the same family, or, if there is none, refused with a message that
says so.
Options meant for a native driver (adbc.* other than adbc.odbc.*) are held
until that decision is made and passed on to the native driver; if delegation
does not happen, AdbcDatabaseInit reports the option as unknown rather than
dropping it. Connection options work the same way: a connection does not know
who will serve it until AdbcConnectionInit, so an option ODBC does not
recognize (Flight SQL's adbc.flight.sql.rpc.call_header.*, a Snowflake
connection setting, …) is held there too — replayed on the native connection,
through the same typed setter it was set with, or reported by
AdbcConnectionInit if the connection ends up on ODBC. ODBC-specific options
(adbc.odbc.batch_size, …) are meaningless to a native driver and are rejected
by it. All adbc.odbc.delegate* options are frozen once AdbcDatabaseInit has
run: setting them afterwards is INVALID_STATE, not a silent no-op.
Finding the native driver
adbcBridge never links against the ADBC driver manager (that would be a
circular dependency); it resolves AdbcLoadDriver from whichever manager is
already in the process and looks in, in order: driver manifests
(<name>.toml) under ADBC_DRIVER_PATH, $XDG_CONFIG_HOME/adbc/drivers (or
~/.config/adbc/drivers) and /etc/adbc/drivers; Python wheel layouts
(site-packages/adbc_driver_postgresql/) next to whatever ADBC object is
already loaded; the directories in adbc.odbc.delegate.search_path /
ADBC_ODBC_DELEGATE_PATH; and finally libadbc_driver_<name>.so on the
dynamic loader's own search path. Only AdbcLoadDriver is used: the newer
AdbcFindLoadDriver changed its signature between driver manager releases.
Because a database option that names a shared library is a code-execution
primitive for any host that forwards caller-supplied options (BI tools, DBAPI
db_kwargs, connector configuration), adbc.odbc.delegate.driver accepts only
a bare driver name — letters, digits, _ and - — by default, and
adbc.odbc.delegate.search_path is refused outright. Paths are opt-in:
ADBC_ODBC_DELEGATE_PATH and ADBC_DRIVER_PATH are ignored for setuid/setcap
processes, adbcBridge refuses to delegate to itself, and a bare
libadbc_driver_<name>.so is never picked up from a directory that was merely
derived from a loaded object's parent (only the exact wheel layout is).
# Off, for this database:
dbapi.connect(driver="odbc", db_kwargs={"uri": uri, "adbc.odbc.delegate": "never"})
# Off, for a whole deployment:
# export ADBC_ODBC_DELEGATE=never
# Required, so that a missing native driver is an error instead of a slow path:
dbapi.connect(driver="odbc", db_kwargs={"uri": uri, "adbc.odbc.delegate": "always"})
Who served a connection is visible through adbc_get_info()["driver_name"]
(ADBC PostgreSQL Driver vs ADBC ODBC Driver) and through
adbc.odbc.delegated_to, which names the native driver (postgresql) or
answers odbc, on both the database and the connection.
Delegation needs the driver manager's loader to be reachable in the process,
and it is not implemented on Windows — there auto always takes the ODBC path
and always fails with a clear message. The native-URI-to-ODBC fallback is
unavailable on Windows for the same reason: it picks the ODBC driver to fall
back to by enumerating odbcinst.ini, which is not implemented there either, so
a native URI on the ODBC path is refused with an explanation instead.
Partitioned reads (ExecutePartitions)
One connection reading a large table is one CPU decoding it. ADBC's partition contract
exists for exactly that: AdbcStatementExecutePartitions hands back N opaque
descriptors, and AdbcConnectionReadPartition turns any one of them back into a stream
— on any connection, in any order, in any process. adbcBridge implements both, so N
connections can read one query in parallel and the pieces concatenate.
import concurrent.futures, pyarrow
import adbc_driver_manager.dbapi as dbapi
def open_conn():
return dbapi.connect(driver="libadbc_driver_odbc.so", db_kwargs={
"adbc.odbc.connection_string": "Driver=PostgreSQL Unicode;Server=...;"})
with open_conn() as conn, conn.cursor() as cur:
cur.adbc_statement.set_options(**{"adbc.odbc.partitions": "8"})
cur.adbc_statement.set_sql_query("SELECT id, val, txt, dt FROM bench")
descriptors, schema, _ = cur.adbc_statement.execute_partitions()
def read(descriptor): # one connection per partition
with open_conn() as conn:
stream = conn.adbc_connection.read_partition(descriptor)
return pyarrow.RecordBatchReader._import_from_c(stream.address).read_all()
with concurrent.futures.ThreadPoolExecutor(len(descriptors)) as pool:
table = pyarrow.concat_tables(list(pool.map(read, descriptors)))
bench/partition_bench.py is that client, benchmarked against the native
adbc_driver_postgresql; the numbers are in
bench/BENCHMARKS.md.
How the split is made. Every strategy produces the same shape: an ordered SQL
expression that is never NULL for a row of the table, and N-1 boundary values cutting it
into N half-open intervals, with the first slice unbounded below and the last unbounded
above. Half-open intervals put a row that lands exactly on a boundary in one slice and
only one, so repeated values are neither doubled nor dropped; the unbounded ends make
the union of the slices the whole domain of the expression, so boundaries computed from
stale metadata cost balance but never rows.
Three strategies, tried in that order:
#
expression
applies when
boundaries from
1
ctid
PostgreSQL, and the relation has a heap
pg_relation_size (block count)
2
the leading primary-key column
there is a single-table primary key whose leading column is an integer, NOT NULL, and ordered
one SELECT MIN(k), MAX(k)
3
yb_hash_code(k)
YugabyteDB, where the primary key is hash-partitioned
fixed: the hash is a uint16
1 — the PostgreSQL heap. Every heap tuple has a ctid of (block, offset) and tid
compares lexicographically, so the table's blocks are a total order that slices the heap
with no index, no key column and no sort:
SELECT … FROM t WHERE ctid >= '(lo,0)'::tid AND ctid < '(hi,0)'::tid
Tuple offsets are 1-based, so '(N,0)' is a point strictly between blocks: no tuple
can land on both sides of a boundary or on neither. PostgreSQL 14+ executes each slice as
a TID Range Scan, so a slice reads only its own blocks rather than filtering a full
sequential scan. This is the best split where it exists — no index needed, balanced in
bytes rather than key values, and read sequentially — so it stays first.
2 — the key range.ctid is a heap detail, and several PostgreSQL-wire servers do
not store tables in a heap: CockroachDB answers SELECT ctid with 42703, YugabyteDB
with 0A000. What they do have is a primary key, which is indexed, ordered and
NOT NULL — the three things the split shape needs:
SELECT … FROM t WHERE "id" >= 125001 AND "id" < 250001
The gate is built out of ODBC's own catalog calls — SQLPrimaryKeys for the leading key
column, SQLColumns for its type and nullability — so it is not tied to any one server's
system tables. It also reaches tables PostgreSQL itself could not split before: a
declaratively partitioned parent has no heap of its own, and now gets N partitions where
it used to get one.
The primary key is required, not merely preferred: a range predicate on an unindexed
column makes every slice scan the whole table, so N slices would be N times the server
work. That is also why there is no modulo-or-hash split (WHERE mod(hash(k), N) = i),
which balances perfectly and is never index-usable, and no LIMIT/OFFSET split, where
the server must produce and discard OFFSET rows per slice.
3 — YugabyteDB's tablet hash. YugabyteDB's default PRIMARY KEY (id) is
hash-partitioned, and against a hash-partitioned key WHERE id >= a AND id < b is not
an index condition at all — it is a Storage Filter over a Seq Scan. A key-range split
there would be correct and N times slower than not splitting. So the ordered expression
becomes the tablet hash itself, over the fixed range [0, 65536):
SELECT … FROM t WHERE yb_hash_code("id") >= 8192 AND yb_hash_code("id") < 16384
On any PostgreSQL-wire server the driver reads pg_index.indoption — where YugabyteDB
records hash partitioning as bit 0x4 — before it will emit a key-range predicate, and
declines to split at all if it cannot read it.
When it falls back to one partition. A wrong split is silent data loss rather than an
error, so the driver splits only what it can prove, and returns a single descriptor
carrying the original query — always correct, never slower than not calling
ExecutePartitions — for everything else:
case
why
anything but a bare SELECT <list> FROM <one table>
a WHERE, JOIN, ORDER BY, GROUP BY, LIMIT, DISTINCT, UNION, subquery, CTE, aggregate or any parenthesis at all
any quoting or comment in the SQL
the scanner does not track quotes, so it refuses rather than mistake a keyword inside a literal for a keyword
a statement with bound parameters
a descriptor carries SQL text; there is nowhere to put them
no heap and no usable primary key
a view or foreign table; a table with no primary key, or one whose leading key column is text, numeric, a date, or nullable
a unique index that is not the primary key
choosing between several is not a choice the catalog makes for us, so the driver does not make it either
a PostgreSQL-wire server that will not say how its key is ordered
the difference between an index condition and N full scans is not something to guess
an empty table, or a key with one distinct value
nothing to slice
adbc.odbc.partitions=1
the explicit off switch
Nothing here ever guesses a partition column: each strategy either proves its
expression is total and NOT NULL over the table from the catalog, or hands back one
partition.
Snapshot semantics. Partitions are read on separate connections and therefore under
separate snapshots. Against a table being written concurrently, the union of the slices
is not a point-in-time view — that is inherent to ADBC's partition model, which has
nowhere to carry a shared snapshot, and is why partitioning is opt-in. Against a table
that is not being written, the slices reproduce the unpartitioned read exactly: same
rows, same schema, no duplicates, no gaps, whatever order they are read in.
When the connection is delegated to a native ADBC driver, both
entry points forward to that driver and its own partitioning (or its own refusal)
applies.
Prefetch
SQLFetch blocks on the socket while the CPU is idle, and the conversion into Arrow then
runs while the socket is idle. adbc.odbc.prefetch=1 overlaps them: a background thread
fetches the next rowset into a second set of bound buffers while the calling thread
converts the current one. Higher values keep more rowsets in flight, up to 8.
It is off by default, because whether it is safe is a property of the ODBC driver
underneath and no driver can be asked. It is also absent from the Windows build: the
pipeline is pthreads and is compiled out on _WIN32 until the Win32 thread shim lands
(roadmap); adbc.odbc.prefetch is accepted there and reads run
unpipelined. Two things make it safe where it does engage:
The statement handle is owned by exactly one thread at a time. The fetch thread owns it
from pthread_create to pthread_join and the calling thread touches it only outside
that window, so no two ODBC calls on one handle are ever concurrent.
It engages only when every column is bound at a width that cannot truncate. Repairing a
truncated value means SQLGetData or SQLFetchScroll on a row the fetch thread has
already read past, so a column bound narrower than its declared width (a TEXT or
NVARCHAR(MAX); see adbc.odbc.long_bind_bytes) turns prefetch off for that query
rather than racing for the cursor. So does a column the driver cannot bind at all, and
a driver that fetches one row at a time.
Falling back is silent and changes nothing about the rows. Errors are unchanged too: a
failure part way through a result set arrives at the same row it would have without
prefetch, and releasing a stream early stops the fetch thread before the handle is freed.
It is worth very little in practice, and the reason is worth knowing: most ODBC
drivers have already buffered the whole result set client-side by the time SQLFetch is
called, so there is no socket wait left to hide. Measured on 1,000,000 rows (medians of
5, bench/prefetch_bench.py):
driver
prefetch=1
prefetch=2
psqlodbc, default
1.00×
1.01×
psqlodbc, UseDeclareFetch=1
1.06×
1.10×
sqliteodbc
1.01×
1.01×
MariaDB Connector/ODBC
1.04×
0.93×
Only psqlodbc's cursor mode leaves a real socket wait inside SQLFetch, and even there
it buys 6–10%. For a big PostgreSQL read, partitioning
is the mechanism that pays.
Connection keywords set for you
What adbc.odbc.tune does: the ODBC connection keywords adbcBridge adds for a driver it recognises, and the ones it deliberately leaves alone.
Some ODBC drivers have connection keywords whose good value depends on how the
application reads a result set — something the driver cannot know and you should
not have to. Where adbcBridge recognises the target driver it fills those in
while it assembles the connection string, under three rules: a keyword you
set (in the connection string or in the DSN) is never overridden, nothing that
changes what a query returns is ever set, and adbc.odbc.tune=false turns the
whole thing off.
The complete list as of v0.1.2 is one keyword on every platform, and a second one on Windows only, described below the table:
driver
condition
what is added
why
psqlodbc (PostgreSQL and the thirteen other PostgreSQL-wire servers in the matrix)
you set UseDeclareFetch=1 and no Fetch
Fetch=8192 (8 × adbc.odbc.batch_size, clamped to 8192…65536)
UseDeclareFetch=1 asks psqlodbc to stream the result set through a server-side cursor instead of buffering all of it client-side. Each FETCH then brings back max(Fetch, rowset) rows, so psqlodbc's default Fetch=100 is inert — our rowset always wins it — and the cursor round-trips once per rowset. 1M rows of (int4, float8, varchar(20), date) at batch_size 1024: 0.72 s at the default Fetch against 0.57 s with this, which is exactly what the same read costs not streaming. Peak process RSS for that read is 158 MB streaming against 422 MB buffered
On Windows there is a second one, LFConversion=0, added for psqlodbc (recognised
by its Driver= value, or by UseDeclareFetch behind a DSN) whenever you did not
set LFConversion yourself. psqlodbc's default for that keyword is 1 on Windows
and 0 everywhere else, and at 1 the driver rewrites every LF in a fetched text
value to CR LF — "line1\nline2" comes back as "line1\r\nline2", bytes the
server never stored. 0 is what makes a Windows read return what Linux and macOS
already do.
psqlodbc's other keywords were swept and are deliberately not set:
ByteaAsLongVarBinary, TextAsLongVarchar, MaxVarcharSize and UnknownSizes
change the SQL types and widths the driver reports, and so the Arrow schema and
the DDL bulk ingest generates; TrueIsMinus1 rewrites values; UseDeclareFetch
and Protocol are transaction semantics (a server-side cursor and per-statement
SAVEPOINTs, neither of which every PostgreSQL-wire server behind psqlodbc has).
Everything else measured flat, within ±4% of the default on a 1M-row read.
If you do turn UseDeclareFetch=1 on, note that it is genuinely a different
mode, not just a buffer size: the read becomes O(Fetch) in client memory
instead of O(result set), it needs a server that implements DECLARE … CURSOR
WITH HOLD and FETCH n IN …, and rolling back a transaction with the cursor
still open leaves the statement handle needing a fresh cursor.
Options
adbcBridge is an ADBC (Arrow Database Connectivity) driver that reaches a
database through an ODBC (Open Database Connectivity) driver. It is configured
the ADBC way: string key/value options set on a database, connection, or
statement handle. This page lists every option the driver accepts, its level,
type, default and effect, then explains the notable ones in depth, and finally
documents the environment variables the library and its language bindings read.
Options come in two families:
adbc.odbc.* — options specific to adbcBridge.
Standard ADBC options the driver honours: uri, username, password,
adbc.connection.autocommit, adbc.connection.catalog, and the bulk-ingest
options adbc.ingest.*.
Values are always strings (ADBC also offers typed setters — integer, double,
bytes — which adbcBridge maps onto the string form where meaningful). Unless a
default is noted, an option is simply unset.
An option set at a broader level is inherited by narrower ones opened afterward:
adbc.odbc.batch_size set on the database is the starting value for every
connection and statement, each of which may override it. One caveat at the
connection level: AdbcConnectionInit copies the database's
adbc.odbc.batch_size, adbc.odbc.prefetch and adbc.odbc.sqllen_32bit
values over the connection's, so a connection-level value for one of those set
beforeAdbcConnectionInit (the order the driver manager's
connect(conn_kwargs=...) uses) is discarded. Set them after init, or on the
database or statement instead. Re-applying the pre-init values after the copy is
a roadmap item; see ROADMAP.md.
Database options
Set with AdbcDatabaseSetOption before AdbcDatabaseInit.
Key
Type / values
Default
Effect
uri
string
—
The ODBC connection string, e.g. Driver=SQLite3;Database=my.db;, or a native ADBC URI when delegating. Alias: adbc.odbc.connection_string.
adbc.odbc.connection_string
string
—
Same as uri.
dsn
string
—
An ODBC Data Source Name (a driver + settings registered in odbc.ini). One of uri/dsn is required.
username
string
—
Appended as UID= to the connection string.
password
string
—
Appended as PWD=. Not readable back.
adbc.odbc.batch_size
positive integer
1024
Rows per Arrow batch (SQL_ATTR_ROW_ARRAY_SIZE).
adbc.odbc.prefetch
integer 0–8
0
Rowsets kept in flight on a background fetch thread. 0 off, 1 double-buffering.
adbc.odbc.max_bind_bytes
positive integer
32768
Declared column width (bytes) above which a variable-length column is not bound at full width.
adbc.odbc.long_bind_bytes
positive integer
2048
Width at which such an over-wide column is bound anyway, with truncated values repaired.
adbc.odbc.rowset_bytes
positive integer
8388608 (8 MiB)
Ceiling on one reader's bound rowset buffers; rows per rowset are capped to fit.
adbc.odbc.decimal_as_string
true to enable
false
Read DECIMAL/NUMERIC as Arrow string instead of decimal128. Only the exact value true enables it.
adbc.odbc.sqllen_32bit
true/false (1/0)
autodetected
Force the 32-bit-SQLLEN driver quirk on or off. Unset means autodetect from the driver name.
adbc.odbc.tune
true/false (1/0)
true
Allow adbcBridge to add its own ODBC connection keywords where it recognises the driver.
adbc.odbc.utc_session
true/false (1/0)
true
On a PostgreSQL-wire connection (psqlodbc), run SET TIME ZONE 'UTC' at connect. psqlodbc hands a timestamptz over as the session's wall-clock time without its offset and sends a bound timestamp the same way, so zoned values are only correct when the session zone is UTC; on a server configured for another zone both reads and bulk ingest would be shifted by that zone's offset. false keeps the server's setting. Session-dependent SQL (now()::text, date_trunc on a timestamptz) then renders in UTC.
adbc.odbc.delegate
auto / never / always
auto (or $ADBC_ODBC_DELEGATE)
Whether to hand the connection to a native ADBC driver instead of going over ODBC.
adbc.odbc.delegate.driver
string
—
The native ADBC driver to delegate to (name, path, or manifest).
adbc.odbc.delegate.search_path
path list
—
Extra directories to find the native driver in (honoured only with allow_paths).
adbc.odbc.delegate.allow_paths
true/false (1/0)
false (or $ADBC_ODBC_DELEGATE_ALLOW_PATHS)
Opt in to loading a native driver from a caller-named path.
any adbc.* not under adbc.odbc.*
string
—
Held and passed through to the native driver when the connection is delegated; reported as unknown if ODBC ends up serving it.
Read-only database options (via AdbcDatabaseGetOption): uri, dsn,
username, and adbc.odbc.tune. Typed integer reads are available for
batch_size, max_bind_bytes, long_bind_bytes, rowset_bytes,
sqllen_32bit, and tune.
Setting any delegate option after AdbcDatabaseInit is an error.
Connection options
Set with AdbcConnectionSetOption. The three adbc.odbc.* rows take effect
only when set after AdbcConnectionInit (see the inheritance note above).
Key
Type / values
Default
Effect
adbc.connection.autocommit
true/false
true
Turns ODBC autocommit on or off (SQL_ATTR_AUTOCOMMIT).
adbc.connection.catalog
string
—
Sets the current catalog (SQL_ATTR_CURRENT_CATALOG); requires the connection to be open.
current schema (when connected), asked of the server in its own words: current_schema() on PostgreSQL-wire servers and DuckDB. ODBC has no attribute for it, so on a driver whose server has not been taught the option answers NOT_FOUND.
adbc.odbc.sqllen_32bit
true/false
adbc.odbc.driver_name
SQL_DRIVER_NAME of the backing ODBC driver (the underlying driver's file name; ADBC_INFO_DRIVER_NAME stays a stable identity for adbcBridge itself)
adbc.odbc.delegated_to
the native driver serving the connection, or odbc
A standard ADBC option that neither ODBC nor the driver understands before init
is held so a native driver can receive it if the connection turns out to be
delegated; if ODBC serves the connection, the first such option is reported as
unknown.
Statement options
Set with AdbcStatementSetOption.
Key
Type / values
Default
Effect
adbc.odbc.batch_size
positive integer
inherited
Per-statement batch size.
adbc.odbc.prefetch
integer 0–8
inherited
Per-statement prefetch.
adbc.odbc.sqllen_32bit
true/false (1/0)
inherited
Per-statement override.
adbc.odbc.array_binding
true/false
true (unless a driver quirk disables it)
Bind Arrow batches as ODBC parameter arrays (one execute per batch) instead of one execute per row.
adbc.odbc.partitions
integer 0–256
0
Partitions AdbcStatementExecutePartitions aims for. 0 automatic, 1 never split.
adbc.odbc.rows_per_insert
integer 0–2147483647
0
Row-groups per INSERT during bulk ingest. 0 automatic, 1 disables the multi-row rewrite.
adbc.odbc.ingest_connections
integer 1–64
1
Connections a bulk ingest may spread over. 1 keeps it atomic on the caller's connection. Forced to 1 on Windows.
Create a new table (the default); fail if it exists.
adbc.ingest.mode.append
Insert into an existing table; do not create.
adbc.ingest.mode.replace
Drop any existing table and recreate it.
adbc.ingest.mode.create_append
Create the table if absent, then insert.
Notable options in depth
Native delegation (adbc.odbc.delegate*)
Some databases ship a first-party ADBC driver that is faster or more faithful
than going through ODBC. When delegation is enabled, adbcBridge stands up that
native driver, translates the ODBC connection string and standard options into
what the native driver expects, and forwards every call to it — the ODBC path is
never opened.
adbc.odbc.delegate = auto (try native, fall back to ODBC), never (always
ODBC), or always (native required; fail if unavailable). The default comes
from the ADBC_ODBC_DELEGATE environment variable, else auto.
In auto mode, once the native database is up, adbcBridge opens one connection
through it and runs SELECT version() before handing anything over. A server
that speaks the PostgreSQL wire protocol without being PostgreSQL — CockroachDB,
CrateDB, YDB, openGauss, Cloud Spanner's PGAdapter — lets the native driver
initialize and then fails its first result set (the native driver reads through
binary COPY, which those servers do not implement); the probe catches that at
AdbcDatabaseInit and the connection stays on ODBC, with the reason in
adbc.odbc.delegate.last_error. The probe runs only for a target translated
from an ODBC connection string, never for a native URI the caller passed, and
always skips it. Added in 0.1.1; before that, auto committed to the native
driver on those servers and every query failed.
adbc.odbc.delegate.driver names the native driver.
adbc.odbc.delegate.search_path adds directories to look in, but only when
adbc.odbc.delegate.allow_paths is true — loading a driver from a
caller-named path is opt-in for safety. In a setuid/setgid process the
ADBC_ODBC_DELEGATE_ALLOW_PATHS and ADBC_ODBC_DELEGATE_PATH environment
variables are ignored; the database option itself still applies.
adbc.odbc.delegate.last_error (read-only) explains why delegation did not
happen; adbc.odbc.delegated_to (read-only) names the driver that served the
connection, or odbc.
Prefetch (adbc.odbc.prefetch)
By default the reader fetches one rowset, converts it to Arrow, then fetches the
next. Prefetch runs the ODBC SQLFetch for the next rowset on a background
thread while the current rowset is being converted, overlapping the two. 0
(the default) is off; 1 is classic double buffering; the ceiling is 8. Each
rowset in flight is another full set of bound buffers (rowset_bytes of them),
and one is usually enough to hide the fetch behind the conversion — more helps
only when fetch and conversion times are very unevenly matched.
Batch and buffer sizing (batch_size, rowset_bytes, max_bind_bytes, long_bind_bytes)
These four together decide how much the reader binds at once. batch_size is the
row count it would like per rowset. Because ODBC drivers describe a variable-length
column by the widest value its type could hold — sqliteodbc reports 65,536
characters for every TEXT column, MySQL 16,777,215, SQL Server 2,147,483,647
for NVARCHAR(MAX) — binding at full declared width would allocate hundreds of
megabytes per rowset. So:
rowset_bytes (default 8 MiB) caps the bound buffers; the reader fetches
min(batch_size, rowset_bytes / row_width) rows at a time, at least one.
max_bind_bytes (default 32 KiB) is the width above which a variable-length
column is not bound at its declared width.
long_bind_bytes (default 2 KiB) is the narrower width such a column is
bound at, with any value that overflows re-read (where the driver supports it —
see Driver quirks). Binding narrow and repairing the overflow
costs nothing on the values that fit; the default of 2 KiB was measured fastest
for ordinary text (2 KiB/row versus 256 KiB/row at the declared width).
adbc.odbc.sqllen_32bit
Some ODBC drivers are compiled with a 32-bit SQLLEN/SQLULEN while the driver
manager and adbcBridge use 64-bit ones — notably IBM's freely downloadable Db2
CLI driver on 64-bit Linux, and MDB Tools (Microsoft Access). Every length or
indicator such a driver writes is then only four bytes wide, which unrepaired
reads as garbage (a NULL can look like a 4 GB value). adbcBridge autodetects this
from the driver name; set this option to true/false to force it either way
(which also suppresses the autodetection).
adbc.odbc.array_binding (default true) binds an Arrow batch as one ODBC
parameter array executed once, rather than one execute per row. Some drivers
accept parameter arrays but execute them incorrectly; for those it is turned
off automatically (quirk no_param_arrays).
adbc.odbc.rows_per_insert (default 0 = automatic) controls the multi-row
INSERT rewrite ingest uses: INSERT INTO t VALUES (?,?),(?,?),… carrying this
many row-groups per execute. 1 disables the rewrite; the automatic value is
found by probing how large a statement the server will prepare. Only bulk
ingest is rewritten — a caller's own SQL is never touched.
adbc.odbc.ingest_connections (default 1) lets a bulk ingest fan out over
several connections. This trades atomicity for speed and is opt-in for that
reason: N > 1 opens N−1 extra connections, each an independent transaction,
so a failure can leave the table holding an unspecified subset of the stream.
Use it only where a partially populated table on failure is acceptable. Fan-out
is skipped (silently falling back to one connection) when the caller is inside
its own transaction, and is compiled out entirely on Windows.
adbc.odbc.tune
Where adbcBridge recognises the backing driver, it may add ODBC connection
keywords that suit how it reads a result set — for example asking psqlodbc to
Fetch several rowsets per server-side-cursor round trip. It never overrides a
keyword the caller set (in the connection string or the DSN) and never sets
anything that changes what a query returns. Set adbc.odbc.tune=false to turn
this off.
Diagnostics and logging
adbcBridge has no logging or diagnostics option. Errors surface through the
standard ADBC AdbcError (message, SQLSTATE, vendor code), populated from the
backing driver's ODBC diagnostic records. The read-only options
adbc.odbc.driver_name, adbc.odbc.delegated_to, and
adbc.odbc.delegate.last_error are the introspection points for which driver is
in play and whether delegation happened.
Environment variables
Environment variables fall into three groups: those the driver library itself
reads, those the language bindings read to locate the library, and those the
test and benchmark harnesses read. Secure-exec (setuid/setgid) processes ignore
the path-bearing ones.
Read by the driver library
Variable
Read in
Controls
ADBC_ODBC_DELEGATE
src/odbc_delegate.c
Default value of adbc.odbc.delegate (auto/never/always). Invalid values fall back to auto.
ADBC_ODBC_DELEGATE_ALLOW_PATHS
src/odbc_delegate.c
Default for adbc.odbc.delegate.allow_paths. Ignored under secure-exec.
ADBC_ODBC_DELEGATE_PATH
src/odbc_delegate.c
Extra native-driver search directories (fallback for delegate.search_path). Ignored under secure-exec.
ADBC_DRIVER_PATH
src/odbc_delegate.c
Extra directories searched for ADBC driver manifests. Ignored under secure-exec.
XDG_CONFIG_HOME, HOME
src/odbc_delegate.c
Locate the per-user manifest directory ($XDG_CONFIG_HOME/adbc/drivers, else $HOME/.config/adbc/drivers).
The driver library itself does not read ADBC_ODBC_DRIVER, ODBCINI, or
ODBCSYSINI — those are conventions of the bindings and of unixODBC.
Read by the ODBC driver manager (unixODBC / iODBC)
adbcBridge does not read these; the driver manager it loads does, and they decide
which vendor ODBC drivers and DSNs are available:
Variable
Controls
ODBCSYSINI
Directory holding the system odbcinst.ini (registered drivers) and odbc.ini (system DSNs).
ODBCINI
Path of the user odbc.ini (DSN definitions).
Read by the language bindings to locate the library
Each binding finds libadbc_driver_odbc by checking, in order, an explicit
library path, then the ADBC manifest directories, then conventional install
locations.
Variable
Bindings
Controls
ADBCBRIDGE_LIBRARY
Java, C#, Go, Rust, and (build-time) Python
Explicit path to the driver library; checked first (after ADBC_ODBC_DRIVER in Go).
ADBC_ODBC_DRIVER
Python, Java, C#, Go, Rust, and the test suites
Explicit path to the driver library.
ADBCBRIDGE_PRELOAD
Python
Toggles the driver preload in connect(); 0/false/no/off disables it.
ADBCBRIDGE_BUILD_DIR
Python (build)
Build tree to find the freshly built library in.
ADBC_DRIVER_PATH
Python, Java, C#, Go, Rust
Extra manifest search directories.
ODBCSYSINI, ODBCINI
Python
Fallbacks for locating odbcinst.ini/odbc.ini when odbcinst -j yields nothing.
Java, C# (Windows); Go reads LOCALAPPDATA, ProgramFiles and ProgramW6432, Rust USERPROFILE
Windows manifest and install directories.
VIRTUAL_ENV, CONDA_PREFIX
Java, Go, Rust
Python-environment prefixes searched for a bundled driver.
Read by the tests and benchmarks
These configure the compatibility matrix (tests/compat/test_matrix.py) and the
benchmarks; they are not read by the driver:
Variable
Controls
<NAME>_ODBC_DRIVER
Path to each database's vendor ODBC driver (e.g. SQLITE_ODBC_DRIVER, POSTGRES_ODBC_DRIVER, MYSQL_ODBC_DRIVER, …). Also gates whether that database's matrix entry runs.
<NAME>_CONN
Overrides the whole connection string for one matrix entry.
ADBC_MATRIX_SUFFIX
Table-name suffix so concurrent runs on a shared server do not collide.
ADBC_ODBC_DELEGATE
Set to never by some tests to force the ODBC path.
SQLITE_ODBC_DRIVER is also consumed by CMake's C smoke test (see
Building from source).
Connection strings
adbcBridge connects to a database by handing an ODBC connection string to the
ODBC (Open Database Connectivity) driver manager's SQLDriverConnect. The string
is whatever your data source's ODBC driver expects; adbcBridge passes it through
almost verbatim. This page explains how the string is assembled and quoted, then
gives a working template for every database in the compatibility matrix.
The connection string is set as the ADBC uri option (or its alias
adbc.odbc.connection_string). See Options for the option
mechanics.
How the string reaches the driver
adbcBridge builds the final string in OdbcOpenHdbc (src/odbc_driver.c) by
concatenating, in order:
the uri / adbc.odbc.connection_string value, with a trailing ; added if
it lacks one;
DSN=<value>; if the dsn option was set;
UID=<value>; if the username option was set;
PWD=<value>; if the password option was set;
any keywords added by connection tuning (adbc.odbc.tune, on by default),
which never overrides a keyword you already set.
The result is passed to SQLDriverConnect with SQL_DRIVER_NOPROMPT (no
interactive dialog). If the narrow call fails with no diagnostic at all, or with
only a truncation diagnostic, adbcBridge retries with the wide
(SQLDriverConnectW) entry point — this is what lets some drivers on iODBC, and
the OpenSearch driver on unixODBC, connect at all.
Driver= versus DSN=
An ODBC connection string identifies the driver in one of two ways:
Driver=… names the ODBC driver directly — either a driver name
registered in odbcinst.ini (Driver=SQLite3) or the path to the driver's
shared library (Driver=/usr/lib/.../libsqlite3odbc.so). The rest of the
keywords (Server, Database, Uid, …) are driver-specific. This is the form
every template below uses.
DSN=… names a Data Source Name — a driver plus a saved set of keywords —
defined in odbc.ini. Use the dsn option, or put DSN=name in the uri.
The two can be combined: DSN=name;Database=other; starts from the DSN and
overrides individual keywords.
Keywords and quoting
Keywords are Key=Value pairs separated by ;. Keys are case-insensitive.
Order does not matter, except that with a bare DSN=/Driver= the driver
manager reads keys left to right, so later keys override earlier ones.
A value containing ;, ], or a leading/trailing space must be wrapped in
braces: PWD={p;a}. Braces are the ODBC quoting mechanism; adbcBridge does not
add or strip them, so quote such values yourself.
adbcBridge builds UID=/PWD= from the username/password options without
quoting, so if a password needs braces, set it inside the uri yourself rather
than via the password option.
The compatibility templates
The templates below are the exact connection strings adbcBridge's compatibility
matrix (tests/compat/test_matrix.py) uses for each database, one per entry, 53
in total. In them:
{drv} is the path to that database's vendor ODBC driver .so/.dll. In
the matrix it is filled from a per-database environment variable
(<NAME>_ODBC_DRIVER); in your own use, put the driver name or path there.
{no_ssps} expands to NO_SSPS=1;only on Windows, and to nothing
elsewhere. It turns off MySQL Connector/ODBC's server-side prepared statements,
which that connector needs on Windows to reach a non-MySQL server.
{plugin_dir} and {plugin} are two spellings of the same value: they
expand to PLUGIN_DIR=<driver-dir>/plugin; when that directory exists (so
MySQL Connector/ODBC can find its mysql_native_password client plugin), and
to nothing otherwise.
A literal NO_SSPS=1; written into a template (as opposed to the {no_ssps}
placeholder) is unconditional on every platform — several MySQL-wire servers
reject server-side prepares outright and always need it.
PostgreSQL wire
These all use psqlodbc (the PostgreSQL ODBC driver, psqlodbcw.so), which
serves every server that speaks the PostgreSQL wire protocol. adbcBridge tells
the real PostgreSQL apart from the wire-compatible forks with a connect-time
server probe (see Driver quirks).
Materialize sets Protocol=7.4-0 to disable psqlodbc's per-statement
SAVEPOINT (Materialize has no savepoints).
QuestDB and ArcadeDB and YDB set BoolsAsChar=0 so booleans are
described as a boolean type rather than a char.
RisingWave and YDB set UseServerSidePrepare=0.
Google Cloud Spanner is reached through PGAdapter, Google's PostgreSQL-wire
proxy.
CrateDB: the decimal type psqlodbc reports depends on the driver build —
psqlodbc 16 (typical on Linux) describes the matrix's decimal column as
decimal128(28, 6), psqlodbc 18 (typical on macOS) as decimal128(28, 3).
Both are the driver's fallback rendering; the matrix accepts either.
MySQL wire
These use MySQL Connector/ODBC (libmyodbc9w.so). The {plugin_dir} /
{no_ssps} placeholders and the unconditional NO_SSPS=1; are as described
above; MySQL-wire analytic warehouses without prepared-statement support need
NO_SSPS=1 on every platform.
Databend, Apache Doris, GreptimeDB, StarRocks and the
MongoDB BI Connector set an unconditional NO_SSPS=1; — their servers do
not support COM_STMT_PREPARE.
MatrixOne, Apache Doris, StarRocks and SingleStore carry no Database= in the
template (the schema is created during test setup); OceanBase's
User=root@test puts the tenant in the login name.
MySQL and MariaDB are set to ANSI_QUOTES mode during test setup so
identifiers can be double-quoted.
MariaDB Connector/ODBC
These use MariaDB Connector/ODBC (libmaodbc.so) rather than MySQL's
connector. {no_ssps} applies (Windows only).
SQL Server uses Server=host,port (a comma, not a colon).
TrustServerCertificate=yes accepts the server's self-signed certificate.
IBM DRDA (Db2 and Informix)
Both are reached through IBM's Db2 CLI driver (libdb2.so), which speaks DRDA
to Db2 and to Informix's DRDA listener. adbcBridge asks the server which one it is
at connect time. The freely downloadable CLI driver is a 32-bit-SQLLEN build,
which adbcBridge autodetects (see adbc.odbc.sqllen_32bit in
Options).
SQLite uses SQLiteODBC, DuckDB its own ODBC driver, Access MDB Tools
(libmdbodbcW.so). Access uses the DBQ= keyword for the file path, not
Database=. In the matrix <path> is a temporary directory; substitute your own
file path.
Databases with their own native protocol and ODBC driver
Oracle uses DBQ=host:port/service, and the matrix additionally sets the
environment variable NLS_LANG=.AL32UTF8 for UTF-8. Vertica's template shows an
empty password (PWD=;).
Firebird carries the full database path inside DBNAME=inet://… and sets
CHARSET=UTF8.
Virtuoso puts host and port together in HOST=host:port and has no
Database= keyword. The matrix uses the ANSI driver virtodbc.so, not the
Unicode virtodbcu.so.
Ignite uses ADDRESS=host:port and SCHEMA=.
OpenSearch uses lower-case host/port keyword names and is read-only.
TDengine uses SERVER=host:port and sets TIMESTAMP_AS_IS=1; the database
is created during setup, so the template has no DB=.
SAP HANA names the SQL port with SERVERNODE=host:port and the tenant with
DATABASENAME=; a client on another machine is redirected by the server to the
tenant's own port, so from a remote host name that port in SERVERNODE= directly.
Exasol uses the EXAHOST=host:port, EXAUID= and EXAPWD= keywords;
SSLCertificate=SSL_VERIFY_NONE accepts the container's self-signed certificate.
Altibase sets NLS_USE=UTF8 so the driver hands text over as UTF-8.
Kinetica connects to the HTTP endpoint with URL=; the entry is read-only
(the driver binds parameters off by one).
Ingres names a virtual node (Server=) that the Ingres client configuration
maps to the host, plus ServerType=INGRES; the matrix reaches the driver through
an ANSI shim under unixODBC (see the compatibility test README).
Db2 for i is the one remote entry: System= is the IBM i host, SSL=1
turns on TLS, Naming=0 selects SQL naming, CommitMode=0 autocommit, and
DefaultLibraries= the library the workload creates its tables in. The matrix
fills host, user, password and library from the PUB400_* environment variables.
Two databases the project documents as having no working ODBC route — H2 and
libSQL — are not part of the matrix and have no template here.
Type mapping
adbcBridge is an ADBC (Arrow Database Connectivity) driver that speaks to a
database through an ODBC (Open Database Connectivity) driver. This page is the
reference for how types cross that boundary in both directions:
Read — an ODBC SQL_* type, as the backing driver describes each result
column, becomes an Arrow type in the schema adbcBridge returns.
Bind — an Arrow type, as it appears in a batch of bound parameters or in a
bulk-ingest stream, becomes an ODBC C type (SQL_C_*) and SQL type (SQL_*)
that adbcBridge hands to SQLBindParameter.
Two terms recur below. A driver's C type (SQL_C_CHAR, SQL_C_SBIGINT, …)
is the in-memory shape adbcBridge asks the driver to read or write; the driver's
SQL type (SQL_VARCHAR, SQL_BIGINT, …) is how the value is described to
the server. SQLWCHAR is the ODBC wide-character unit: two bytes (UTF-16) with
unixODBC and on Windows, four bytes with iODBC.
Everything here is driven from src/odbc_reader.c (ClassifyColumn, the
read side) and src/odbc_bind.c (SlotFromArrowValue and ArrayParamPlan, the
bind side). Where a driver quirk changes a mapping, the quirk is named; its full
definition is in Driver quirks.
Read: ODBC SQL type to Arrow type
adbcBridge calls SQLDescribeCol for every result column and classifies it by
its reported SQL type, its column size (precision, or declared width in
characters) and its decimal digits (scale, or fractional-second precision).
Exact and numeric types
ODBC SQL type
Condition
Arrow type
ODBC C type read
Notes
SQL_BIT
—
bool
SQL_C_BIT
SQL_TINYINT
signed
int8
SQL_C_STINYINT
signedness from SQL_DESC_UNSIGNED
SQL_TINYINT
unsigned
uint8
SQL_C_UTINYINT
SQL_SMALLINT
signed / unsigned
int16 / uint16
SQL_C_SSHORT / SQL_C_USHORT
SQL_INTEGER
signed / unsigned
int32 / uint32
SQL_C_SLONG / SQL_C_ULONG
SQL_BIGINT
signed / unsigned
int64 / uint64
SQL_C_SBIGINT / SQL_C_UBIGINT
SQL_REAL
—
float32
SQL_C_FLOAT
SQL_FLOAT, SQL_DOUBLE
—
float64
SQL_C_DOUBLE
SQL_DECIMAL, SQL_NUMERIC
1 ≤ precision ≤ 38, 0 ≤ scale ≤ precision, and adbc.odbc.decimal_as_string off
decimal128(precision, scale)
SQL_C_CHAR
value read as text and parsed exactly into a 128-bit decimal
SQL_DECIMAL, SQL_NUMERIC
otherwise (out of decimal128 range, or adbc.odbc.decimal_as_string=true)
string (utf8)
SQL_C_CHAR
the driver's textual rendering, lossless
A SQL_CHAR/SQL_VARCHAR/SQL_WCHAR/SQL_WVARCHAR column of width ≤ 8 whose
driver type name is a boolean (for example PostgreSQL bool, which psqlodbc
reports as a short character column) is detected by name and mapped to Arrow
bool, read as text.
Date and time types
ODBC SQL type
Condition
Arrow type
Read as
Notes
SQL_TYPE_DATE, SQL_DATE
—
date32 (days)
SQL_C_TYPE_DATE (DATE_STRUCT)
SQL_TYPE_TIME, SQL_TIME, SQL_SS_TIME2
scale 0
time32[s]
SQL_C_TYPE_TIME (TIME_STRUCT)
TIME_STRUCT has no sub-second field
SQL_TYPE_TIME, SQL_TIME, SQL_SS_TIME2
scale 1–6
time64[us]
text
parsed from the driver's text rendering
SQL_TYPE_TIME, SQL_TIME, SQL_SS_TIME2
scale 7–9
time64[ns]
text
SQL_TYPE_TIME_WITH_TIMEZONE
—
string (utf8)
text
Arrow has no time-with-timezone type; the driver's text form is kept
SQL_TYPE_TIMESTAMP, SQL_TIMESTAMP
naive
timestamp[unit] (no zone)
SQL_C_TYPE_TIMESTAMP (TIMESTAMP_STRUCT)
unit chosen from precision, see below
SQL_TYPE_TIMESTAMP, SQL_TIMESTAMP
driver reports a zone (IsTimestampWithTimezone)
timestamp[us, UTC]
text
parsed to UTC
SQL_TYPE_TIMESTAMP, SQL_TIMESTAMP
driver has no TIMESTAMP_STRUCT for it (quirk timestamp_as_text, TDengine)
SQL Server datetimeoffset and ODBC 4.0 timestamp-with-zone
Timestamp precision (TimestampUnitForColumn): the Arrow time unit follows
the column's fractional-second precision — scale 1–3 gives ms, 4–6 gives us,
7–9 gives ns. A reported scale of 0 is not trusted on its own: when the column
size is 21–29 (a driver that encodes precision in the width, e.g.
column_size − 20), that width is used instead; with any other size (MySQL
Connector/ODBC reports scale 0 / size 19 for DATETIME(6), SQLiteODBC scale 0 /
size 32 for every TIMESTAMP) the unit falls back to microseconds, which cannot
silently drop sub-second digits the way whole seconds would. timestamp[s] is
therefore never produced. Timestamps carrying a zone are always us.
Other types
ODBC SQL type
Arrow type
Read as
Notes
SQL_GUID
string (utf8)
text
36 characters, or 38 with the braces some drivers add
SQL_INTERVAL_* (all 13 qualifiers)
string (utf8)
text
Arrow intervals cannot express every ODBC qualifier (year-to-month, day-to-second with leading precision, …); the driver's text form is lossless
SQL_BINARY, SQL_VARBINARY, SQL_LONGVARBINARY, IBM BLOB
binary
SQL_C_BINARY
SQL_CHAR, SQL_VARCHAR, SQL_LONGVARCHAR
string (utf8)
SQL_C_CHAR (POSIX) / SQL_C_WCHAR (Windows)
see Unicode below
SQL_WCHAR, SQL_WVARCHAR, SQL_WLONGVARCHAR
string (utf8)
SQL_C_WCHAR
transcoded from UTF-16 (or 4-byte SQLWCHAR) to UTF-8
anything else / unknown
string (utf8)
SQL_C_CHAR
the fallback: ask the driver for a string representation
Character columns whose Arrow type ends up string always produce UTF-8
Arrow data regardless of the C type used to read them.
Unicode on the read path
How a character column is read depends on the platform and on driver quirks,
because the ODBC driver manager transcodes narrow (SQL_C_CHAR) and wide
(SQL_C_WCHAR) buffers differently on each:
unixODBC / iODBC (POSIX): narrow SQL_C_CHAR bytes are passed through
untouched, so a UTF-8 database is read as UTF-8 over the narrow path. SQL_CHAR
columns take that path; SQL_WCHAR columns are read SQL_C_WCHAR and
transcoded from SQLWCHAR to UTF-8. On iODBC, SQLWCHAR is four bytes, and
adbcBridge's codecs handle both one-code-point-per-unit and surrogate-pair forms.
Windows: the driver manager transcodes a SQL_C_CHAR buffer through the
process ANSI code page (so "héllo" corrupts and anything outside the code page
becomes ?). To avoid that, every character column on Windows — SQL_CHAR
included — is read as SQL_C_WCHAR and converted in adbcBridge.
Two Windows-only quirks override that default for specific drivers:
text_as_binary (Arrow Flight SQL ODBC driver): its SQL_C_WCHAR
conversion keeps only the low 16 bits of a non-BMP code point and its
SQL_C_CHAR conversion is the ANSI code page, but SQL_C_BINARY on a text
column hands the server's native UTF-8 through byte-exact. The column is read
SQL_C_BINARY and the bytes are taken as UTF-8.
wchar_as_utf8 (Apache Ignite): an ANSI-only driver whose narrow path is
already UTF-8. The column is read SQL_C_CHAR; the driver manager leaves a
SQL_C_CHAR buffer alone, so the bytes arrive as written. On POSIX this same
quirk is set for Firebird's OdbcFb, Virtuoso's ANSI driver (on 2-byte-SQLWCHAR
builds only, i.e. unixODBC, not the iODBC/macOS build, where its wide path is
the correct one), Informix, Altibase (with narrow_params, as Informix: its
SQL_C_WCHAR path stores a character above the BMP as CESU-8), Actian Ingres
(a four-byte-SQLWCHAR driver that rejects a surrogate pair outright, while its
narrow path is UTF-8), and MySQL Connector/ODBC built for iODBC — drivers
whose SQLWCHAR handling is not UTF-16.
Bind: Arrow type to ODBC parameter
When a batch is bound as parameters (AdbcStatementBind/BindStream) or fed to
bulk ingest, adbcBridge chooses a C type and SQL type per Arrow column. Two code
paths exist and are kept in agreement: a row-at-a-time path
(SlotFromArrowValue) and a column-array path (ArrayParamPlan, used when
array binding is on and the driver supports it). Both send identical wire values.
Arrow type
ODBC C type
ODBC SQL type
Notes
null
SQL_C_CHAR
SQL_VARCHAR
bound as a NULL varchar
null in a SQL_DECIMAL/SQL_NUMERIC parameter under quirk null_decimal_param_as_char (Exasol)
SQL_C_CHAR
as described by SQLDescribeParam
Exasol fails the whole statement (SI002, then HY010) for SQL_C_DEFAULT on a decimal NULL, and every Exasol integer type is a DECIMAL alias, so any numeric NULL takes this route
bool
SQL_C_BIT
SQL_BIT
see boolean quirks below
int8/int16/int32 and unsigned that fit in int32
SQL_C_SLONG
SQL_INTEGER
SQL_C_SLONG is the most widely supported integer binding; narrower types are widened into a staging buffer
int64 (out of int32 range)
SQL_C_SBIGINT
SQL_BIGINT
uint64 (out of int32 range)
SQL_C_UBIGINT
SQL_BIGINT
any wide integer, quirk bigint_param_as_string (Oracle, Virtuoso)
SQL_C_CHAR
SQL_NUMERIC
64-bit ints sent as numeric text
float16/float32/float64
SQL_C_DOUBLE (row path) / SQL_C_FLOAT+SQL_REAL for float32 (array path)
SQL_DOUBLE / SQL_REAL
half-float is widened to double
string, large_string, string_view
SQL_C_WCHAR
SQL_WVARCHAR (≤ 4000) / SQL_WLONGVARCHAR (> 4000)
sent as UTF-16 by default
string under quirk wchar_as_utf8 / narrow_params
SQL_C_CHAR
SQL_VARCHAR / SQL_LONGVARCHAR
UTF-8 bytes for drivers whose SQLWCHAR is not UTF-16
the same under quirk binary_param_as_varchar (Exasol)
SQL_C_CHAR
SQL_VARCHAR
Exasol has no binary column type and answers HY003 for SQL_C_BINARY; the bytes go as text into a VARCHAR and read back byte for byte
date32
SQL_C_TYPE_DATE
SQL_TYPE_DATE
time32[s]
SQL_C_TYPE_TIME
SQL_TYPE_TIME
time32/time64 sub-second
SQL_C_CHAR
SQL_VARCHAR
"HH:MM:SS.ffffff"; TIME_STRUCT has no fractional field, and several drivers reject SQL_C_CHAR → SQL_TYPE_TIME
timestamp (naive or zoned)
SQL_C_TYPE_TIMESTAMP
SQL_TYPE_TIMESTAMP
rendered from TIMESTAMP_STRUCT; the zone is not sent to the driver
timestamp under quirk timestamp_as_text (TDengine)
SQL_C_CHAR
SQL_VARCHAR
ISO-8601 text
decimal128, decimal256
SQL_C_CHAR
SQL_DECIMAL
rendered to exact decimal text, described with the Arrow precision and scale
decimal128/decimal256 under quirk decimal_param_as_varchar (DuckDB)
SQL_C_CHAR
SQL_VARCHAR
DuckDB mis-scales SQL_DECIMAL parameters
dictionary
(its value type)
(its value type)
decoded per row and bound as the value it encodes — a pandas categorical binds like the plain column
anything else
—
—
ADBC_STATUS_NOT_IMPLEMENTED, naming the Arrow type
Strings longer than 4000 units are described as the LONG variant of their SQL
type; the column_size sent is the value's own length. Half-float is always
widened to double.
Boolean binding quirks
Arrow bool normally binds as SQL_C_BIT / SQL_BIT, but three quirks reroute it:
bool_param_as_varchar (QuestDB): the words "true"/"false" in a
SQL_VARCHAR — QuestDB parses a boolean only from those words.
bool_param_as_int (DuckDB, Informix): an integer described as
SQL_INTEGER — those drivers reject SQL_BIT parameters.
bool_param_as_tinyint (TDengine): an integer described as SQL_TINYINT,
the one boolean-parameter route taos-odbc implements.
Temporal / binary text fallback
Under quirk temporal_binary_param_as_varchar (MySQL Connector/ODBC fronting
a non-MySQL server with server-side prepares off), date, timestamp and binary
parameters are bound as SQL_VARCHAR text so the server's own literal parsing
coerces them, instead of the driver emitting MySQL charset-introducer literals
(_binary'...') that other servers cannot parse.
NULL handling
A NULL Arrow value sets the ODBC length/indicator to SQL_NULL_DATA. The C and
SQL type are still chosen from the Arrow column type, so a typed NULL is sent.
Arrow null (untyped, e.g. Python None) is sent as a NULL SQL_VARCHAR.
Under quirk null_param_as_varchar (clickhouse-odbc, which ignores
SQL_NULL_DATA while a value buffer is bound and sends an empty string; a NULL
value pointer stores NULL for every type, so this is belt-and-braces there) every
NULL is bound as a NULL SQL_VARCHAR
whatever the column's Arrow type. This is a per-value decision, so a batch with
NULLs in such a column cannot use array binding and falls back to row-at-a-time.
On read, a column the driver reports as SQL_NO_NULLS clears the Arrow
NULLABLE flag on its field; otherwise the field is nullable.
Unknown and unsupported types
Read: any SQL type not listed above falls through to the default arm and is
read as text (SQL_C_CHAR, or SQL_C_WCHAR on Windows) and delivered as an
Arrow string. Nothing is dropped; the worst case is that a value arrives as
its textual rendering rather than a native Arrow type.
Bind: an Arrow type not listed above (for example a list, struct, map, or
union) fails the bind with ADBC_STATUS_NOT_IMPLEMENTED, and the error names
the offending Arrow type.
Driver quirks
No two ODBC (Open Database Connectivity) drivers behave identically. Some report
a capability they do not honour; some need a non-standard spelling of a type or a
statement; some encode wide characters in a way the ODBC standard does not pin
down. adbcBridge keeps these differences out of the rest of the code by detecting
the backing driver at connect time and setting a set of quirk flags that the
reader and the parameter binder consult.
This page documents the detection mechanism, every driver adbcBridge recognises
and the quirks it sets, and the meaning of each quirk flag. It is drawn from
OdbcDetectQuirks in src/odbc_driver.c and the OdbcReaderOptions struct in
src/odbc_internal.h. None of this is configuration a user normally touches;
two quirks can be forced by option (adbc.odbc.sqllen_32bit,
adbc.odbc.array_binding — see Options), and the rest are
automatic.
How detection works
OdbcDetectQuirks runs once, immediately after a connection is opened. It works
in three passes:
Self-reported capabilities.SQLGetInfo is queried for capabilities the
driver is willing to describe about itself, and quirk flags are set from the
answers:
- SQL_PARAM_ARRAY_ROW_COUNTS → param_array_row_counts
- SQL_TXN_CAPABLE → txn_capable (false when the driver answers SQL_TC_NONE)
- SQL_MAX_STATEMENT_LEN → max_statement_len
- SQL_GETDATA_EXTENSIONS → getdata_repair (needs
SQL_GD_BLOCK | SQL_GD_BOUND | SQL_GD_ANY_ORDER) and getdata_bound
- SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1 → refetch_repair (from
SQL_CA1_ABSOLUTE)
Driver identity.SQLGetInfo(SQL_DRIVER_NAME) is read, lower-cased, and
matched with substring tests (strstr). MDB Tools (Microsoft Access) does not
implement SQL_DRIVER_NAME, so detection falls back to
SQLGetInfo(SQL_DBMS_NAME) for it. Each matched driver sets the quirks in the
table below.
Connect-time server probes. One ODBC driver can front many different
servers — psqlodbc drives every PostgreSQL-wire database, MySQL Connector/ODBC
drives every MySQL-wire one — and reports the same driver name for all of them.
No quirk may be keyed on such a driver's name alone, or it would fire on the
real database too. For those drivers adbcBridge asks the server who it is with
a small query (SELECT version(), SHOW server_version,
SELECT @@version_comment, current_setting('spanner.ddl_transaction_mode'),
an information_schema.engines count, SQL_DBMS_VER), and sets quirks from the
answer. Errors from these probes are swallowed: a server that does not
understand the query simply is not the one being looked for.
Some quirks are guarded by #if defined(_WIN32) and apply only on Windows,
because the difference they compensate for is a property of the Windows driver
manager (its narrow/wide transcoding) or of a Windows-only build of the driver.
Those are marked (Windows only) below.
Driver quirk table
The match column is the lower-cased substring tested against the driver name
(or, where noted, a server probe). One database can appear under two matches — a
MySQL-wire warehouse matches myodbc and then a server probe narrows it further.
Writes a full 2048-row vector into bound buffers regardless of array size; rejects SQLSetPos, so truncated values are unrecoverable; rejects SQL_BIT params; mis-scales SQL_DECIMAL params; SQLDescribeParam aborts the process; ignores the indicator array of a column-wise param array
sqlite3odbc
SQLite
refetch_repair=true
Its result set is materialised in memory and SQLFetchScroll(SQL_FETCH_ABSOLUTE) re-reads any row correctly, so the 65,536-char width it claims for every TEXT column can be bound
Ignores SQL_NULL_DATA while a value buffer is bound and sends an empty string (a NULL value pointer stores NULL for every type, so null_param_as_varchar is belt-and-braces over the NULL pointer the row path already binds); needs Nullable(...) DDL wrapper; runs one parameter set per SQLExecute/SQLMoreResults (its own protocol, #324) and on 1.5.5 stops after set 1 because SQLMoreResults answers SQL_NO_DATA (#582); reports whole-second TIME only
Its bound arrays become one COM_STMT_BULK_EXECUTE and beat multi-row INSERT (< 3.2), but 3.2+ over Connector/C segfaults on a NULL DATE in an array; ignores SQLSetPos; reports whole-second TIME
maodbc or myodbc + engine probe
MariaDB ColumnStore
ansi_ddl_type_names
ColumnStore's DDL parser rejects LONG VARCHAR and BIT; the standard spellings TEXT/BOOLEAN are accepted
Sizes SQL_C_WCHAR in 4-byte wchar_t; executes only the first param set; has no multi-row VALUES (uses UNION ALL over the one-row system table); its BLOB SUB_TYPE TEXT reads ~100× slower than VARCHAR
Has no wide SQL type at all (SQL_WVARCHAR → HYC00); narrow path is UTF-8; reads a param array's NULL indicator from row 0 for every row, so a NULL below it stores an empty string in a character column and segfaults the client on a binary one
ANSI driver's SQL_C_WCHAR is 4-byte wchar_t on both the parameter and the fetch side; stores and reads SQL_C_SBIGINT as 0 (only text declared SQL_NUMERIC/SQL_DECIMAL converts exactly); strides datetime parameter arrays by ColumnSize instead of the C struct, so a DATE32 bound with ColumnSize 0 repeats row 0; on Win64 strides the bound-column indicator array by 4 bytes
monetdb
MonetDB
no_param_arrays
Executes only the first parameter set of a bound array, silently
verticaodbc
Vertica
prefer_param_arrays
Its client driver turns a bound array into one native bulk load, far ahead of multi-row INSERT on a column store
psqlodbc (any server)
every PostgreSQL-wire database
SET TIME ZONE 'UTC' at connect (adbc.odbc.utc_session, default on)
psqlodbc hands a timestamptz over as the session's wall-clock time with no offset in its SQL_C_CHAR form, and sends a bound timestamp as a zone-less literal the server reads in the session zone, so zoned values are only right when the session zone is UTC; on a server set to another zone every read and ingest was shifted by that zone's offset (found 2026-09-15 on a macOS cluster whose initdb took the host zone). A server that does not take the statement is left as it is
psqlodbc + version() = postgresql … and no fork marker
PostgreSQL (incl. TimescaleDB, Citus)
pg_array_ingest=true
Only real PostgreSQL is owed the multi-argument unnest array-ingest form — a policy, not a limit the servers impose: Cloudberry, probed directly, expands the form with array literals and bound ?::bigint[] parameters alike and passes the proof query, and keeps the multi-row INSERT path because a fork owes none of this by contract; forks (Yugabyte, Cockroach, Greenplum/Cloudberry, openGauss, RisingWave, CrateDB, GreptimeDB, Materialize, YDB, …) are excluded by name
Non-transactional MySQL-wire servers run with NO_SSPS=1 and emit MySQL introducer literals other servers reject; report MySQL's type names whatever the server
… + SQL_DBMS_VER contains mongosqld
MongoDB BI Connector
no_sql_columns
SQLColumns segfaults on a NULL NUMERIC_PRECISION for a DECIMAL column
… + @@version_comment contains doris
Apache Doris
ddl_table_options="DISTRIBUTED BY RANDOM BUCKETS AUTO PROPERTIES (…)"
An MPP warehouse refuses a CREATE TABLE with no distribution clause
… + version() contains greptimedb
GreptimeDB (MySQL wire)
ddl_extra_column="greptime_timestamp TIMESTAMP(3) TIME INDEX DEFAULT CURRENT_TIMESTAMP", ddl_table_options="WITH ('append_mode'='true')"
Every table needs one NOT NULL TIMESTAMP time index; without append mode rows sharing a time index merge
arrow flight
Arrow Flight SQL servers (Dremio, sqlflite, …)
no_sql_columns; (Windows only)text_as_binary
SQLColumns describes its 18 result columns and then segfaults inside the first SQLFetch — arrow::KeyValueMetadata::Get on a null pointer, from GetColumns_Transformer::Transform — for tables whose Flight SQL schema carries no per-field key-value metadata: every table tried against sqlflite, and against InfluxDB 3 twelve of the seventeen tables SQLTables returns (all seven information_schema views and five of the eight system tables); InfluxDB's own iox tables fetch cleanly, and against Dremio 26 the call fetches normally. Nothing in the return code marks any of it, so the quirk is applied driver-wide; on Windows only SQL_C_BINARY reads its text byte-exact
taos_odbc
TDengine
timestamp_as_text, bool_param_as_tinyint
No TIMESTAMP_STRUCT conversion; the only route into a BOOL column is an integer described as SQL_TINYINT
Rejects SQL_C_SBIGINT; keeps INSERT ALL for Oracle releases with no multi-row VALUES, though 23.26 takes the plain form and never reaches the fallback; takes a mid-cursor rowset change and then segfaults or loses rows, LOB column or not; cannot re-read a truncated LOB value unless the rowset holds exactly one row
msodbcsql
Microsoft SQL Server
ddl_string_type_name="NVARCHAR(MAX)"
Its SQL_LONGVARCHAR is the deprecated TEXT, which cannot be sorted, grouped, de-duplicated or compared
db2 + SQL_DBMS_NAME = IDS…
IBM Informix
wchar_as_utf8, narrow_params, bool_param_as_int
Gives up on a UTF-16 surrogate pair (-415); a SQL_C_BIT param breaks the DRDA stream
db2 + SQL_DBMS_NAME not IDS…
IBM Db2
ddl_string_as_max_varchar
Its SQL_LONGVARCHAR is LONG VARCHAR, ~700× slower to bulk-insert than VARCHAR
Stores a SQL_C_WCHAR parameter as CESU-8 and hands those bytes back verbatim, while the narrow path round-trips; has no SQL_LONGVARCHAR, and reports CREATE_PARAMS "precision" for VARCHAR, so a generated column would be VARCHAR(1); a bound array ingests at 778–816k rows/s against 30k for the multi-row form
Decodes narrow statement text as Latin-1, so statement text goes through the W entry points; its SQL_LONGVARCHAR is CLOB, which HANA bars from ORDER BY and SELECT DISTINCT; SQLGetTypeInfo(SQL_TYPE_TIMESTAMP) names whole-second SECONDDATE first; HANA has no multi-row VALUES at all, so without arrays ingest is one execute per row (3,506 rows/s against 296,082)
Every DECIMAL(p, s) is stored as DECIMAL(18, 4) but described as precision 38 scale 0; its planner answers a provably false predicate from the empty pseudo-table SYSTEM.ITER, which cannot carry a BYTES column
iiodbcdriver
Actian Ingres
wchar_as_utf8; sqllen_32bit (autodetected)
Reads a SQL_C_WCHAR parameter as UCS-2 and rejects a surrogate pair (5000B), while its narrow path is UTF-8; its SQLLEN is four bytes on 64-bit Linux
Exasol has no binary column type and the driver rejects SQL_C_BINARY outright (HY003); a NULL parameter described SQL_DECIMAL — which every Exasol numeric is — fails with SI002 unless bound as SQL_C_CHAR; SQLGetData answers SQL_NO_DATA on a clipped bound value, so a long column has to stay unbound
cwbodbc + SQL_DBMS_NAME = DB2/400…
IBM Db2 for i (IBM i Access ODBC)
ddl_string_type_name="VARCHAR(8000) CCSID 1208"
Its SQL_LONGVARCHAR is CLOB, which cannot be array-bound for writing and has no width to bind for reading (8 rows/s each way against 1,070 and 1,636 as VARCHAR); the reported VARCHAR(32739) is refused in a table with three more columns and describes too wide to bind; CCSID 1208 keeps the column UTF-8
myodbc + 4-byte SQLWCHAR
MySQL Connector/ODBC on iODBC (macOS)
wide_utf16_pairs, wchar_as_utf8
Writes UTF-16 units into 4-byte slots but mangles wide params under NO_SSPS; its narrow path is clean UTF-8
Autodetected sqllen_32bit
Unless pinned by adbc.odbc.sqllen_32bit, the 32-bit-SQLLEN quirk is turned on
when the driver name contains db2 (but not libdb2o/db2o.), mdbtools or
iiodbcdriver. IBM's freely downloadable Db2 CLI driver ships a 32-bit-SQLLENlibdb2.so even on 64-bit Linux; MDB Tools writes bound-column NULL indicators
the same truncated way; and Ingres' own driver has a four-byte SQLLEN on
64-bit Linux too, which read a NULL column back as the previous row's value.
Windows final adjustment
On Windows, wchar_as_utf8 is forced back off for every matched driver except
Apache Ignite. The quirk's premise — "the narrow path is UTF-8" — does not hold
on Windows, where a Unicode driver's narrow path is the ANSI code page; Ignite is
the one ANSI-only driver whose own narrow path is genuinely UTF-8.
Quirk flag glossary
Every field of OdbcReaderOptions that acts as a quirk, with its exact meaning.
Buffer sizing and truncation repair
Flag
Type
Meaning
min_buffer_rows
int64
Allocate at least this many rows in bound buffers, for a driver (DuckDB) that writes a whole internal chunk regardless of SQL_ATTR_ROW_ARRAY_SIZE.
getdata_repair
bool
SQLGetData can re-read a bound column of any row of a block cursor in any order (SQL_GD_BLOCK|SQL_GD_BOUND|SQL_GD_ANY_ORDER). Lets a long column be bound and only the truncated values re-read.
getdata_bound
bool
SQLGetData can re-read a bound column (SQL_GD_BOUND); enough on its own when the cursor holds one row.
refetch_repair
bool
SQLFetchScroll(SQL_FETCH_ABSOLUTE) re-reads an earlier row and a plain SQLFetch resumes after it; another way to recover a truncated bound value.
fixed_rowset
bool
SQL_ATTR_ROW_ARRAY_SIZE is fixed for the life of a cursor (Oracle's SQORA takes the change and then either segfaults on a later fetch or silently duplicates and drops rows, LOB column or not); the rowset is chosen before the first fetch and never touched again.
Parameter binding
Flag
Type
Meaning
bool_param_as_int
bool
Bind booleans as an integer described SQL_INTEGER (DuckDB, Informix reject SQL_BIT).
bool_param_as_tinyint
bool
Bind booleans as an integer described SQL_TINYINT (TDengine's one BOOL route).
bool_param_as_varchar
bool
Bind booleans as the words "true"/"false" in a VARCHAR (QuestDB).
bigint_param_as_string
bool
Send 64-bit integers as numeric text (Oracle, Virtuoso have no SQL_C_SBIGINT).
decimal_param_as_varchar
bool
Bind decimals as SQL_VARCHAR text, not SQL_DECIMAL (DuckDB mis-scales).
null_param_as_varchar
bool
Bind NULL parameters as a NULL VARCHAR whatever the type (clickhouse-odbc).
null_decimal_param_as_char
bool
Bind a NULL parameter described SQL_DECIMAL/SQL_NUMERIC as SQL_C_CHAR rather than SQL_C_DEFAULT (Exasol fails the whole statement otherwise).
binary_param_as_varchar
bool
Bind binary parameters as SQL_C_CHAR into a VARCHAR (Exasol has no binary column type and rejects SQL_C_BINARY).
temporal_binary_param_as_varchar
bool
Bind date/timestamp/binary parameters as VARCHAR text so the server parses the literal (MySQL Connector/ODBC fronting a non-MySQL server).
timestamp_as_text
bool
The driver has no TIMESTAMP_STRUCT conversion; read timestamp columns as text and bind timestamp parameters as text (TDengine).
no_param_arrays
bool
Column-wise parameter arrays are accepted but only partly executed; bind one execute per row instead.
no_timestamp_param_arrays
bool
As no_param_arrays, but only for a batch that binds a timestamp (Spanner).
prefer_param_arrays
bool
Keep ODBC parameter arrays ahead of multi-row INSERT for ingest (MariaDB Connector/ODBC before 3.2, Vertica, Altibase, SAP HANA).
param_array_row_counts
int
SQL_PARAM_ARRAY_ROW_COUNTS: SQL_PARC_BATCH (default, one row count per set) or SQL_PARC_NO_BATCH.
Unicode and statement text
Flag
Type
Meaning
wchar_as_utf8
bool
The driver's SQLWCHAR is not UTF-16; use the narrow SQL_C_CHAR (UTF-8) path for strings both ways.
wide_utf16_pairs
bool
(4-byte SQLWCHAR only) The driver puts UTF-16 code units in its wchar_t slots — a non-BMP char is a surrogate pair (MySQL Connector/ODBC on iODBC).
narrow_params
bool
Bind string parameters as SQL_C_CHAR (UTF-8) even where the wide path is default, including on Windows (Apache Ignite, Informix).
text_as_binary
bool
(Windows) Read character columns as SQL_C_BINARY and take the bytes as UTF-8 (Arrow Flight SQL driver).
narrow_sql
bool
(Windows) Send caller statement text through the narrow SQLExecDirect/SQLPrepare as UTF-8 (Apache Ignite).
wide_sql
bool
Send caller statement text through the wide SQLExecDirectW/SQLPrepareW, for a driver that decodes narrow text as Latin-1 (SAP HANA).
ind_stride_32bit
bool
(Windows) Read side only: the driver strides the bound-column indicator array by 4 bytes per row on a block cursor (Virtuoso on Win64).
sqllen_32bit
bool
The driver was compiled with a 32-bit SQLLEN/SQLULEN while the driver manager uses 64-bit (IBM Db2 CLI, MDB Tools). Every SQLLEN the driver writes is read at 4-byte width.
sqllen_32bit_forced
bool
sqllen_32bit was pinned by option; suppresses autodetection.
DDL and ingest shaping
Flag
Type
Meaning
nullable_type_format
string
DDL wrapper for a nullable column, e.g. "Nullable(%s)" (ClickHouse).
ansi_ddl_type_names
bool
The names SQLGetTypeInfo reports are not accepted in DDL; spell CREATE TABLE with portable type names (QuestDB, MySQL-wire warehouses).
ddl_extra_column
string
An extra column the server fills in itself, appended to ingest DDL (GreptimeDB time index, YDB / Spanner surrogate primary key).
ddl_table_options
string
A trailing table-options clause every ingest CREATE TABLE needs (Doris distribution, GreptimeDB append mode).
ddl_string_as_max_varchar
bool
Spell an unbounded Arrow string as the widest VARCHAR, not SQL_LONGVARCHAR (Db2 LONG VARCHAR).
ddl_string_type_name
string
A literal DDL type for an unbounded Arrow string (NVARCHAR(MAX) for SQL Server, VARCHAR(8191) for Firebird, VARCHAR(32000) for Altibase, VARCHAR(8000) CCSID 1208 for Db2 for i).
ddl_timestamp_type_name
string
A literal DDL type for an Arrow timestamp column, overriding what SQLGetTypeInfo names first (SAP HANA's TIMESTAMP, since it lists whole-second SECONDDATE first).
fractional_time_type_format
string
DDL type for a sub-second TIME column, e.g. "Time64(%d)", "TIME(%d)".
fractional_time_max_digits
int
Largest fractional-digit count that format accepts (0 = no limit).
Multi-row INSERT and array ingest
Flag
Type
Meaning
multirow_insert_all
bool
Oracle's INSERT ALL … SELECT 1 FROM dual, for a server with no multi-row VALUES. Consulted only after the standard form has actually been refused, so an Oracle that takes the plain form (23.26 does) simply uses it.
multirow_union_from
string
The server has neither; use INSERT … SELECT <typed>, … FROM <this> UNION ALL … over this one-row table (Firebird's RDB$DATABASE).
pg_array_ingest
bool
Send a whole batch as one array parameter per column via multi-argument unnest (real PostgreSQL only; verified once per connection before use).
max_statement_len
int64
SQL_MAX_STATEMENT_LEN in bytes (0 = unknown).
max_statement_params
int64
A hard parameter ceiling the halving probe cannot find (Spanner's 950; 0 = probe it).
txn_capable
bool
SQL_TXN_CAPABLE was not SQL_TC_NONE, so wrapping a multi-row execute in a transaction is worth trying.
Metadata and describe
Flag
Type
Meaning
no_describe_param
bool
Never call SQLDescribeParam (DuckDB aborts the process on it).
no_sql_columns
bool
Never call SQLColumns (it segfaults or returns nothing usable with no return code to reveal it); describe SELECT * FROM <table> WHERE 1=0 instead.
zero_row_suffix
string
Appended to that zero-row column-discovery SELECT for a planner that will not fold a false predicate (Kinetica's LIMIT 0).
decimal_fixed_precision / decimal_fixed_scale
int
The server's one real decimal type, used in place of the precision and scale the driver describes (Kinetica stores every decimal as DECIMAL(18, 4) and describes it as 38, 0).
The four batching constants that bound these behaviours — the starting parameter
ceiling, the VALUES row cap, the array-ingest row count and their byte budgets —
are compile-time limits in src/odbc_internal.h, not per-driver quirks.
Build, install and the driver manifest
The repository build, the install layout on each platform, and how driver="odbc" resolves by name. Per-platform walkthroughs: Linux, macOS, Windows.
Build
sudo apt install unixodbc-dev cmake # Debian/Ubuntu
brew install unixodbc cmake # macOS
# Windows: the ODBC driver manager ships with the OS
cmake -S . -B build && cmake --build build
# -> build/libadbc_driver_odbc.so
Install
For a no-root user install, use install.sh (see
Quick start); it wraps the CMake commands below with
PREFIX=~/.local and the manifest going to the ADBC user config directory.
PREFIX, MANIFEST_DIR, BUILD_DIR, BUILD_TYPE and JOBS override the
defaults. Otherwise, install by hand:
cmake --install build --prefix /usr/local
This installs two things:
<prefix>/lib/libadbc_driver_odbc.so (lib64 on Fedora/RHEL-style 64-bit
systems, per CMake's GNUInstallDirs; bin\libadbc_driver_odbc.dll on Windows)
<prefix>/etc/adbc/drivers/odbc.toml — an
ADBC driver manifest
pointing at the installed library
The manifest is what lets applications ask for the driver by name instead of by
path (see below). Pass -DADBCBRIDGE_INSTALL_MANIFEST=OFF to skip it, or
-DADBCBRIDGE_MANIFEST_DIR=<dir> to install it elsewhere — relative to the
install prefix (share/adbc/drivers) or absolute (/etc/adbc/drivers).
The absolute library path inside the manifest is computed while
cmake --install runs, not at configure time. A single build tree can
therefore be installed into as many prefixes as you like — /usr/local,
"$VIRTUAL_ENV", a packaging staging root via DESTDIR= — and every installed
manifest points at its own copy of the library.
Use by name (driver manifest)
With the manifest installed somewhere the ADBC driver manager searches, every
binding can load adbcBridge as simply odbc:
import adbc_driver_manager.dbapi as dbapi
conn = dbapi.connect(
driver="odbc", # resolved via <prefix>/etc/adbc/drivers/odbc.toml
db_kwargs={"uri": "Driver=SQLite3;Database=my.db;"},
)
The driver manager looks for odbc.toml in, among others:
location
how to use it
$ADBC_DRIVER_PATH
colon-separated list of directories (;-separated on Windows)
<sys.prefix>/etc/adbc/drivers
added by the Python driver manager inside a virtualenv: cmake --install build --prefix "$VIRTUAL_ENV"
~/.config/adbc/drivers
per-user install: what ./install.sh uses ($XDG_CONFIG_HOME/adbc/drivers if set); by hand, -DADBCBRIDGE_MANIFEST_DIR="$HOME/.config/adbc/drivers"
/etc/adbc/drivers
system-wide install: configure with -DADBCBRIDGE_MANIFEST_DIR=/etc/adbc/drivers, then cmake --install build --prefix /usr
On macOS the user/system directories are ~/Library/Application Support/ADBC/Drivers
and /Library/Application Support/ADBC/Drivers; on Windows the driver manager
also reads HKEY_CURRENT_USER\SOFTWARE\ADBC\Drivers and the machine-wide
equivalent.
Loading by path keeps working, and is what you want for a build tree:
driver="/path/to/libadbc_driver_odbc.so".
then no manifest was found, and the driver manager fell back to loading a plain
shared library named odbc — which on Unix is unixODBC's own driver manager,
not this driver. Check that the directory holding odbc.toml is one of the
locations above, and that the path recorded inside odbc.toml exists.
Building from source
adbcBridge is a single plain-C11 shared library, libadbc_driver_odbc.so
(.dylib on macOS, .dll on Windows), built with CMake. It links only an ODBC
(Open Database Connectivity) driver manager and the C standard library; the Arrow
support it needs is vendored (nanoarrow, under vendor/). This page covers the
prerequisites, the build and install commands, every CMake option, the test
suite, the release workflow, and how each language binding is packaged from
source.
Prerequisites
You need a C11 compiler, CMake 3.16 or newer, and an ODBC driver manager with its
development headers (sql.h, sqlext.h).
Platform
Install
Debian / Ubuntu
sudo apt install cmake unixodbc-dev
Red Hat / Fedora
sudo dnf install cmake unixODBC-devel
macOS
brew install cmake unixodbc
Windows
CMake and a C toolchain (MSVC or MinGW); the OS ships the ODBC driver manager (odbc32), no separate install needed
The driver manager can be unixODBC or iODBC on POSIX, and Windows' own on
Windows. CMake finds it via find_package(ODBC), falling back to searching for
sql.h and a library named odbc, odbc32, or iodbc. On macOS, point CMake at
Homebrew's unixODBC with -DCMAKE_PREFIX_PATH="$(brew --prefix unixodbc)".
To use the driver you also need at least one vendor ODBC driver for the database
you are connecting to; that is separate from building adbcBridge itself.
The default build type is Release if none is given. The install lays out:
Component
Destination
the shared library
<prefix>/lib (or lib64, per CMake's GNUInstallDirs on Fedora/RHEL-style 64-bit systems; <prefix>/bin on Windows)
the ADBC driver manifest odbc.toml
<prefix>/etc/adbc/drivers (configurable)
Quick user install
For a no-root install into your home directory, install.sh wraps the three
commands above and writes the manifest into the ADBC per-user config directory so
the driver is immediately loadable by the name odbc:
./install.sh
It installs the library under ~/.local/lib (lib64 on Fedora/RHEL-style
64-bit systems; install.sh prints the actual path) and the manifest under
~/.config/adbc/drivers (or ~/Library/Application Support/ADBC/Drivers on
macOS). Its behaviour is tunable through environment variables: PREFIX,
MANIFEST_DIR, BUILD_DIR, BUILD_TYPE, and JOBS.
The driver manifest
ADBCBRIDGE_INSTALL_MANIFEST (on by default) installs a TOML manifest named
odbc.toml. The ADBC driver manager discovers a driver by finding
<name>.toml in one of its search directories, so installing it as odbc.toml
lets any ADBC binding load adbcBridge simply as driver="odbc" — no
ADBC_DRIVER_PATH or LD_LIBRARY_PATH needed.
The manifest is expanded from adbc_driver_odbc.toml.in at install time (not
configure time), because the absolute library path it must contain is only known
once cmake --install --prefix has chosen the final prefix. It carries a
platform tuple key such as linux_amd64 under [Driver.shared]; CMake derives
the OS, architecture, and a libc suffix (_musl, _mingw) so the key matches
exactly what the driver manager looks up — a mismatch would make discovery
silently fall through to some other library called "odbc".
CMake options
Option
Default
Meaning
ADBC_ODBC_BUILD_SHARED
ON
Accepted but has no effect in v0.1.0: the library is always built shared.
ADBC_ODBC_BUILD_TESTS
ON
Build the C smoke test and the C unit tests.
ADBC_ODBC_ASAN
OFF
Build with AddressSanitizer and UndefinedBehaviorSanitizer (requires GCC or Clang).
ADBCBRIDGE_BUILD_SHARED
ON
Accepted but has no effect in v0.1.0, like ADBC_ODBC_BUILD_SHARED.
ADBCBRIDGE_INSTALL_MANIFEST
ON
Install the odbc.toml ADBC driver manifest.
ADBCBRIDGE_MANIFEST_DIR
etc/adbc/drivers
Where to install the manifest, relative to the install prefix (or an absolute path, as install.sh uses to place it in the user config dir).
Standard CMake variables also apply: CMAKE_BUILD_TYPE, CMAKE_INSTALL_PREFIX,
CMAKE_PREFIX_PATH (to find unixODBC), and the install-time
cmake --install --prefix.
Test suite
Tests are built when ADBC_ODBC_BUILD_TESTS is ON and run with ctest:
ctest --test-dir build --output-on-failure
# on CMake older than 3.20, which has no --test-dir:
cd build && ctest --output-on-failure
--test-dir arrived in CMake 3.20; the build itself only needs 3.16, so on an
older ctest run it from inside the build directory as shown. On Windows, where the Visual Studio generator is multi-config, add the
configuration — ctest --test-dir build -C Release --output-on-failure (or
-C Debug) — otherwise ctest runs no tests.
C unit tests
These compile the driver's own sources and exercise pieces of it in isolation,
with no database or ODBC driver required. Each is both a ctest target and a
source file under tests/c/:
Target
Source
Exercises
test_utf16
test_utf16.c
The reader's SQLWCHAR (UTF-16) to UTF-8 conversion on the fetch path, plus NaN/Inf passthrough for float columns.
The 32-bit-SQLLEN accessors that read a narrow driver's lengths and indicators.
test_objects
test_objects.c
The GetObjects catalog/schema/table metadata assembly.
test_errors
test_errors.c
Mapping ODBC diagnostic records to ADBC errors and status codes.
test_multirow
test_multirow.c
The multi-row INSERT batching used by bulk ingest.
test_partition
test_partition.c
Splitting a query into partitions for ExecutePartitions.
When the ODBC headers support SQL_WCHART_CONVERT (unixODBC's sqltypes.h
does), two extra targets — test_utf16_wchar32 and test_multirow_wchar32 —
rebuild those two tests with that macro defined, giving the four-byte SQLWCHAR
iODBC always has, so the wide-character codecs are proven for an iODBC-style
build without needing iODBC present.
C smoke test
adbc_odbc_c_smoke (tests/c/test_driver.c) is a dependency-free test that
dlopens the built driver and drives the ADBC 1.1.0 vtable directly. It is
POSIX-only (it uses dlopen/mkdtemp) and is skipped on Windows. It needs a
SQLite ODBC driver, named by the SQLITE_ODBC_DRIVER environment variable (a
path or a registered driver name); with none set it reports "skipped".
The test build also produces helper libraries never installed:
adbc_fake_native_driver (a stand-in native ADBC driver for the delegation
tests) and, on ELF/glibc, a pair of TLS libraries that reproduce a specific
loader failure for tests/test_driver_load_errors.py.
Python integration tests and the compatibility matrix
The end-to-end tests under tests/ drive the built library through the ADBC
Python driver manager. test_sqlite.py is the smoke test; test_delegate.py
covers native delegation; test_plug_and_play.py
covers the install; and the feature tests — test_partitions.py,
test_prefetch.py, test_parallel_ingest.py, test_long_columns.py,
test_pg_array_ingest.py, test_driver_load_errors.py, test_windows_text.py
— sit beside them. They read:
ADBC_ODBC_DRIVER — path to the built libadbc_driver_odbc.so (default
<repo>/build/libadbc_driver_odbc.so);
SQLITE_ODBC_DRIVER / POSTGRES_ODBC_DRIVER / … — the vendor ODBC driver for
the database under test.
To run the smoke test by hand, from the repository root:
That the install itself is plug-and-play — install into a temporary prefix,
then load the driver by the name odbc — is covered by test_plug_and_play.py.
It exercises both discovery routes (cmake --install --prefix plus
ADBC_DRIVER_PATH, and install.sh into the per-user config directory with
nothing set in the environment), each connection in a subprocess with an
explicitly built environment so a manifest already installed on the machine
cannot make it pass by accident:
Native delegation needs the native ADBC drivers installed and the
adbc_fake_native_driver helper from the test build; each case skips when its
native driver, ODBC driver (POSTGRES_ODBC_DRIVER, MARIADB_ODBC_DRIVER) or
server is missing:
ADBC_ODBC_DRIVER — the driver under test (default
<repo>/build/libadbc_driver_odbc.so);
<NAME>_ODBC_DRIVER — each database's vendor ODBC driver, which also gates
whether that entry runs;
<NAME>_CONN — an optional per-entry connection-string override;
ADBC_MATRIX_SUFFIX — a table-name suffix to isolate concurrent runs.
The servers themselves are brought up by tests/compat/docker-compose.yml, which
defines a service for every server-backed database (sqlite, duckdb and
access are file-based and need none) with the ports the matrix templates
connect to.
See tests/compat/README.md for how the fleet is
started and which vendor ODBC drivers each entry expects.
Smoke tests from the other bindings
The same smoke test exists for every language binding, each a standalone project
under tests/ that depends only on published packages. All of them read
SQLITE_ODBC_DRIVER (a path or a registered driver name; default SQLite3) and
ADBC_ODBC_DRIVER (default ../../build/libadbc_driver_odbc.so, relative to the
project), and need no DSN, odbc.ini entry or server. Build the driver first.
From Java, in a container, so no JDK, Maven or unixODBC is needed on the host
(see tests/java/README.md); the named volume
caches the Maven downloads between runs:
From Go (tests/go/), which skips unless both variables are set:
cd tests/go && ADBC_ODBC_DRIVER=/abs/path/libadbc_driver_odbc.so \
SQLITE_ODBC_DRIVER=/abs/path/libsqlite3odbc.so go test ./...
What CI runs
.github/workflows/ci.yml runs on every push
to main and every pull request. Its single build job is a matrix over Ubuntu,
macOS, Windows x64 and Windows Win32 (32-bit, where SQLLEN is 32 bits wide, as
the Access, Excel and Text drivers on a 32-bit Office machine require). Each leg
installs the ODBC driver manager where needed, configures (Debug, except Win32,
which builds Release because that is the configuration people ship), builds,
runs the C unit tests with ctest --test-dir build -C Debug --output-on-failure
(-C Release on Win32), installs,
and prints the installed odbc.toml.
The end-to-end tests need an actual ODBC driver, so the Linux leg alone installs
libsqliteodbc and Python 3.12 and additionally runs:
the SQLite end-to-end test, tests/test_sqlite.py;
the Python package's pytest suite (pip install -e python, then
pytest python/tests);
the manifest discovery check: a connection by the name odbc through
ADBC_DRIVER_PATH, against both the prefix given at configure time and a
second prefix given at install time with cmake --install --prefix. The
latter is a regression test — the manifest used to freeze the library path at
configure time, so a relocated install shipped a manifest pointing at the old
prefix.
Release workflow
Continuous integration is described under What CI runs.
.github/workflows/release.yml runs on a v* tag (or as a dry run via
workflow_dispatch) and produces, for each platform, on a GitHub Release:
Job
Artifact
linux (x86_64, aarch64, manylinux_2_28)
an auditwheel-repaired Python wheel and adbcbridge-<tag>-<rid>.tar.gz (containing the .so)
macos (arm64)
a delocated wheel and …-osx-arm64.tar.gz (containing the .dylib)
windows (x64)
a wheel and …-win-x64.tar.gz (containing the .dll)
sdist
a Python source distribution
crate
the Rust crate (cargo package)
nuget
a NuGet package bundling the four native libraries
maven
a Maven jar bundling the four native libraries
The wheels bundle the driver library but deliberately exclude the OS ODBC
driver manager (libodbc), so the user's own odbcinst.ini still governs which
vendor drivers are visible. Native libraries move between jobs through the Release
itself; a dry run builds everything against a draft release that the final job
deletes. PyPI publishing is a separate workflow (publish-pypi.yml) using trusted
publishing.
Packaging each language binding from source
Every binding wraps the same C library. They obtain it in one of two ways: some
compile it from a bundled copy of the C sources, others bundle a prebuilt native
library. Each binding locates the library at run time via ADBCBRIDGE_LIBRARY /
ADBC_ODBC_DRIVER, the ADBC manifest, or a bundled copy (see the environment
variables in Options).
Rust (rust/)
The crate's default bundled feature compiles the driver from rust/csrc/ at
build time via a build.rs that mirrors the CMake build (C11, ADBC_EXPORTING,
hidden visibility, linking the ODBC driver manager plus dl/pthread). Because a
crates.io package may only contain files under the crate directory, csrc/ is a
copy of the repository's src/, include/, and vendor/nanoarrow/; refresh it
with rust/sync-csrc.sh after touching those, and a test (tests/csrc_in_sync.rs)
fails until the copy matches. Package with:
cd rust && cargo package
Python (python/)
A thin wrapper that ships the prebuilt shared library inside the wheel. setup.py
takes the library path from ADBCBRIDGE_LIBRARY (or finds it under
ADBCBRIDGE_BUILD_DIR, default <repo>/build). Build with:
An ordinary go build loads the driver from an external path or the ADBC
manifest at run time. Building with -tags adbcbridge_embed instead embeds the
native libraries staged under go/internal/native/<goos>_<goarch>/ into the
binary and extracts the right one at run time:
go build -tags adbcbridge_embed ./...
Without the tag, or with no library staged, the build still compiles and simply
falls back to locating the driver via the manifest and install directories.
Troubleshooting
Can't open lib '<path>' : file not found, for a file that is there
unixODBC loads driver libraries through libltdl, which tries a list of candidate
names and, when none of them opens, reports file not found — the dlerror()
saying why the loader refused the file is discarded before the driver manager
sees it. So a driver that is present, readable and perfectly valid is reported
as missing, which sends you looking for the wrong problem.
adbcBridge opens the same path itself when a connection fails that way, and puts
the real reason in the ADBC error:
UNKNOWN: [ODBC] SQLDriverConnect failed
[01000] (0) [unixODBC][Driver Manager]Can't open lib '/opt/mysql-connector-odbc/lib/libmyodbc9w.so' : file not found
[adbcbridge] the file is there and readable -- the driver manager says "file not found"
for any load failure. dlopen(): /lib/x86_64-linux-gnu/libstdc++.so.6: cannot allocate
memory in static TLS block
[adbcbridge] that library was pinned to dynamic TLS before this driver loaded -- importing
pyarrow does that to libstdc++ -- and glibc cannot move it to static TLS afterwards. Load
the ODBC driver before pyarrow, or LD_PRELOAD it. Raising glibc.rtld.optional_static_tls
does not help. See docs/TROUBLESHOOTING.md
A file that genuinely is not there still says so (No such file or directory),
and one that cannot be read says Permission denied rather than file not
found. The most common real reasons are the static TLS one below, a missing
dependency of the driver (ldd <driver.so>), and a 32-bit driver in a 64-bit
process.
cannot allocate memory in static TLS block
What you see
From Python, after importing pyarrow — with adbcBridge, with pyodbc, or with a
bare ctypes.CDLL; adbcBridge is not involved in causing it:
OSError: /lib/x86_64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block
Through unixODBC it arrives as the misleading Can't open lib ... : file not
found above. It is deterministic: the driver never loads again in that process.
What is actually happening
It is not, despite the wording, about running out of the loader's static TLS
surplus. Measured on this host (glibc 2.39, default
glibc.rtld.optional_static_tls = 0x200), a process that has imported pyarrow
has exactly as much surplus left as one that has not — enough for a 1 KiB
initial-exec thread-local, not enough for 4 KiB, either way.
What importing pyarrow changes is the state of libstdc++. libarrow reaches
libstdc++'s thread-locals through the dynamic TLS model, and the first such
access to a dlopened library with no static TLS offset makes glibc record it
as dynamic-TLS-only for the rest of the process' life. A library can never be
moved into the static TLS block afterwards, at any surplus.
MySQL Connector/ODBC reaches those same thread-locals (_ZSt11__once_call,
_ZSt15__once_callable) through the initial-exec model, which requires
libstdc++ to have a static TLS offset. After pyarrow that is no longer possible,
so the driver cannot be loaded at all.
import pandas and import adbc_driver_manager.dbapi both import pyarrow, so
they do the same thing. import numpy and import adbc_driver_manager do not.
Which drivers are affected
A driver is affected if it has initial-exec TLS relocations against a symbol
that a shared library defines:
readelf -r <driver.so> | grep R_X86_64_TPOFF64
Of the 24 distinct ODBC driver libraries in this repository's compatibility
matrix, exactly one has any:
Driver library
Affected
After import pyarrow
MySQL Connector/ODBC 9.4 (libmyodbc9w.so)
yes (3 initial-exec relocations into libstdc++)
fails to load
MariaDB Connector/ODBC (libmaodbc.so)
no
loads
psqlodbc (psqlodbcw.so)
no
loads
SQLite ODBC (libsqlite3odbc.so)
no
loads
DuckDB ODBC (libduckdb_odbc.so)
no
loads
msodbcsql 18 (libmsodbcsql-18.6.so)
no
loads
Oracle Instant Client (libsqora.so.23.1)
no
loads
IBM Db2 clidriver (libdb2.so)
no
loads
clickhouse-odbc (libclickhouseodbcw.so)
no
loads
Vertica (libverticaodbc.so)
no
loads
MonetDB (libMonetODBC.so)
no
loads
Firebird (libOdbcFb.so)
no
loads
Virtuoso (virtodbc.so)
no
loads
MDB Tools (libmdbodbcW.so)
no
loads
TDengine (libtaos_odbc.so)
no
loads
OpenSearch (libsqlodbc.so)
no
loads
Arrow Flight SQL / Dremio (libarrow-odbc.so)
no
loads
Apache Ignite (libignite-odbc.so)
no
loads
SAP HANA client (libodbcHDB.so)
no
loads
Exasol (libexaodbc.so)
no
loads
Altibase (libaltibase_odbc-64bit-ul64.so)
no
loads
Kinetica (libKineticaODBC.so)
no
loads
Actian Ingres (libiiodbcdriver.1.so)
no
loads
IBM i Access (libcwbodbc.so)
no
loads
The one affected library is shared by twelve matrix entries, because every
MySQL-wire database is driven through it: MySQL, TiDB, Dolt, Doris, StarRocks,
Percona, MatrixOne, GreptimeDB, Databend, OceanBase, SingleStore and MongoDB
(BI connector).
What works
Load the ODBC driver before anything imports pyarrow. Opening the driver
first is what gives libstdc++ its static TLS offset; everything afterwards —
pyarrow included — then uses that offset. The adbcbridge Python package does
this for you: import adbcbridge deliberately does not import pyarrow, and
adbcbridge.connect() opens the ODBC driver named in the connection string
before it imports adbc_driver_manager.dbapi. Nothing is needed from you.
Set ADBCBRIDGE_PRELOAD=0 to switch that off.
If you use adbc_driver_manager directly, or another ODBC library such as
pyodbc, do it yourself as the first thing in the program — before pyarrow,
pandas, or adbc_driver_manager.dbapi:
import adbcbridge
adbcbridge.preload_odbc_driver("MySQL ODBC 9.4 Unicode Driver") # or a full path
import adbc_driver_manager.dbapi # imports pyarrow; now harmless
preload_odbc_driver accepts a path, a driver name from odbcinst.ini, or
uri=/dsn=, and is best-effort by default: pass strict=True to have it
raise when it cannot resolve or cannot load the library. Any dlopen of the
driver does as well — ctypes.CDLL("/path/to/libmyodbc9w.so") before the first
import pyarrow is enough.
Or preload libstdc++ from outside the process, which puts it in the initial
static TLS block before anything can pin it to dynamic TLS:
LD_PRELOAD pointing at the ODBC driver itself works equally well. This is the
option to reach for when you do not control the import order — a notebook
kernel, a worker started by a framework that imports pandas first.
What does not work
GLIBC_TUNABLES=glibc.rtld.optional_static_tls=N. Tried at N = 512,
1024, 4096, 65536, 1048576 and 4194304, in decimal and hex, alone and with
glibc.rtld.nns: the driver still fails to load, identically. The tunable is
not being ignored — with it set to 4194304 a 1 MiB initial-exec thread-local
dlopens where the default surplus refuses 4 KiB — it simply addresses a
different problem. Surplus was never what ran out.
Loading libstdc++ after pyarrow, e.g.
ctypes.CDLL("/lib/x86_64-linux-gnu/libstdc++.so.6"). It is already loaded;
the damage is its recorded TLS model, not its absence.
ctypes.CDLL("/lib/x86_64-linux-gnu/libstdc++.so.6")before pyarrow.
A dlopened library gets a static TLS offset only when something demands one,
and a plain dlopen does not. Preload the driver, or use LD_PRELOAD.
Loading the driver again after the first failure. There is no retry that
helps, which is why adbcBridge does not attempt one.
Nothing here can be fixed from inside adbcBridge for a process that has already
imported pyarrow. The durable fix belongs upstream, in a Connector/ODBC build
that does not use initial-exec TLS.
Reproducing it without a MySQL driver
tests/test_driver_load_errors.py builds the same condition out of two small
fixture libraries (tests/c/tls_dep.c, tests/c/tls_user.c) — one standing in
for libstdc++, one for a driver that needs it in static TLS — so the diagnostic
can be tested on any glibc host without depending on which drivers or Python
packages happen to be installed.
Windows: non-ASCII text arrives mangled, or pyarrow raises UnicodeDecodeError
Symptoms seen on Windows 11 with builds before 5b932c5 (2026-08-24):
a column named prix_€ came back as the byte 0x80, and fetch_arrow_table() raised
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80;
with psqlodbc against a UTF8 database, SELECT 'héllo'::varchar raised
UnicodeDecodeError: byte 0xe9 and SELECT '日本語'::text returned ???.
Cause: the Windows driver manager transcodes every narrow (SQLCHAR*, SQL_C_CHAR)
string between the driver and the process's ANSI code page (1252 on a Western install).
unixODBC and iODBC pass narrow bytes through untouched, so on Linux and macOS the narrow
entry points and the SQL_C_CHAR fetch path carry UTF-8 end to end, and adbcBridge was
written on that assumption. Bound parameters and SQL_WCHAR columns were always correct
(they are UTF-16), which is what hid both halves for as long as it did.
Fix, in the driver: on _WIN32 statement text, catalog names, column and type names and
diagnostics go through the W entry points (src/odbc_text.c, 38f426d), and every
character column — whatever the driver calls it — is read as SQL_C_WCHAR and converted,
with the wchar_as_utf8 quirk (whose premise is the narrow-path-is-UTF-8 assumption)
forced off there (76223c9). Verified byte-exact against PostgreSQL 16 on Windows 11 for
héllo and 日本語 through both varchar and text.
How to check a build: python tests\test_windows_text.py (needs the SQLite ODBC driver)
must print all passed; the compat workload's statement-literal step
(WHERE s LIKE 'héllo%') fails on any entry whose text path is wrong.
A driver that reports column_size in bytes rather than characters would over-allocate
the wide buffer harmlessly; one that under-reports would truncate — none of SQLite,
DuckDB, msodbcsql or psqlodbc does either. If a text column on Windows comes back
truncated, that is where to look.
macOS: a vendor driver built for iODBC (empty diagnostics, or "Lost connection during query")
What you see
Through a bridge built against unixODBC, a driver such as MySQL Connector/ODBC for
macOS fails every call with an empty diagnostic — [H000] [ (0) (SQLDriverConnect) —
and pyodbc fails the same way. Through a bridge built against iODBC (before 6843467)
it connects, then the first statement with a non-ASCII character fails server-side:
[08S01] (2013) Lost connection to MySQL server during query, the server logging
something like Utf8Error { valid_up_to: 63 }.
What is actually happening
macOS has two ODBC driver managers with different SQLWCHAR widths: unixODBC's is
two bytes (UTF-16), iODBC's is wchar_t, four bytes, one code point per unit. A driver
compiled against one cannot be loaded through the other, whatever install_name_tool
says: the wide entry points exchange text in the wrong unit size, and the diagnostic
strings come back unreadable — hence the empty error. MySQL Connector/ODBC 26.7.1 for
macOS arm64 links @rpath/libiodbcinst.dylib; it is an iODBC driver. (So are some
others — OpenSearch's macOS pkg links /usr/lib/libiodbc.) Relinking such a driver to
unixODBC is not a valid recipe.
The second symptom was the bridge's own bug: its UTF-8 ↔ SQLWCHAR codecs assumed
UTF-16 and wrote surrogate pairs into four-byte slots. Since 6843467 every codec
honours sizeof(SQLWCHAR) — the tests are built a second time with a four-byte
SQLWCHAR (test_utf16_wchar32, test_multirow_wchar32) to keep it that way.
What works
Build the bridge against iODBC and use the vendor driver through it:
# iODBC 3.52.16 from the openlink/iODBC tag: ./configure --disable-gui && make install
cmake -S . -B build-iodbc -DCMAKE_BUILD_TYPE=Release \
-DODBC_INCLUDE_DIR=<iodbc>/include -DODBC_LIBRARY=<iodbc>/lib/libiodbc.dylib
cmake --build build-iodbc && ctest --test-dir build-iodbc
# The connector as downloaded needs its quarantine flag cleared, rpaths for
# libiodbcinst.dylib and its bundled libssl, and a fresh ad-hoc signature:
xattr -c <connector>/lib/*.so <connector>/lib/*.dylib <connector>/lib/plugin/*.so
install_name_tool -add_rpath <iodbc>/lib -add_rpath <connector>/lib <connector>/lib/libmyodbc26w.so
codesign -f -s - <connector>/lib/libmyodbc26w.so
The connector itself: Oracle's download page is JavaScript-only and its /get/ URL refuses
curl; the "No thanks, just start my download" link fetches
mysql-connector-odbc-26.7.1-macos15-arm64.tar.gz. Its bound wide parameters are not read the
way its columns are written, so on a four-byte build the bridge binds text for this connector
through the narrow UTF-8 path (5bb3e3c) — at no measurable cost: 2.0M rows/s fetch on
Databend either way.
Then Driver=<connector>/lib/libmyodbc26w.so;... in the connection string, with
ADBC_ODBC_DRIVER pointing at build-iodbc/libadbc_driver_odbc.dylib. One bridge build
per driver manager: a unixODBC-built bridge for unixODBC drivers (psqlodbc, sqliteodbc,
the MariaDB connector, ...), an iODBC-built one for iODBC drivers.
macOS: the process dies with SIGABRT on the first SQL error, no message
What you see
Connect works, SELECT works, and the first statement that fails on the server — a DROP of a
missing table, a typo — kills the process: SIGABRT, nothing on stderr, no crash report. unixODBC's
own isql dies the same way. Seen with Virtuoso (Homebrew 7.2.17) and the Arrow Flight SQL ODBC
driver 0.9.7 for Apple Silicon (which also fronts InfluxDB 3 and Dremio).
What is actually happening
Those drivers are built to iODBC's SQLWCHAR (wchar_t, four bytes). unixODBC's driver manager
(SQLWCHAR two bytes) reads their wide diagnostic on the first SQL_ERROR into a fixed 12-byte
stack array — SQLWCHAR sqlstate[6] in DriverManager/__info.c, extract_diag_error_w — the
driver writes 24, and the stack protector aborts. It is the driver manager, not the driver and
not the bridge; the same call through iODBC returns the proper 42S02. Reported upstream with a
driver-independent reproduction (a fake driver compiled with SQL_WCHART_CONVERT does it on Linux
too, on 2.3.12 and 2.3.14): lurcher/unixODBC#239; the drivers' undocumented width:
openlink/virtuoso-opensource#1469, dremio/warpdrive#16.
What works
Build the bridge against iODBC and use those drivers through it (the recipe two sections up):
Flight SQL, InfluxDB 3 and Dremio then pass the compat workload. Or keep unixODBC and never let a
statement fail — which is not a workaround anyone should ship.
PostgreSQL: HYC00 Unrecognized C_parameter type in copy_statement_with_parameters
What you see
A multi-row executemany (or adbc_ingest) through psqlodbc fails with
NOT_IMPLEMENTED: [ODBC] SQLExecute failed — [HYC00] (10) Unrecognized C_parameter type in
copy_statement_with_parameters, while single statements and plain reads work. pyodbc through
the same DSN fails the same way on an executemany (HYC00) and with 07006 Received an
unsupported type from Postgres (14) on a fetch.
What is actually happening
make install of psqlodbc installs two libraries: psqlodbca.so, the ANSI build, and
psqlodbcw.so, the Unicode build. The ANSI build exports no SQL*W entry points and has no
SQL_C_WCHAR case in its parameter-copy path, so any bound wide parameter — which is how
adbcBridge sends text — is refused. Distro packages on Linux ship both files too, and a DSN or
Driver= can point at either.
What works
Point Driver= (or the DSN's Driver line) at psqlodbcw.so. Every psqlodbc row in
COMPATIBILITY.md was measured through the Unicode build.
Compatibility tracker
adbcBridge speaks ODBC, so it can reach anything that has an ODBC driver — the ODBC
ecosystem covers a few hundred data sources. That is a statement about reachability.
This file tracks what has actually been verified: the same workload (types, NULLs,
Unicode incl. emoji, parameters, bulk ingest, batched reads, GetObjects, error mapping)
run through tests/compat/test_matrix.py and passing
against a real server or file, plus the numbers in
bench/MATRIX_BENCHMARKS.md.
All but a few of the verified drivers (Cloudberry, CockroachDB, Percona, TiDB, openGauss,
OceanBase and SingleStore among the exceptions) needed at least one workaround — each row's Notes say which — so an
unverified driver should be expected to work on the generic path and to have a quirk or
two waiting. Bug reports with the driver name and SQLSTATE are what move rows from the
second table to the first.
The Verified table below is the Linux column, 53 entries; the macOS (45 of 53) and Windows (48 of 53)
columns, cell by cell, are under Per operating system. Servers
for the matrix: docker compose -f tests/compat/docker-compose.yml up -d. Per-database
driver setup (root-free) and run commands:
tests/compat/README.md.
A note on the PostgreSQL-wire rows
Fourteen entries below reach their server through psqlodbc, and most of them are not
PostgreSQL. One behaviour is keyed on being PostgreSQL rather than on speaking its wire:
bulk ingest sends a batch as one array parameter per column
(INSERT … SELECT * FROM unnest(?::bigint[], ?::text[], …)) instead of K row-groups of
bound cells. It is enabled only when version() is a PostgreSQL banner carrying no fork's
marker, and the server is then asked to prove it expands the form the way PostgreSQL does
before it is used. Verified against live servers: on for PostgreSQL, TimescaleDB and
Citus (the last two being extensions on stock PostgreSQL) and off for CockroachDB,
YugabyteDB, CrateDB, RisingWave, Materialize, openGauss and Apache Cloudberry — off meaning
the fork test excluded them, not that the form was found wanting: Cloudberry, probed on its
own, expands the multi-argument unnest from array literals and bound ?::bigint[]
parameters alike and passes the proof query, and keeps the multi-row INSERT path by
construction. Off by
construction, not tried live, for QuestDB and ArcadeDB (both name themselves in
version(), which is also how their existing quirks are keyed) and for YDB and Cloud
Spanner via PGAdapter (whose version() is indistinguishable from a real PostgreSQL's,
so the code that identifies them for their other quirks switches this one off
explicitly). Everything excluded keeps the multi-row INSERT path, which is what it had
before.
Note that being excluded is not evidence the form would have failed: CockroachDB and
YugabyteDB both answer the semantic check correctly. The rule is the brief's — default to
off for anything not verified — not a claim that each excluded server is broken.
A note on partitioned reads
ExecutePartitions splits one query
across N connections, which is where adbcBridge's read-path win against the native
PostgreSQL driver comes from. Which split a server gets is decided per table, from the
catalog, and never guessed — anything unprovable falls back to a single partition, which
is exactly what not calling ExecutePartitions would have done. Three strategies, and a
fallback:
ctid — the PostgreSQL heap split. Best where it exists: no index needed, balanced
in bytes, read sequentially. Tried first, and taken by any relation with a heap.
key range — MIN/MAX of the leading primary-key column, cut into half-open
intervals. Needs a single-table primary key whose leading column is an integer,
NOT NULL and ordered. Gate built from SQLPrimaryKeys + SQLColumns, so it is not
tied to any one server's system tables.
yb_hash_code — YugabyteDB's tablet hash, for its default hash-partitioned primary
key, where a key range would be a Seq Scan with a Storage Filter and 2.2x slower
(measured).
one partition — no heap and no usable key, so the read stays on one connection.
Only PostgreSQL, CockroachDB and YugabyteDB have been measured live, and the rows below
say so where they have. Everything not mentioned falls where its table shape puts it: a
heap gets ctid, a heapless table with a suitable primary key gets a key range, and a
table with neither gets one partition. The numbers are in
bench/BENCHMARKS.md.
driver quirks handled: 2048-row vectors (the driver writes a full vector into bound buffers whatever the rowset size), no SQL_BIT params (booleans go as integers), no usable parameter arrays on 1.5.5 (fixed on main by duckdb-odbc#524, in the first release after v1.5.5.0)
PostgreSQL 16
psqlodbc 16
PASS
native delegation to adbc_driver_postgresql when installed; bulk ingest sends one array parameter per column (INSERT … SELECT * FROM unnest(?::t[], …)) rather than K row-groups of cells — a server quirk keyed on version(), so no other PG-wire server here gets it; partitioned reads split on ctid (1.21x native at 1 M rows, 1.55x at 10 M), and a declaratively partitioned parent — which has no heap of its own and used to get one partition — now takes the key-range split, 1.36x native
MariaDB 11
MariaDB Connector/ODBC 3.1
PASS
the driver's parameter arrays go as one COM_STMT_BULK_EXECUTE and beat the multi-row INSERT, so it opts into prefer_param_arrays (3.1.15 on Linux; from Connector/ODBC 3.2 arrays are off — see the macOS cell)
MariaDB ColumnStore 23.02
MariaDB Connector/ODBC 3.1 (MariaDB wire)
PASS
columnar engine inside MariaDB 11.1: standard-SQL ingest DDL (ColumnStore rejects maodbc's own LONG VARCHAR/BIT type names), no VARBINARY column type; needs columnstore_cache_inserts=ON (bound-parameter inserts are ~2 rows/s without it) and provision to start the backend processes; ingest 14.9k rows/s (54.6k with array binding), fetch 1.41M rows/s
MySQL 8.4
MySQL Connector/ODBC 9.4 (and MariaDB Connector/ODBC 3.1)
PASS
the driver executes parameter arrays row by row
Dolt 2.3.1 (MySQL 8.0.33 wire)
MySQL Connector/ODBC 9.4
PASS
no driver quirks; Dolt offers only mysql_native_password, which Connector/ODBC 9.x loads as a plugin, so the entry points PLUGIN_DIR at the tarball's own lib/plugin
SQL Server 2022
msodbcsql 18
PASS
incl. NVARCHAR(MAX) via chunked SQLGetData — the driver describes it as SQL_WVARCHAR with column size 0 and reports SQL_GETDATA_EXTENSIONS = SQL_GD_BLOCK only, so a value truncated in a bound column cannot be re-read (07009 on every route) and the wide column is left unbound; ingest DDL spells an Arrow string NVARCHAR(MAX), not the deprecated TEXT the driver's SQLGetTypeInfo(SQL_LONGVARCHAR) names (SQL Server will not sort, group or even compare it, IS NULL and LIKE excepted). A rowset above 1 makes every SELECT return 01S02 Cursor type changed, benign
Oracle 23ai Free
Instant Client ODBC 23
PASS
set NLS_LANG=.AL32UTF8 for non-ASCII; no SQL_C_SBIGINT, so 64-bit ints are sent as numeric text. SQORA's rowset is fixed for the life of a cursor: changing SQL_ATTR_ROW_ARRAY_SIZE on an open cursor is accepted (SQLSetStmtAttr succeeds, SQLGetStmtAttr reads it back) and then goes wrong in one of three unannounced ways. Raising it segfaults inside libsqora on a later SQLFetch (bcoReturnColData through bcoCacheFetch, a per-rowset slot sized at execute time) -- on a (NUMBER, CLOB) cursor and equally on cursors with no LOB at all; whether a given raise dies depends on how deep the cursor already is, so the same raise survives after one rowset and crashes after twelve. Where it does not crash it can rewind, re-delivering rows already returned and dropping others while still ending on the right total. Lowering it never crashes and is no better: the driver keeps stepping by the original size and returns only the first N rows of each block, so 100,000 rows read at 1,024 and dropped to 128 come back as 13,440-14,336 depending on where the change falls, SQL_NO_DATA and no diagnostic. Reproduced with plain SQLBindCol/SQLFetch, nothing of ours on the stack. The reader therefore settles the rowset before the first fetch and never moves it (fixed_rowset), and a value that outgrew its bound buffer cannot be repaired once the rowset holds more than one row -- SQLGetData answers HY109 there, SQLSetPos HY109 and SQLFetchScroll HY106 at any size, although SQL_GETDATA_EXTENSIONS advertises SQL_GD_BLOCK|SQL_GD_BOUND; at a one-row rowset SQLGetData does re-read the whole value -- so a column with no real declared width -- CLOB, NCLOB, BLOB, LONG, all reported as 2,147,483,647 wide -- stays unbound and is read a row at a time with SQLGetData. Result sets without a LOB column keep the full block cursor, at the one size chosen before their first row. Also seen: Oracle 23.26 takes the standard multi-row VALUES (…),(…) form, prepared or direct, so the INSERT ALL fallback is never reached there; SQL_C_SBIGINT is refused on the read side too (07006), and SQLGetTypeInfo(SQL_BIGINT) returns nothing; an unconstrained NUMBER column is described SQL_FLOAT and read through a double (9223372036854775807 comes back 9223372036854780000, exact in NUMBER(19)); the empty string is NULL on both the literal and the bound path. This matters because ingest DDL spells an Arrow string as CLOB here, so ingest-then-read-back used to crash on the default path; the compat entry now writes 3,000 mixed-width strings (one in 250 is 9 KB) and reads them all back whole
IBM Db2 12.1
Db2 clidriver (libdb2.so)
PASS
32-bit SQLLEN (see adbc.odbc.sqllen_32bit); ingest DDL spells an Arrow string as the widest VARCHAR, not the LONG VARCHAR the driver's SQLGetTypeInfo(SQL_LONGVARCHAR) names (deprecated; will not sort, group or de-duplicate -- SQL0134N on ORDER BY, GROUP BY, DISTINCT and UNION -- and has no bulk-insert path: ~7k rows/s whatever the batch size against ~430k for VARCHAR(32672) in a 20,000-row test, 30-56x on a warm database and over 200x through adbc_ingest on a cold one; an earlier run recorded ~700x)
Db2 for i 7.5 (IBM i, PUB400.COM public server)
IBM i Access ODBC 1.1.0.29 (libcwbodbc.so 07.01.029, IBM i host servers)
PASS
a different engine and a different wire from the Db2 row above, not DRDA: IBM's own IBM i Access driver on the host-server ports, SQL_DBMS_NAME "DB2/400 SQL" 07.05.0015. Hosted — IBM i runs on Power hardware, there is no container and no emulator, and the entry points at PUB400.COM, the free public IBM i (PUB400_HOST/PUB400_USER/PUB400_PASSWORD; big_rows 3,000, one connection at a time). Driver quirks: the Driver= value may be at most 35 characters (Key value in connection string too long. (30119) at 36 — the driver's own installed path is 36, so a DSN-less connection needs the registered name or a short symlink); the libraries resolve their message catalogues and conversion tables under a compiled-in /opt/ibm/iaccess, and without that directory every diagnostic degrades to CWBNL0202 - cwbodmsg.dll and the real error is lost (root-free route: a bwrap mount namespace). Generated ingest DDL spells an Arrow string VARCHAR(8000) CCSID 1208, not the CLOB the driver's SQLGetTypeInfo(SQL_LONGVARCHAR) names (ddl_string_type_name, keyed on SQL_DBMS_NAME "DB2/400"): a CLOB column can be neither array-bound for writing nor bound at all for reading, so every row costs a round trip — 3,000 rows went in at 8 rows/s and came back at 8 rows/s against 926-1,070 and 1,636-8,052 as VARCHAR(8000). The width is measured, not chosen: the widest VARCHAR the server reports (32,739) is refused in a four-column table (SQL0101, Db2 for i's row is at most 32,766 bytes) and describes too wide to bind, which puts the read back on SQLGetData — 120 rows/s at VARCHAR(16000), 62 at VARCHAR(32700). Server side: CommitMode=0 (*NONE) is required, because a table created with plain CREATE TABLE in a user library is not journaled and cannot be changed under commitment control (SQL7008 … not valid for operation); Naming=0 with DefaultLibraries=<user>1; SSL=1 (host-server ports 9470-9479) verified as well as plain. The entry's s column is VARCHAR(50) CCSID 1208 because a plain VARCHAR takes the job CCSID — 273, single-byte EBCDIC, on this host — and loses héllo 🚀 to substitution characters; CCSID 1208 is UTF-8 and round-trips it. Unquoted identifiers fold to upper case, SQL_MAX_IDENTIFIER_LEN is 18, and INTEGER, DOUBLE, VARBINARY(n), DATE, TIMESTAMP(6), DECIMAL(10,3) and BOOLEAN are all native 7.5 types needing no tolerance flag. First connect needs a password change PUB400 only offers on a 5250 screen: ssh -p 2222 refuses an expired profile outright and the client's own cwbCO_ChangePassword() is refused by this host (CWBSY1008 … rc=400); ingest 926 rows/s, fetch 8.1k rows/s, ~110 ms network round trip
ClickHouse 26
clickhouse-odbc 1.5
PASS
a NULL parameter must be bound with a NULL value pointer on 1.5.5 — with a value buffer bound the driver ignores SQL_NULL_DATA and sends the parameter empty, which the server rejects for Int32/Float64/Date/Decimal/Bool and silently stores as '' in String and as the epoch in DateTime64; fixed on master by our clickhouse-odbc#586 (merged 2026-09-24), still present in 1.5.5.20260810 as re-checked on 2026-09-24, so the flag stays until a release carries it (SQL_DESCRIBE_PARAMETER is N; SQLDescribeParam answers SQL_UNKNOWN_TYPE); no affected-row counts on 1.5.5 (SQLRowCount answers 0 for every write; fixed on master by clickhouse-odbc#585, merged 2026-09-15, which answers −1 — the ODBC "unknown" — from the next release); Nullable() DDL wrapper on ingest; parameter arrays are the driver's own protocol — SQLExecute sends set 0 and each SQLMoreResults the next set (clickhouse-odbc#324) — and on 1.5.5 the first SQLMoreResults sends set 1 but answers SQL_NO_DATA, after which nothing advances: a 5-set array lands 2 rows under SQL_SUCCESS/SQL_NO_DATA (clickhouse-odbc#582), and a spec-conforming single SQLExecute lands 1 — fixed on master by our clickhouse-odbc#588 (merged 2026-09-29): SQLExecute sends every set and reports the failing one through SQL_ATTR_PARAMS_PROCESSED_PTR/SQL_ATTR_PARAM_STATUS_PTR; the multi-row INSERT stays until a release carries it; one HTTP request per execute — ~16 rows/s one row at a time, ~1k rows/s through the K-row INSERT ingest uses, with K bounded by the server's 1,000-field HTTP form limit
CockroachDB 26
psqlodbc 16 (PG wire)
PASS
no quirks; declare a PRIMARY KEY or the synthesised hidden rowid shows up in GetObjects; version() is CockroachDB's own, so the PostgreSQL array-ingest form stays off and ingest uses the multi-row INSERT (verified); no ctid (42703), so partitioned reads use the key-range split — measured 5.1x at N=8 and 6.5x at N=16 over a single connection on 1 M rows. adbc_driver_postgresql cannot read this server at all (it needs binary COPY TO, which CockroachDB does not implement), so there is no native ADBC alternative to compare against
Percona Server 8.4
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
drop-in MySQL fork: the mysql entry applies unchanged, no quirks; ingest 21.1k rows/s, fetch 1.18M rows/s
YugabyteDB 2026.1 (YSQL)
psqlodbc 16 (PG wire)
PASS
no quirks; YSQL is PostgreSQL 15, and its internal row id is a system column so GetObjects is unaffected; version() carries -YB-, so the PostgreSQL array-ingest form stays off (verified); no ctid (0A000), and its default PRIMARY KEY (id) is hash-partitioned, so partitioned reads split on yb_hash_code() — 2.6x over a single connection, and 2.2x better than a key range, which YugabyteDB would run as a Seq Scan with a Storage Filter. Against the native driver it reaches 1.18–1.22x at 4 M rows, i.e. on the 1.2x bar rather than over it, and 0.94x at 1 M: the server is the bottleneck there, not psqlodbc
TimescaleDB 2.29 (PostgreSQL 16)
psqlodbc 16 (PG wire)
PASS
no quirks; an extension on stock PostgreSQL, so the array-ingest form applies and is used (verified); also ingests into and reads back a create_hypertable() hypertable; a plain table with rows in it has a heap and takes the ctid split, while a hypertable is an inheritance parent (relkind r, chunks through pg_inherits, not a declarative p parent) whose own heap is empty — pg_relation_size 0, so the block-count probe declines the heap split — and takes the key-range split only when its primary key leads with an integer NOT NULL column (not timed; TimescaleDB requires the partitioning column in any unique index, so a (d, a) key leads with the DATE and gets no split). SELECT ctid itself succeeds on an uncompressed hypertable and hands back chunk-local ctids that repeat across chunks, so it is the zero block count and not an error that rules the heap split out
Citus 14.1 (PostgreSQL 18)
psqlodbc 16 (PG wire)
PASS
no quirks; an extension on stock PostgreSQL, so the array-ingest form applies and is used, distributed tables included (verified: the entry also ingests into and reads back a create_distributed_table() hash-distributed table); a fresh single node needs no registration -- create_distributed_table() registers the coordinator itself with shouldhaveshards on; if the coordinator was registered with citus_set_coordinator_host() alone, shouldhaveshards has to be set too or create_distributed_table() fails with replication_factor (1) exceeds number of worker nodes (0); ingest 107k rows/s (array binding), fetch 1.86M rows/s
CrateDB 6.4
psqlodbc 16 (PG wire)
PASS
driver quirk handled: with autocommit off psqlodbc sends BEGIN;<statement> as one query string, and CrateDB tags every result of a multi-statement query with the leading text of the whole string (BEGIN;INSERT 1, where PostgreSQL sends BEGIN then INSERT 0 1), so SQLRowCount succeeds without writing its out-parameter for any write or DDL inside a transaction — the same driver and query string against stock PostgreSQL write the count (fixed generically in OdbcRowCount(), which now pre-fills unknown rather than none; reported upstream and fixed in CrateDB 6.4.4 by crate/crate#20088, the bridge handling stays for earlier versions); version() is CrateDB's own, so the PostgreSQL array-ingest form stays off (verified); server side: eventually consistent, so reads follow REFRESH TABLE; no binary or DATE column type; ingest 50.0k rows/s (45.6k with array binding; one multi-row INSERT per batch — row-at-a-time is ~0.9k rows/s because CrateDB fsyncs its translog once per statement, and psqlodbc sends a parameter array as separate single-row statements), fetch 726k rows/s
QuestDB 10
psqlodbc 16 (PG wire)
PASS
own type system behind the PG wire: standard-SQL ingest DDL, true/false boolean params, parameter arrays off (psqlodbc inlines their values as string literals, which QuestDB will not convert to BINARY or BOOLEAN); psqlodbc's SQLColumns fails here, so GetObjects falls back to SQLDescribeCol of a zero-row SELECT instead; a BINARY value holding a 0x00 byte comes back truncated at the first NUL through psqlodbc (the server keeps the whole value); on a manual-commit connection free a prepared statement only after the commit — psqlodbc's free sends SAVEPOINT …; DEALLOCATE …; RELEASE whatever Protocol says, QuestDB has no SAVEPOINT, the transaction aborts unreported and COMMIT still returns SQL_SUCCESS (the psqlodbc path already recorded for Materialize)
RisingWave 3.0
psqlodbc 16 (PG wire)
PASS
no adbc.odbc.* quirks, but the entry sets psqlodbc's UseServerSidePrepare=0: psqlodbc names every server-side statement _PLAN0x<statement-handle address> and RisingWave keeps the name after the handle is freed, so the next SQLPrepare under a reused name fails XX000 ... Duplicated statement name -- which the ordinary allocate/prepare/execute/free loop hits on its second query, with or without parameters (concurrently open handles get distinct names and all succeed, re-executing a prepared handle is fine, SQLExecDirect never collides); version() names RisingWave, so the PostgreSQL array-ingest form stays off (verified); server side: RisingWave's parser takes no length, precision or scale on a column type -- VARCHAR(50)/TIMESTAMP(6) are parser errors and NUMERIC(10,3) an unsupported type, so VARCHAR and NUMERIC are declared unqualified; FLOAT(p) is the one modifier it does take, and honours (1-24 gives real, 25-53 double precision) -- and a write reaches a scan only at the next barrier (the default 1 s interval here; a COMMIT does not bring it forward), so reads follow FLUSH, which forces a barrier straight away (tens of ms), or SET RW_IMPLICIT_FLUSH = true, which does that on every write; ingest 31.9k rows/s (35.3k with array binding), fetch 1.00M rows/s
MonetDB 11.55 (Dec2025-SP3)
MonetDBODBClib 11.55
PASS
driver quirk handled: no usable parameter arrays (executes only the first set); SQLEndTran unreliable under pyodbc
TiDB 7.5
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
no quirks; run from the tarball the driver needs PLUGIN_DIR= for the mysql_native_password client plugin TiDB's root account uses
Firebird 5
Firebird ODBC 3.5.0-rc1
PASS
SQL_C_WCHAR sized in 4-byte wchar_t; parameter arrays are off (no_param_arrays): with column-wise binding OdbcFb steps fixed-length C types at the BufferLength stride, so every row receives row 1's values with SQL_SUCCESS throughout (firebird-odbc-driver#299, reproduced in plain ODBC on 3.0.1 and 3.5.0-rc1; fixed upstream in PR #308, verified here on its CI build 2026-09-07, so no_param_arrays comes off once a release carries it). Firebird's dialect has neither multi-row VALUES (-104 Token unknown at the second row-group's comma) nor Oracle's INSERT ALL, so bulk ingest batches through the third form it does take: INSERT INTO t (cols) SELECT CAST(? AS <type>), ... FROM RDB$DATABASE UNION ALL SELECT ... (multirow_union_from, probed like every other form and only after the standard one is refused). The CAST is not a guess and cannot lose anything -- it names the type ingest itself would create for that Arrow type, so a value too wide for the target column raises string right truncation on the INSERT's own assignment exactly as a one-row INSERT would; a column with no such exact type gets no batching. Firebird's 256-context limit caps it at ~250 row-groups. Also handled here: once a NULL has been bound to a SQL_BIGINT parameter with SQL_C_DEFAULT (whose ODBC default C type is SQL_C_CHAR), OdbcFb writes NULL for every later SQL_C_SBIGINT rebind of that parameter (#300) -- NULLs are bound SQL_C_SBIGINT (NullParamCType). SQL_ATTR_ROWS_FETCHED_PTR set before SQLPrepare is discarded (#301); the bridge sets it after execute and was not affected. Both have upstream fixes from F.D. Castel, PR #302 and PR #303, verified here on their CI builds on 2026-09-07 (the workarounds stay until a release carries them). Generated ingest DDL used to spell an Arrow string as BLOB SUB_TYPE TEXT -- what SQLGetTypeInfo(SQL_LONGVARCHAR) names. One run read a 100,000-row table with such a column at 8,256 rows/s against 1,004,277 without it; that slowdown could not be reproduced afterwards (2026-08-28: BLOB and VARCHAR columns both read at 300k+ rows/s, plain ODBC and through the bridge, driver 3.0.1 and 3.5.0-rc1), so it is not recorded as a driver property. Strings go in as VARCHAR(8191), legal in any character set (ddl_string_type_name), because the column can then be filtered and indexed and reads through ordinary bound columns; the same table ingested at 40,670 rows/s instead of 5,705 in that run. Values over 8,191 characters are refused on insert, as Db2's VARCHAR(32672) refuses its last 28 bytes. Ingest 7.9k rows/s (8.4k with array binding, 6.0k with adbc.odbc.rows_per_insert=1) on the compat table, fetch 294k rows/s
Databend 1.2
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
the server has no prepared statements, so the connector runs with NO_SSPS=1; driver quirks handled: _binary literals for date/timestamp/binary params, MySQL type names in ingest DDL
Azure SQL Edge 16.0
msodbcsql 18
PASS
the SQL Server 2022 engine, so it takes the same path as SQL Server 2022, TEXT ingest-DDL quirk included — it even reports SQL_DBMS_NAME "Microsoft SQL Server"
OpenLink Virtuoso 7.2
Virtuoso ODBC (virtodbc.so, ANSI build)
PASS
ODBC-native server; driver quirks handled: SQL_C_WCHAR is a 4-byte wchar_t on both the parameter and the fetch side, not unixODBC's 2-byte SQLWCHAR (UTF-8 narrow path instead), SQL_C_SBIGINT is stored as 0 and read back as 0 without a diagnostic (64-bit ints sent as text declared SQL_NUMERIC, the one declaration that converts exactly), and datetime parameter arrays are strided by ColumnSize rather than by the C struct — a DATE32 bound with ColumnSize 0 repeats row 0 (so no parameter arrays); no BOOLEAN type. Two more to know: an SQL_C_SLONG parameter is read as 8 bytes whatever BufferLength says, so two int32 parameters bound from adjacent slots fail with SR346: Integer out of range; and any Charset= value but the exact literal UTF-8 aborts the process inside SQLDriverConnect (GPF: Dkbox.c:638 Double free). The Unicode build virtodbcu.so is built to a 4-byte SQLWCHAR, so unixODBC's driver manager aborts reading its first error diagnostic — lurcher/unixODBC#239, see UPSTREAM.md; ingest 11.9k rows/s, fetch 1.04M rows/s
openGauss 6.0
psqlodbc 16 (PG wire)
PASS
no quirks: a PostgreSQL 9.2 fork, driven by the postgres entry's types unchanged; server-side setup only (CAP_SYS_NICE for the MOT engine's mbind(), max_process_memory >= 2 GB, and a role created after start-up because the initial user cannot log in remotely); ingest 220k rows/s (258k with array binding), fetch 1.35M rows/s
IBM Informix 15.0.1 (developer edition)
Db2 clidriver (libdb2.so, DRDA)
PASS
same libdb2.so as Db2, so keyed on SQL_DBMS_NAME "IDS", not the driver name: no usable SQL_C_WCHAR params (UTF-8 narrow path instead), SQL_C_BIT params break the DRDA stream so booleans go as integers; 32-bit SQLLEN as for Db2; BYTE described as IBM's own SQL_BLOB type code (-98); server side: GL_USEGLU=1 for 4-byte UTF-8, DELIMIDENT=y for the quoted identifiers ingest emits, DATETIME YEAR TO FRACTION(5) timestamps; ingest 15.6k rows/s (130k array), fetch 751k rows/s
Arrow Flight SQL (sqlflite 1.5.5 / DuckDB 1.1.1)
Flight SQL ODBC 0.9.7 (Dremio, open source)
PASS (read)
driver has no SQLBindParameter at all, so nothing can be written through it; driver quirks handled: SQLColumns segfaults on the first SQLFetch, so GetObjects describes a zero-row SELECT instead (no_sql_columns); every DECIMAL described as (19,0), so decimals are read as exact text; fetch 1.32M rows/s
GreptimeDB 1.1.4 (MySQL 8.4.2 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
time-series store, driven over its MySQL wire (4002); its PostgreSQL wire (4003) cannot be reached at all — psqlodbc's connect handshake asks for show transaction_isolation, which GreptimeDB does not implement (only SHOW TRANSACTION ISOLATION LEVEL), so SQLDriverConnect fails as it does for H2. Every table needs a TIME INDEX column, so generated ingest DDL appends one that defaults to the insert time (ddl_extra_column: greptime_timestamp TIMESTAMP(3) TIME INDEX DEFAULT CURRENT_TIMESTAMP) plus append_mode (ddl_table_options) — outside append mode rows sharing a timestamp are merged; prepared-statement metadata types every parameter as a string and then rejects it, so NO_SSPS=1 plus the Databend _binary quirk; no DOUBLE PRECISION type name and no ANSI_QUOTES (backtick-quoted identifiers); ingest 181k rows/s (156k with array binding), fetch 989k rows/s
a second Flight SQL server behind the same driver, and it needed no new driver quirk (the SQLColumns one is shared with sqlflite — here it crashes only for tables whose Flight SQL schema carries no per-field metadata, InfluxDB's system and information_schema tables, while the entry's own iox tables fetch cleanly); the entry is read-only twice over (InfluxDB 3's SQL is query-only — DDL answers DDL not supported: <op>, DML DML not supported: <op>, and tables come into existence when line protocol is written to them over the HTTP API — and the driver has no SQLBindParameter); server side: a table is tags, fields and a nanosecond time column that is always spelled time, with no DATE, DECIMAL or binary type, so the entry reads time as ts and casts the date back in its own SELECT; fetch 1.03M rows/s
Dremio 26.0.5 (OSS, Arrow Flight SQL)
Flight SQL ODBC 0.9.7 (Dremio, open source)
PASS (read)
the engine that publishes this driver, and it needed no new driver quirk — no_sql_columns is keyed on the driver name, so Dremio takes the path shared with sqlflite and InfluxDB, though against Dremio 26 SQLColumns does not in fact crash (it describes its 18 result columns and fetches every row), so here the quirk costs a describe rather than avoiding a segfault; read-only because of the driver alone -- Dremio writes fine ($scratch, the writable source a stock dremio-oss ships, takes CTAS, and a table created there with a column list is Iceberg and takes INSERT), but SQLBindParameter is HYC00 Unsupported function even after a SQLPrepare that succeeds, and SQLNumParams reports 0, so no parameter can reach the server and setup builds both tables with literal CREATE TABLE IF NOT EXISTS ... AS SELECT; unlike sqlflite the driver reports a DECIMAL's declared scale — described as (19, s), precision still always 19 — so decimals arrive exact in a wider decimal128 rather than as text; a string literal holding any character outside ISO-8859-1 (a BMP 漢 as much as an emoji) needs the _UTF8'…' prefix or Dremio's parser encodes it as ISO-8859-1 and fails planning; server side, the first admin user has to be created over the REST API before any login works, and the query context comes from a schema connection property the driver forwards as a gRPC header; fetch 1.1M rows/s
Microsoft Access .mdb/.accdb
MDB Tools 1.0 (odbc-mdbtools)
PASS (read)
the driver executes no DDL/DML and has no SQLBindParameter; 32-bit SQLLEN, as Db2
ArcadeDB 26.9 (PostgreSQL wire)
psqlodbc 16 (PG wire)
PASS (read)
multi-model engine behind the PG wire: no CREATE TABLE at all (a table is a document type plus one CREATE PROPERTY per column), so adbc_ingest's generated DDL has nowhere to go and the entry runs the read side — queries, parameters and the catalog all work; driver quirks handled: psqlodbc's SQLColumns and SQLTables(SQL_ALL_TABLE_TYPES) are queries its parser will not run -- SQLColumns answers SQL_SUCCESS with zero rows, SQLTables(SQL_ALL_TABLE_TYPES) answers SQL_ERROR -- so GetObjects describes a zero-row SELECT and GetTableTypes falls back to the types the server's own tables have; BoolsAsChar=0, ISO-8601 T timestamp literals, @rid/@type/@cat in every SELECT *; also traverses a small graph (vertices, edges, expand(out())); fetch 332k rows/s. ArcadeDB published an account of this integration and the four protocol findings behind it on 2026-09-21: https://arcadedb.com/blog/arcadedb-apache-arrow-adbc-postgresql-wire/. The T-literal requirement is a 26.9.1 trait only: the silent NULL behind it, #8090, is fixed in #8096 (merged 2026-09-22, ships in 26.10.1), and on 26.10.1-SNAPSHOT the space-and-fraction literal and a bound timestamp parameter both round-trip, verified here 2026-09-22. Native-driver status, for comparison: the Apache Arrow PostgreSQL ADBC driver (1.12.0) could not connect at all until ArcadeDB fixed its pg_type bootstrap (#7178, opened and merged by the maintainer within a day of the probe note, 2026-09-06; ships in 26.10.1, in 26.10.1-SNAPSHOT now); on the merged build it connects and lists types, stops at COPY … TO STDOUT (FORMAT binary) with its defaults, and with adbc.postgresql.use_copy=false reads the fixture end to end; GetTableSchema's ::regclass lookup, the last catalog gap, is fixed in #7187 (merged 2026-09-06, also in 26.10.1); binary COPY itself, #7188, was implemented in #7398 and its framing matched to PostgreSQL's in #7607 (2026-09-15), so on 26.10.1-SNAPSHOT the native driver passes the whole probe with its defaults; on the released 26.9.1 the ODBC route above is the one that works
Materialize 26.38
psqlodbc 16 (PG wire)
PASS
streaming warehouse; PostgreSQL SQL layer, so no driver quirks -- but no SAVEPOINT, so the entry sets psqlodbc's Protocol=7.4-0 to stop the driver wrapping the second batch of a large ingest in one (with that setting, a prepared statement freed before a manual commit also lost the transaction's rows through a psqlodbc/Materialize pair of defects: fixed in psqlodbc 18.00.0003 and in Materialize #38606, merged 2026-09-14, verified on its main image, not yet in a Materialize release); its single 39-digit NUMERIC is wider than an Arrow decimal128, so decimals read back as exact strings; also ingests into and reads back an incrementally maintained MATERIALIZED VIEW; ingest 23.6k rows/s (23.7k with array binding), fetch 322k rows/s
Apache Ignite 2.17
Ignite ODBC (built from the sources in the image)
PASS
in-memory key-value grid with a SQL engine: no prebuilt Linux driver exists, so platforms/cpp is built root-free (-DWITH_ODBC=ON -DWITH_CORE=OFF, no JVM); driver quirks handled: no wide SQL type at all (SQL_WVARCHAR refused outright by SQLBindParameter, SQL_C_WCHAR sized in wchar_t) so the UTF-8 narrow path, as for Firebird, and column-wise parameter arrays that test the NULL indicator of row 0 for every row — a NULL below the first row is dropped rather than sent: a character column stores an empty string, and a BINARY column segfaults the client inside SQLExecute (WriteInt8Array is handed the -1 indicator as its length), so arrays are off (row-wise binding is refused outright). SQL_DRIVER_VER and SQL_DBMS_VER are both the hardcoded 02.04.0000, neither the server's 2.17.0 nor the negotiated protocol 2.13.0. Server side: every table is a cache and must declare a PRIMARY KEY, which generated ingest DDL cannot, so mode="create" is impossible (ingest_create=False) and the entry ingests by appending into a keyed table; identifiers fold to upper case and the driver reports no quote character. Fetch 930k rows/s; append into a keyed table ~95k rows/s
Google Cloud Spanner (emulator + PGAdapter 0.55)
psqlodbc 16 (PG wire)
PASS
two driver quirks, both keyed on a PGAdapter-only setting because version() just says PostgreSQL 14.1: psqlodbc inlines a parameter array's timestamps as '...'::timestamp, a type Spanner does not have, so a batch binding a timestamp goes row-at-a-time (no_timestamp_param_arrays); and every Spanner table needs a PRIMARY KEY, so generated ingest DDL adds a surrogate GENERATED BY DEFAULT AS IDENTITY column (ingest_key_column). Server side: no 32-bit integer, no TIMESTAMP WITHOUT TIME ZONE (so ts reads back zone-aware), no modifier on NUMERIC, no DDL inside a transaction; also ingests into and reads back an INTERLEAVE IN PARENT child table. A third quirk on the same key is Spanner's ceiling of 950 parameters per statement (max_statement_params): PGAdapter prepares a multi-row INSERT that carries more without complaint and then closes the connection at SQLExecute (08S01), leaving the batching's halving search no connection to halve on -- measured exactly, 948 parameters go through and 952 drop the connection -- so it is declared rather than probed, and ingest runs at 237 four-column rows per INSERT. ingest 7.3k rows/s (7.4k with array binding), fetch 95.8k rows/s at --rows 300 --fetch-rows 2000, which is the size this entry is benchmarked at
MatrixOne 4.2 (MySQL 8.0.30 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
mysql_native_password only, so run from the tarball the connector needs PLUGIN_DIR=; server side: a table without a PRIMARY KEY gets a hidden __mo_fake_pk_col that SQLColumns reports in GetObjects; a parameter array bound into a BIT column aborts the server once it holds NULLs (malloc(): unaligned fastbin chunk detected; single parameters and NULL-free arrays are fine), so ingest sends booleans as TINYINT — fixed on MatrixOne main by matrixorigin/matrixone#27645 (2026-08-26): on the 2026-08-28 nightly a bound NULL stores as NULL and the same 999-set array runs clean, so the mapping is for the released 4.2.0; driver quirk handled: MatrixOne describes a TEXT column as SQL_WLONGVARCHAR one third of its widest value's byte length wide (5 characters for the benchmark's 16-byte strings, 0 for an empty result set), so binding at that width truncates every row; a no-declared-length column is bound at long_bind_bytes instead of re-reading every row (2.05M rows/s in the fix's own measurement). SHOW VARIABLES and SHOW COLLATION kill the client with SIGFPE inside Connector/ODBC 9.4's get_column_size (a zero charset width in the result metadata); the bridge issues neither; ingest 97.5k rows/s (86.5k with array binding), fetch 1.97M rows/s
StarRocks 4.1.4 (MySQL 8.0.33 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
MPP columnar warehouse: it prepares nothing but SELECT, so the connector runs with NO_SSPS=1, and the _binary date/timestamp/binary literals it then emits are sent as ordinary quoted text instead (temporal_binary_param_as_varchar, restored -- it had been dead code since a bad merge); MySQL type names rejected in ingest DDL, so it uses standard SQL names, and the portable fallback for a double is now DOUBLE, not the ISO DOUBLE PRECISION, which StarRocks does not parse; no ANSI_QUOTES mode at all, so identifiers are quoted with backticks (the driver already asks for SQL_IDENTIFIER_QUOTE_CHAR); DECIMAL(10,3) described at MySQL's display width (12,3); ingest 4.8k rows/s -- every INSERT is a load transaction costing a flat ~100 ms of server-side work (10 rows/s for any client sending one row per statement), so the multi-row batching is worth 480x here (pyodbc, which has no such batching, cannot ingest here at all); fetch 1.80M rows/s
OpenSearch 3.8 (SQL plugin)
OpenSearch SQL ODBC (built from source)
PASS (read)
search engine reached through its bundled SQL plugin (/_plugins/_sql); the project publishes the driver for Windows and macOS only, so it is built for Linux — its never-compiled POSIX branch needed three source fixes: two compile fixes plus a sem_init() given capacity where WIN32/APPLE use initial (the pop semaphore starts full, so pop() corrupts an empty queue and clear() segfaults on close). Driver quirk handled: its ANSI SQLDriverConnect cannot connect at all — CC_connect() asks for the SQL_ASCII client encoding, which it does not support, unless SQLDriverConnectW set the unicode-driver flag first — and returns SQL_ERROR with an empty diagnostic queue, so adbcBridge retries a connect that failed without saying why through SQLDriverConnectW; one that reported a real error, bad credentials say, is left alone — though through unixODBC no other failed connect ever returns: the driver exports SQLErrorW and hands back the same record on every call, and the driver manager, which prefers SQLError for collecting a failed connect's diagnostics, loops on it forever (opensearch-project/sql-odbc#102). Read-only twice over — the SQL plugin is a query interface (no CREATE TABLE, no INSERT) and the driver answers SQLBindParameter with "OpenSearch does not support parameters" — so the indices are written over the REST _bulk API; backtick identifiers, no DECIMAL or binary type, and the driver's result-set type map has no timestamp entry so ts is described SQL_WVARCHAR and arrives as text (its SQLColumns and SQLGetTypeInfo tables do map timestamp, so the two metadata paths disagree about the same column); also runs MATCH/MATCH_QUERY full-text predicates; fetch ~120k rows/s
Apache Doris 2.1.0 (MySQL 5.7.99 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
MPP analytic warehouse; reports no transaction support (SQL_TC_NONE), so the Databend quirk (_binary parameter literals rewritten as text, portable ingest type names) applies unchanged and NO_SSPS=1 is required (the FE prepares only a point SELECT on a store_row_column unique-key table -- any other SELECT is refused with Only support prepare SelectStmt point query now -- or an INSERT, and a server-side prepared INSERT executes only for parameters bound from SQL_C_CHAR or to a matching non-character SQL type: a parameter bound to a character SQL type from any other C type -- SQL_C_WCHAR included, which is how this connector binds a string -- gets a bare NullPointerException from the FE); every OLAP table has to declare how its rows are distributed, so generated ingest DDL appends DISTRIBUTED BY RANDOM BUCKETS AUTO plus enable_duplicate_without_keys_by_default (ddl_table_options) — a duplicate table with no key columns, without which Doris refuses any table whose first column is TEXT/STRING -- what a generated string column is here -- FLOAT or DOUBLE (The olap table first column could not be float, double, string ...; a leading VARCHAR(n), CHAR(n) or DECIMAL(p,s) is accepted) — keyed on @@version_comment since version() is a bare MySQL number; no binary column type and no DOUBLE PRECISION spelling; ANSI_QUOTES is accepted but ignored, so identifiers are backtick-quoted; ingest 2.2k rows/s (2.3k with array binding) -- like StarRocks an INSERT is a load transaction whatever it carries, so multi-row batching is worth ~300x (7 rows/s without it); fetch 1.36M rows/s — pyodbc's ingest column is empty: its row-at-a-time binding sends date, datetime and bytes parameters as _binary'...' literals, which Doris' parser rejects (the same values passed as str insert fine), and its fast_executemany stages first set autocommit=False, which Doris refuses (Transactions are not enabled)
no driver quirks and no tolerance flags: an MPP cluster of PostgreSQL 14 segments behind one coordinator, driven by the postgres entry's types unchanged (and, unlike CockroachDB, needing no PRIMARY KEY) — and since it reports SQL_DBMS_NAME "PostgreSQL" behind the same psqlodbcw.so, no driver-name quirk could be correct here without also firing on real PostgreSQL. Cloudberry is named once in the bridge and not as a workaround: version() carries Apache Cloudberry, a fork marker, so it is excluded from the unnest array-ingest form only PostgreSQL proper is claimed to owe (see the array-ingest note above) — conservative rather than necessary, since the form's own proof query passes and a 5,000-row unnest ingest lands on heap, append-optimized row and append-optimized column tables alike, about an order of magnitude faster server-side than the multi-row INSERT it keeps (~530k against ~33k rows/s, bare SQL on a shared host); no Apache-published server image exists -- apache/incubator-cloudberry holds only cbdb-build-*/cbdb-test-* CI toolchains and apache/cloudberry-db does not exist -- so the community woblerr/cloudberry image is used, and it needs --shm-size=1g or gpinitsystem fails; extra steps cover what the standard workload cannot tell apart from postgres: a DISTRIBUTED BY table whose bulk-ingested rows occupy more than one segment plus an aggregate merged on the coordinator, and append-optimized column-oriented storage (read from pg_am as ao_column -- Greenplum 6's relstorage is gone in the PostgreSQL 14-based 2.x); ingest 9.7k rows/s (9.8k with array binding), fetch 1.33M rows/s
YDB 23.4 (PostgreSQL wire)
psqlodbc 16 (PG wire)
PASS
distributed HTAP store behind a PostgreSQL-compatible wire; version() names it PostgreSQL 16, so the two quirks are keyed on the one thing that gives YDB away over ODBC — it answers SHOW server_version with that whole banner rather than a version number: every table needs a PRIMARY KEY, and no ingested column can be one (a YDB key is implicitly NOT NULL), so generated ingest DDL appends adbc_pk SERIAL PRIMARY KEY (ddl_extra_column), and pg_catalog.pg_attribute is empty, so psqlodbc's SQLColumns answers success with zero rows and GetObjects describes a zero-row SELECT instead (no_sql_columns). Server side: its PG wire has no NULL bind parameter at all — a -1 parameter length is read as a zero-length value, silently storing '' in a text column — so the entry sets psqlodbc's UseServerSidePrepare=0 and every NULL goes as a literal; NUMERIC precision is not reported; the image ships no users and no environment variable for the PG feature flags, so a role is created after start-up and both are covered by the setup notes; ingest 1.8k rows/s (1.8k with array binding) -- UseServerSidePrepare=0 means psqlodbc inlines every value into the statement text, and YDB parses and plans the result, which is what caps this; still 32x the 55 rows/s one INSERT per row manages; fetch 654k rows/s
first-party driver on 5433 — the port speaks the PostgreSQL v3 wire (libpq connects, server_version 14.0) but the PostgreSQL catalogs are absent, so psqlodbc fails at its connect-time pg_type lookup (Relation "pg_type" does not exist) and cannot drive it. Needs no tolerance flags at all (as Cloudberry does not): every workload type is a native Vertica type and the workload round-trips exactly, emoji and microseconds included (i is int64 — Vertica's integer types are all 64-bit aliases). f is exact because 1.5 is: the driver passes DOUBLE PRECISION through a 15-significant-digit decimal form in both directions (%1.15e in libverticaodbc.so, SQLDescribeCol size 15), so a bound 3.141592653589793 is stored as 3.14159265358979. The entry points VERTICAINI at a vertica.ini of its own for the driver's message catalogue (ErrorMessagesPath; without it every driver diagnostic degrades to [Vertica][DSI] ... Could not open error message files, though the workload still passes) and pins DriverManagerEncoding = UTF-16 — client 25.1 detects unixODBC's 2-byte SQLWCHAR by itself, but UTF-32 there corrupts every wide string in both directions with no error (six U+FFFD) and can abort the process. One quirk: its parameter arrays are a native bulk load (COPY ... FROM LOCAL STDIN NATIVE) and beat the one-row-per-execute path 7-8× (17-20k rows/s → 135-139k), so prefer_param_arrays — the flag maodbc already uses. Multi-row VALUES is refused on this fixture (Function public.explode("array") does not exist: vcluster create_db --skip-package-install leaves the ComplexTypes package out); with the package installed it parses and runs at ~2.5k rows/s, a column store taking each multi-row VALUES as one row-store insert, so the array path stays ahead either way. Server side, vertica/vertica-ce no longer exists (the vertica Docker Hub namespace is empty and the opentext one publishes no CE image), so the entry runs opentext/vertica-k8s and builds the database with vcluster itself (fixtures/setup_vertica.sh); pinned to 25.3 because Vertica 26.1 dropped Community Edition and a 26.x server refuses the licence its own image ships; needs nofile 65536; ingest 151k rows/s (array binding), fetch 948k rows/s
OceanBase CE 4.4.2 (MySQL 5.7.25 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
no driver quirks and no tolerance flag the mysql entry does not already have (TINYINT(1) booleans, ANSI_QUOTES) — a distributed HTAP engine whose MySQL mode takes that entry's types unchanged; multi-tenant, so the login name carries the tenant (User=root@test), and mysql_native_password only, so run from the tarball the connector needs PLUGIN_DIR=; the container must boot with MODE=SLIM, which starts a prebuilt cluster — the default MODE=MINI builds its tenant from scratch and times out loading the timezone tables (117,043 rows in mysql.time_zone_transition), and is also the only path on which obd's open-files precheck (OBD-1007, nofile below 20000) fires; the SLIM path boots under Docker's default 1024, and the compose file raises nofile to 20000 anyway because at 1024 the observer quietly stops accepting connections once ~780 are open; ingest 105k rows/s at 20,000 rows (12.2k with adbc.odbc.rows_per_insert=1; adbc.odbc.array_binding changes nothing here -- Connector/ODBC walks a parameter array row by row, so the multi-row INSERT stays ahead and ingest takes it either way); fetch 1.32M rows/s. One thing to know when writing NULLs here: on a server-side prepared INSERT the connector sends a NULL as MYSQL_TYPE_NULL until a non-NULL execute has fixed that parameter's type, and OceanBase refuses that execute with Object type error (4001) -- the NULL-carrying execute itself, not the ones after it; once a value has gone through on that statement, later NULLs are accepted, and a fresh statement starts over. NO_SSPS=1 avoids it entirely; MySQL itself is happy with the same sequence. Bulk ingest is where it shows: a multi-row INSERT whose first execute carries a NULL is refused, the ingest falls back to a parameter array, and Connector/ODBC answers SQL_PARAM_ERROR for the sets the server rejected -- adbcBridge re-runs those rows one at a time after the batch, by which point the types are fixed, so every row lands (0.1.0 dropped them silently and under-reported the count; a column that is NULL in every row raises the server's error)
SAP HANA Express 2.00.088 (HANA 2.0 SPS08)
SAP HANA client ODBC 2.29.25 (libodbcHDB.so, native wire)
PASS
SAP's in-memory column store, first-party server (saplabs/hanaexpress, which pulled anonymously) driven by SAP's own first-party driver from the free HANA client download — the EULA that gates it is passed with the cookie its checkbox sets, and the ODBC member of the tarball needs no installer, no LD_LIBRARY_PATH and no .ini of its own. Every workload type is a native HANA type and round-trips exactly, emoji and microseconds included (i is int32; a bare CREATE TABLE is a column table, default_table_type = column, and a CREATE ROW TABLE passes the same workload). Two HANA spellings to know: TIMESTAMP takes no precision argument (TIMESTAMP(6) is 42000, 257 sql syntax error: incorrect syntax near "(") because it is always 7 fractional digits, and there is no multi-row VALUES at all (INSERT INTO t VALUES (1,'a'),(2,'b') is 42000, 257 ... incorrect syntax near ",", with literals as with parameters). Four driver quirks, all keyed on libodbchdb. The first is a correctness bug independent of this matrix: the driver decodes narrow statement text as Latin-1, not as the UTF-8 bytes unixODBC handed it, so the UTF-8 of héllo 🚀 (68 c3a9 6c6c6f 20 f09f9a80) is stored as the eleven Latin-1 characters those bytes spell (LENGTH(s) 11, not 8) — the same corruption the Windows driver manager causes for every driver, here caused by one driver on every platform. It hides because it is self-consistent within narrow statements, and shows the moment a literal must match a value sent as a bound parameter (which travels SQL_C_WCHAR and arrives correct). No connection property (CHAR_AS_UTF8, CHAR_SET, charset) and no locale (LC_ALL=C, en_US.UTF-8, C.UTF-8) changes it; SQLExecDirectW stores it exactly, so wide_sql (new) routes caller statement text through the W entry points on every platform — the mirror of the existing narrow_sql, reusing the conversion the Windows build already had. Second, SQLGetTypeInfo(SQL_TYPE_TIMESTAMP) names SECONDDATE first — HANA's whole-second timestamp (COLUMN_SIZE 19, MAXIMUM_SCALE 0, no CREATE_PARAMS) — with the 7-digit TIMESTAMP only in rows two and three, so generated ingest DDL silently dropped every microsecond; ddl_timestamp_type_name (new) gives the name outright, since SECONDDATE has no CREATE_PARAMS for fractional_time_type_format to ask for a scale and TIMESTAMP takes no precision argument either. Third, SQLGetTypeInfo(SQL_LONGVARCHAR) names CLOB, which HANA bars from ORDER BY (HY000, 264 invalid datatype: "V" LOB type in ORDER BY clause) and SELECT DISTINCT (HY000, 264 ... LOB type in distinct select clause) — the SQL Server TEXT trap — so ddl_string_as_max_varchar, the flag Db2 already sets, asks for VARCHAR(5000) instead, fully Unicode since HANA 2.0 merged VARCHAR into NVARCHAR. Fourth, prefer_param_arrays (the third driver to set it, after maodbc and Vertica, and here the server's doing rather than the driver's): with no multi-row VALUES, MultiRowSetup's probe is refused and ingest falls back to one execute per row, 7,675 rows/s against 1,142,092 with a bound array. Server side, startup is 158 s on a cold volume (system database, then the HXE tenant) and settles at ~5 GB under a 12 GB cap; 39013 (system DB) and 39017 (the HXE tenant) must be published unmapped, because the client resolves a tenant by asking for its port and reconnecting to exactly that number; --agree-to-sap-license and a world-writable mount holding passwords.json (the image's hxeadm is uid 12000) are required, and of SAP's four sysctls only kernel.shmmax is IPC-namespaced and settable per container — the entry was verified with the other three left at a stock Ubuntu's values. Ingest 1,142,092 rows/s (array binding), fetch 6,768,257 rows/s
TDengine 3.3.6
taos-odbc (TDengine's own connector, built from source)
PASS (read)
time-series server whose every table must start with a TIMESTAMP primary key that is non-NULL, distinct and inside the retention window, so no generated ingest DDL and no positional workload INSERT fits one -- the entry reads tables its setup builds and bulk-ingests through extra into a timestamp-first table; driver quirks handled: no SQL_C_TYPE_TIMESTAMP conversion for bound columns or parameters (SQLGetData does convert; the bridge reads through bound block cursors, so timestamp columns are read as text and timestamp params sent as text), a boolean param is taken only as SQL_C_SBIGINT described as SQL_TINYINT, the driver implements no DECIMAL (a DECIMAL column fails the whole SELECT with not supported yet) so that column is exact text; backtick identifiers; fetch 403k rows/s
MongoDB 7 + BI Connector 2.14 (MySQL 5.7.12 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS (read)
mongosqld presents MongoDB collections as SQL tables over the MySQL wire; a query engine on a DRDL schema: CREATE TABLE, INSERT and DROP TABLE answer 1105 … requires --writeMode, UPDATE/DELETE/TRUNCATE are not in the grammar at all, and --writeMode is refused together with a --schema path — so the two collections are loaded with mongosh and the entry runs the read side unchanged. Driver quirk handled: SQLColumns segfaults inside Connector/ODBC on any table with a DECIMAL column -- mongosqld's information_schema reports NULL NUMERIC_PRECISION and the connector runs strtol() on it -- so GetObjects describes a zero-row SELECT instead (the existing no_sql_columns path the Flight SQL driver takes, keyed on SQL_DBMS_VER); server side: the handshake also segfaults without PLUGIN_DIR= (mysql_native_password), and COM_STMT_PREPARE is refused (NO_SSPS=1). No binary type; d is mapped timestamp by the entry's DRDL schema so it arrives as a midnight datetime (mongosqld does have a DATE SQL type: a SqlType: date column is described SQL_TYPE_DATE), decimals described as DECIMAL(65,20) so they read back as exact text, _id in every SELECT *; fetch 128k rows/s
SingleStore 9.1.1 (MySQL 5.7.32 wire)
MySQL Connector/ODBC 9.4 (MySQL wire)
PASS
no driver quirks and no tolerance flag the mysql entry does not already have (TINYINT(1) booleans, ANSI_QUOTES) — a distributed HTAP engine whose tables are columnstore by default and whose MySQL mode takes that entry's types unchanged; columnstore and rowstore were probed side by side and behave identically on every point the workload checks. mysql_native_password only, so run from the tarball the connector needs PLUGIN_DIR=; the free ghcr.io/singlestore-labs/singlestoredb-dev image needs no licence key and no account (the older cluster-in-a-box wants a LICENSE_KEY), and its one-off CheckCapacity warning — used capacity 3 units against the licence's 1 — is the Developer Image licence describing itself, with nothing in the workload refused. One thing to know when writing timestamps here through MySQL Connector/ODBC, which costs precision in plain ODBC but not through adbcBridge, and which is the connector's doing rather than SingleStore's (a plain-ODBC probe on 2026-09-04 gave byte-identical results against a stock MySQL 8.4 server): the connector's SQLGetTypeInfo row for datetime reports COLUMN_SIZE 21 / MAXIMUM_SCALE 0 and a DATETIME(6) result column is described COLUMN_SIZE 19 / DECIMAL_DIGITS 0, so neither carries the column's real fractional precision — pyodbc derives its bind scale from that column size (21 − 20 = 1 digit) and silently stores 13:45:10.123456 as 13:45:10.1, on the NO_SSPS=1 text path too; the same value as a SQL literal or bound as a string keeps all six digits, and adbcBridge binds its own scale rather than the driver's type table, so the microsecond assertion passes unchanged. SingleStore's own free ODBC driver (singlestore-odbc-connector 1.2.2, a MariaDB Connector/ODBC fork) passes the same entry and is the cross-check for that: it reports SingleStore / 09.01.0001 rather than the wire's MySQL / 5.7.32, describes the column COLUMN_SIZE 26 / DECIMAL_DIGITS 6 (pyodbc is exact through it), and needs neither PLUGIN_DIR= nor the LD_PRELOAD, but its SQLGetTypeInfo fails under ANSI_QUOTES — 42S22 … Unknown column 'json' in 'field list' (1054), the name always being the first type the call would have returned, so the driver is building its type table from double-quoted string literals that ANSI_QUOTES turns into identifiers — the template SELECT "%s" AS TYPE_NAME, … is visible in the driver binary (SQLColumns, SQLTables and ordinary queries are unaffected); separately, its SQLColumns with a NULL catalog name segfaults the process on strdup(NULL) in MADB_StmtColumnsNoInfoSchema whenever at least one column matches, in either sql_mode. adbcBridge absorbs that rather than failing — TypeNameOne treats a SQLGetTypeInfo that does not succeed as "no such type" and falls through to the portable ANSI name — so the only visible difference is the ingest DDL's spelling (mediumtext/bit(1) through Connector/ODBC, text/tinyint(1) through SingleStore's), both valid and both passing the read-back. The entry uses Connector/ODBC because the eleven other MySQL-wire entries already do. Ingest 164k rows/s (159k with array binding), the fastest of the MySQL-wire entries; fetch 1.26M rows/s. Measure twice: SingleStore compiles each query shape to machine code on first sight, and a cold-container run of the same benchmark gave 75k/109k — which reads as "arrays are 45% faster" and is really the compiler, since with plans cached the two forms land within 3% of each other, so prefer_param_arrays is not wanted. Verified on Linux and macOS
Exasol 2025.1.14
Exasol ODBC 25.2.1 (libexaodbc.so, native wire on 8563)
PASS
in-memory columnar warehouse on its own first-party driver. Three driver quirks, all new and all keyed on SQL_DRIVER_NAME "exaodbc". (1) Exasol has no binary column type at all — BLOB is 0A000 "Feature not supported: data type BLOB" and VARBINARY/BINARY/RAW are not words its parser knows — and the driver refuses the matching C type outright: SQLBindParameter with SQL_C_BINARY answers HY003 "Invalid application buffer type: SQL_C_BINARY" for any target column, so an Arrow binary column could not reach the server at all; it now goes as SQL_C_CHAR into a VARCHAR (binary_param_as_varchar), where the bytes store and read back byte for byte. (2) A NULL parameter described as SQL_DECIMAL cannot be bound with SQL_C_DEFAULT: SQLExecute answers SI002 "C-Type not supported" and HY010 "Error creating prepared statement header", failing the whole statement. That is not a corner case here — Exasol has no narrow integer type, INT/INTEGER/BIGINT are all aliases of DECIMAL, and SQLDescribeParam reports SQL_DECIMAL for every one of them, so a NULL in any numeric column hit it; SQL_C_CHAR with a NULL data pointer is accepted (null_decimal_param_as_char). (3) SQL_GETDATA_EXTENSIONS is 0xf — ANY_COLUMN | ANY_ORDER | BLOCK | BOUND — but SQLGetData on a block rowset works exactly once per SQLFetch: measured on a five-row rowset, the first SQLGetData after the fetch succeeds whichever row SQLSetPos(SQL_POSITION) selected — a clipped 100-character value included — and every later one answers SQL_NO_DATA with no diagnostic record, clipped or not, bound or unbound (a plain-ODBC probe on 2026-09-04 isolated the rule; with SQL_ATTR_ROW_ARRAY_SIZE 1 every row reads fine). SQLFetch resets the driver's per-column "already returned" state and SQLSetPos does not, so a clipped value in a block rowset is unrecoverable (getdata_repair off; SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1 is 0x0 — not even SQL_CA1_NEXT, let alone SQL_CA1_POS_POSITION — yet SQLSetPos returns SQL_SUCCESS rather than refusing), and since SQLGetTypeInfo(SQL_LONGVARCHAR) names VARCHAR(2000000) — the type generated ingest DDL gives an Arrow string column — the common table has an unbindable text column: 1.54M rows/s against the 3.32M/s the wrong bound read managed. Two tolerance flags, both the type system rather than the driver: i reads back decimal128(18, 0) because INTisDECIMAL(18,0), and TIMESTAMP defaults to millisecond precision so the entry declares TIMESTAMP(6) to keep ROW1's microseconds. Unquoted identifiers fold to upper case (SQL_IC_UPPER), so the catalog calls ask for the stored spelling. Everything else is clean on the generic path: emoji, DECIMAL(10,3), NULL rows, affected-row counts, GetObjects/GetTableSchema, batched reads, and the 3,000-row wide-text ingest whose 9,010-character values round-trip whole. Server side, exasol/docker-db needs --privileged (ExaClusterOS manages its own block devices and loop mounts inside the container) and ~4 GB; TLS is on by default, so the connection string carries SSLCertificate=SSL_VERIFY_NONE for the self-signed certificate it generates at first boot, and a fresh database has no schema, so the entry creates and opens one; ingest 10.0k rows/s (10.2k with array binding, pyodbc 16.9k), fetch 1.54M rows/s (pyodbc 390k)
first-party driver on 20300, linked against nothing but libc, lifted out of the server image with docker cp (-ul64 is the 8-byte-SQLLEN build unixODBC wants; the -ul32 sibling sits beside it). Server side, altibase/altibase is licence-gated — it ships a 1-byte conf/license and the boot stops at Check License with No valid license present! (ERR-42000), a 90-day trial key being a registration away — so the entry runs the same vendor's free altibase/a_plus_edition, which needs none; it wants MODE=foreground (the image default MODE=init starts a shell and exits) and DB_CHARSET=UTF8 (the default is KO16KSC5601). Driver quirks handled: the wide and narrow paths disagree above the BMP — a SQL_C_WCHAR parameter is stored as CESU-8 (héllo 🚀 is 13 bytes, LENGTH() 8) and reading that back narrow yields bytes no UTF-8 decoder accepts, while SQL_C_CHAR in and out round-trips it byte for byte, so both ends stay narrow (wchar_as_utf8 + narrow_params, as Informix); its byte types carry vendor SQL_DATA_TYPE codes outside the ODBC range (BYTE 20001, NIBBLE 20002, VARBYTE 20003, BLOB 30, CLOB 40, VARBIT -100) and, unrecognised, BYTE/VARBYTE/BLOB fall through to the text path and come back hex-encoded, so those three are read as SQL_C_BINARY (as Db2's own SQL_BLOB -98 already is); SQLGetTypeInfo has no SQL_LONGVARCHAR at all and reports CREATE_PARAMS precision — not length — for VARCHAR, so generated ingest DDL got a bare VARCHAR, which in Altibase means VARCHAR(1) and fails the first insert with 22026 Invalid data type length, fixed by naming VARCHAR(32000) (ddl_string_type_name, as Firebird); SQL_IDENTIFIER_QUOTE_CHAR is a blank, so ingest quotes nothing and the server upper-cases what it emits (the entry leaves its own SQL unquoted too, as Ignite's does). Types: no BOOLEAN (HY004 Unable to create a column with the specified data type, so bo is SMALLINT → int16), no DOUBLE PRECISION (DOUBLE), no VARBINARY, and DATEis the timestamp — microseconds and all, described SQL_TYPE_TIMESTAMP — so it carries both d and ts; BYTE, VARBYTE, NIBBLE, BIT and VARBIT accept no bound SQL_C_BINARY parameter at all (22018 Conversion not applicable, 135180), which is why b is a BLOB. Its parameter arrays are one round trip and beat the multi-row INSERT 26×, so prefer_param_arrays — the third driver to need it, after maodbc and Vertica's; ingest 30k rows/s multi-row, 778–816k as an array, fetch 2.0–2.3M rows/s
a vectorized analytic database with a first-party driver — and the driver has the sharpest parameter bug in this matrix. It does not refuseSQLBindParameter the way the Flight SQL driver does; it accepts every bind and then executes parameter N with the value bound at N+1, sending the last parameter as NULL. A one-parameter INSERT therefore stores NULL under SQL_SUCCESS with an empty diagnostic queue, a three-parameter one writes the values shifted a column left, and an eight-parameter one fails HY000 (1040) Driver Error: type: %d — an unformatted format string. Reproduced with no pyodbc in the picture, binding SQL_C_LONG→SQL_INTEGER and SQL_C_CHAR→SQL_VARCHAR through unixODBC directly: HY000 (1040) Driver Error: stod; Setting column 'i' with value: 'hello', the second parameter's text landing in the first column. SQLDescribeParam compounds it by reporting every parameter as SQL_VARCHAR(1024) whatever the target column is, and a string parameter on SELECT ? segfaults the process. Nothing a caller binds can be trusted to arrive, so the entry is read-only and setup builds both tables with literal SQL — Kinetica itself writes fine that way, SQLExecDirect is exact, and emoji, BYTES, DATE and BOOLEAN all round-trip. Two bridge quirks, both keyed on the driver/DBMS name Kinetica: decimal_fixed_precision — the server has exactly one decimal type, DECIMAL(18,4) (every DECIMAL(p,s) in DDL is stored as it, as SHOW CREATE TABLE confirms), and the driver misdescribes such a column three different ways: SQLDescribeCol says precision 38 scale 0, SQLColumns says (18,18), and SQLGetTypeInfo says 38; believing scale 0 read the 12.3450 the driver hands over as a decimal128(38,0) holding 12, so the real pair is named instead. zero_row_suffix — Kinetica's planner constant-folds a provably false predicate and answers the query from its empty pseudo-table SYSTEM.ITER, which cannot project a BYTES column (Invalid attribute: BYTES(null) for table: SYSTEM.ITER … Unknown function: BYTES), so GetTableSchema and the GetObjects describe fallback failed outright on any table holding binary; they ask for no rows with LIMIT 0, which the planner does not fold. Also worth knowing: SQL_TXN_CAPABLE is 0, SQLGetTypeInfo names the temporal types TYPE_DATE/TYPE_TIME/TYPE_TIMESTAMP (ODBC C constants, not names any DDL accepts), DATETIME is millisecond precision, SELECT VERSION() answers with a hard-coded PostgreSQL 9.5 banner, and UID=admin;PWD= (a non-empty user with an empty password) is refused 1010 Insufficient credentials where omitting both connects. Server side: the Developer Edition image needs GPUDB_START_ALL=1 or the database never starts, and it needs no GPU and no licence key; the Linux ODBC client tarball ships inside it. Fetch 579k rows/s (pyodbc 383k)
Actian Ingres 10.1 (open-source edition)
Ingres ODBC (libiiodbcdriver.1.so, ships with the installation)
PASS
Ingres' own driver over Ingres/Net on 21064. It cannot be used through unixODBC as it stands: its wide entry points take a four-byte SQLWCHAR (the platform wchar_t) while unixODBC's is two, and unixODBC picks a driver's Unicode path purely by finding SQLConnectW in it, so the connection string arrives as its first character and the connect fails 08004 / 786744 E_GC0138_GCN_NO_SERVER — an empty vnode and database, leaving just the /INGRES server class. The same call with a UTF-32 string connects, which is the proof of the width. Its narrow entry points are correct and complete, so the entry loads it behind a generated ANSI-only forwarding library (fixtures/ingres_ansi_shim.c, 72 one-jump stubs) and unixODBC does the UCS-2 conversion itself. A second driver bug sets the shape of the connection string: Driver= is strcpy'd into a 32-byte buffer, so any real path aborts the process outright (*** buffer overflow detected ***, __strcpy_chk under ConDriverInfo) — the entry registers the shim in odbcinst.ini and names it Driver=Ingres. Two bridge quirks then carry it: 32-bit SQLLEN (as for the Db2 clidriver — a NULL column otherwise reads back as the row before it, a NULL FLOAT8 as 1e-323), and no usable SQL_C_WCHAR parameters (each 16-bit unit is treated as a code point, so a surrogate pair is rejected: 5000B, Unicode code point 0000D83D cannot be mapped to local character set — UTF-8 narrow path instead, as for Firebird, Virtuoso and Informix). Server side: createdb -n (a Unicode-enabled database, without which a VARCHAR cannot hold the emoji and NVARCHAR cannot be created at all), date_alias = ansidate before the DBMS starts (on the default, ANSIDATE is created as Ingres' own date-and-timeINGRESDATE, whose NULL comes back as the year-0 empty date), and an Ingres user for the local account the client runs as — the vnode login authenticates the connection while the session still runs as the caller's OS user (E_US18FF). Ingres SQL has no multi-row VALUES at all (Syntax error on ','), so bulk ingest falls back to parameter arrays on its own. Actian's own actian/ingres:ii12.1.0 image refuses to start without a commercial license.xml, so the server is the GPL ingres-10.1.0-00-NPTL build baked into the community image iidbdb/ingres; ingest 1.1k rows/s (1.3k array), fetch 393k rows/s
Driver available, no server you can run
These exist only as hosted services (or the server is not redistributable), so verifying
them means an account with the vendor. The ODBC drivers themselves are free downloads.
Not verified; expected to work on the generic path.
Database
Driver
What it takes
Snowflake
Snowflake ODBC
trial account
Google BigQuery
Simba ODBC for BigQuery
GCP project
Amazon Redshift / Athena
Amazon ODBC drivers
AWS account
Databricks
Simba Spark ODBC
workspace
Azure Synapse / SQL Database
msodbcsql 18
Azure subscription (same driver as SQL Server — the verified one)
Teradata
Teradata ODBC
Vantage Express VM
Cassandra / ScyllaDB
DataStax ODBC
driver download needs registration
Apache Hive / Impala / Spark Thrift
Cloudera / Simba ODBC
driver download needs registration
Trino / Presto
Starburst ODBC
driver download needs registration
Progress OpenEdge, Actian Zen, InterSystems IRIS/Caché, SAP ASE/IQ, Informix CSDK
vendor ODBC
vendor SDK / licence
Known not to work
Database / driver
Why
H2 2.4 (PG mode) + psqlodbc
psqlodbc's connect handshake sends SET DateStyle = 'ISO';SET extra_float_digits = 2;show transaction_isolation, and H2's parser rejects the last two in every MODE (no SHOW, and SET takes only H2's own setting names). SQLDriverConnect therefore fails with 42001 and adbcBridge never gets a handle, so no quirk can apply. H2 has no ODBC driver of its own. See tests/compat/README.md for the wire-level repro
libSQL server (sqld)
No ODBC route at all. It was queued on the assumption that sqld still had the PostgreSQL wire listener psqlodbc could drive, but SQLD_PG_LISTEN_ADDR is silently ignored: sqld serves only its own HTTP/JSON protocol (Hrana) and gRPC, nothing listens on 5432, and a PG startup packet gets ECONNRESET. --pg-listen-addr is absent from sqld --help and no pgwire string is in the binary, in latest (0.24.33) and in v0.22.0, the oldest tag the registry carries — the code was dropped upstream before that. There is no libSQL ODBC driver, and sqliteodbc opens local files, not a remote sqld. See tests/compat/README.md
Elasticsearch SQL ODBC
Windows-only driver
Microsoft Text/Excel ODBC
Windows-only; on Linux read CSV/Parquet/Excel through DuckDB's ODBC driver instead
Per operating system
The 46 entries of the 2026-08-25 campaign were verified on Linux (Ubuntu 24.04, unixODBC
2.3.12) and the same workload was run on macOS (Apple M4 Max, arm64, macOS 26.5) and Windows.
The Windows column is the 2026-08-25 re-measure of all 46 entries at a4d6ce5 plus the four
Windows-only bridge fixes that campaign found (Firebird's driver name, Informix's narrow
parameter, the Arrow Flight SQL driver's binary text route, Ignite's narrow statement text —
each cell that depends on one says so) on a Windows 11 Home x64 machine (i9-13900HK, 14C/20T,
32 GB; every server a Docker Desktop/WSL2 container under a 20 GB VM cap; x64 Release, MSVC
19.44), which supersedes the earlier 7.7 GB-laptop pass — where the two differ, a driver
version or one of those fixes is the reason and the cell says so. The seven entries added on
2026-09-03 (SingleStore, SAP HANA Express, Exasol, Altibase, Kinetica, Ingres, Db2 for i) have
the cells shown at the end of the table; the per-OS counts quoted elsewhere — 53 on Linux, 45
on macOS, 48 on Windows — are the PASS cells of this table. One result per entry per OS:
PASS (server version); FAIL: <first error>; driver unavailable: <what the vendor ships
for that OS/arch>; not runnable here: <why> — the last two are facts about the platform,
not gaps in the driver, and are recorded as such. pending and not run mean not yet run.
Details and the machine descriptions: bench/BENCHMARKS-macos.md,
bench/BENCHMARKS-windows.md.
The same results, with the quirks each entry needs, are published as machine-readable
data in compatibility.json — generated from this table and from
the harness in tests/compat/test_matrix.py by scripts/gen_compatibility_json.py, and
checked in CI, so it cannot drift from what is written here.
entry
Linux
macOS arm64
Windows x64
sqlite
PASS
PASS (SQLite 3.51.0)
PASS (SQLite 3.43.2, SQLite3 ODBC Driver)
duckdb
PASS
PASS (DuckDB ODBC 1.5.5.0, universal binary)
PASS (duckdb_odbc 1.5.5.0, DuckDB Driver registered by the zip's odbc_install.exe)
PASS (MariaDB 11.8 arm64, Connector/ODBC 3.2.9) — after the maodbc ≥ 3.2 quirk (34b5863): at 688229f Connector/C 3.4.9 segfaulted on a NULL DATE in a parameter array
PASS (MariaDB 11.8.9, mariadb:11 container; MySQL Connector/ODBC 26.7.1 with NO_SSPS=1 from {no_ssps})
columnstore
PASS
PASS (MariaDB ColumnStore 11.1.1) — provisioning and the user created by hand; columnstore.cnf mounted from /private/tmp
PASS (MySQL (via ODBC) 11.1.1-MariaDB-log; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1; provisioned per the README, with the zz-adbc.cnf copied into /mnt/skysql/columnstore-container-configuration/ because the bind-mounted copy is world-writable under Docker Desktop and ignored)
oracle
PASS
PASS (Oracle 23.26.0200, Instant Client 23.3 arm64) — NLS_LANG=.AL32UTF8 must be in the environment before the process opens its first Oracle connection (on Linux setting it in-process before SQLDriverConnect is enough; on macOS the harness's in-process setting was too late, so export it before the process starts — and with it unset the corruption is written, the server stores U+FFFD)
PASS (Oracle 23.26.0200, gvenzl/oracle-free:slim; Instant Client 23 Oracle in instantclient_23_0, NLS_LANG=.AL32UTF8 exported before the process starts; the 3,000-row wide-text/CLOB check included)
PASS (ClickHouse 26.7.5.10; clickhouse-odbc 1.5.5 Unicode from the GitHub MSI)
mssql
PASS
PASS (SQL Server 2022)
PASS (SQL Server 2022 16.00.4265, mcr.microsoft.com/mssql/server:2022 container; msodbcsql 18.6.2.1)
azuresqledge
PASS
PASS (SQL Server 15.00.2000, arm64 image, msodbcsql 18.6.2.1 arm64)
PASS (Azure SQL Edge, SQL Server 16.00.5100; msodbcsql 18.6.2.1)
mysql
PASS
PASS (MySQL 8.4.11 arm64, MariaDB Connector/ODBC 3.2.9) — after the maodbc ≥ 3.2 quirk (34b5863): at 688229f the connector's array path misreported the row count
PASS (MySQL 8.4.11, mysql:8 container; MySQL Connector/ODBC 26.7.1)
tidb
PASS
PASS (TiDB, MySQL wire 8.0.11)
PASS (TiDB v7.5.1, MySQL wire 8.0.11; MySQL Connector/ODBC 26.7.1 with NO_SSPS=1 from {no_ssps} — the 8.4.0-era note that no newer connector is published for Windows is obsolete: dev.mysql.com offers 26.7.1 winx64)
dolt
PASS
PASS (Dolt, MySQL wire 8.0.33)
PASS (Dolt, MySQL wire 8.0.33; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1)
databend
PASS
PASS with MySQL Connector/ODBC 26.7.1 (Oracle's macOS arm64 binary, built for iODBC) through a bridge built against iODBC 3.52.16 — MySQL (via ODBC) 8.0.90-v1.2.881; FAIL through MariaDB Connector/ODBC 3.2.9 at its connect-time probe SELECT 1 FROM DUAL WHERE @@sql_mode LIKE '%ansi_quotes%' (Unknown table "default"."default".DUAL), identical through pyodbc
PASS (MySQL (via ODBC) 8.0.90-v1.2.881; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1, ADBC_BENCH_AUTOCOMMIT=1 for the harness rows) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference)
percona
PASS
PASS (Percona Server, MySQL wire 8.4.11)
PASS (Percona Server 8.4.11-11; MySQL Connector/ODBC 26.7.1, default connection string)
matrixone
PASS
PASS (MatrixOne, MySQL wire 8.0.30)
PASS (MatrixOne v4.2.0, MySQL wire 8.0.30; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference)
doris
PASS
PASS with MySQL Connector/ODBC 26.7.1 (Oracle's macOS arm64 binary, built for iODBC) through a bridge built against iODBC 3.52.16 — MySQL (via ODBC) 5.7.99, 300/2,000 rows as on Linux; FAIL through MariaDB Connector/ODBC 3.2.9: the server NPEs on its server-side prepared INSERT and rejects the _binary '<raw bytes>' literal its PREPONCLIENT=1 path inlines
PASS (Apache Doris, MySQL wire 5.7.99, FE + BE in one container, ~6 min to Alive; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference) — restarting a stopped Doris container fails (boot failed! there; on Linux the FE loops wait catalog to be ready instead, because it recorded its old container IP), recreate it
oceanbase
PASS
PASS (OceanBase CE, MySQL wire 5.7.25) — MODE=SLIM, boots in 40 s, 4.3 GiB peak
PASS (OceanBase CE, MySQL wire 5.7.25, MODE=SLIM, boots in ~60 s under a 6 GB cap; MySQL Connector/ODBC 26.7.1, user root@test) — needs NO_SSPS=1 on Windows: the entry's connection string carries no {no_ssps}, and without it every bound parameter fails No data supplied for parameters in prepared statement (pyodbc identical); run with an OCEANBASE_CONN override that adds it — a one-token patch to the entry
greptimedb
PASS
PASS with MySQL Connector/ODBC 26.7.1 (Oracle's macOS arm64 binary, built for iODBC) through a bridge built against iODBC 3.52.16 — MySQL (via ODBC) 8.4.2; FAIL through MariaDB Connector/ODBC 3.2.9 at the same DUAL probe (Table not found: greptime.public.dual), identical through pyodbc
PASS (GreptimeDB 1.1.4, MySQL wire 8.4.2; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference)
starrocks
PASS
PASS with MySQL Connector/ODBC 26.7.1 (Oracle's macOS arm64 binary, built for iODBC) through a bridge built against iODBC 3.52.16 — MySQL (via ODBC) 8.0.33, 300/2,000 rows; FAIL through MariaDB Connector/ODBC 3.2.9: syntax error at the connector's inlined _binary '' literal, with and without PREPONCLIENT=1
PASS (StarRocks, MySQL wire 8.0.33, allin1-ubuntu; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference)
mongodbbi
PASS
PASS (BI Connector, MySQL wire 5.7.12; read-only) — no macOS build of mongosqld 2.14.x exists, the linux-arm64 build runs inside the mongo:7 arm64 container
PASS (BI Connector mongosqld v2.14.22 over mongo:7, MySQL wire 5.7.12; read-only; MySQL Connector/ODBC 26.7.1 + NO_SSPS=1; libssl 1.1 staged per the README) — with MySQL Connector/ODBC 26.7.1 the astral check passes; through 8.4.0 (the earlier Windows measurement) the same entry FAILED at that check only: héllo 🚀 stored byte-exact, read back as héllo ??? on every read path including pyodbc, because 8.4.0 substitutes ? for a non-BMP character before either C type sees it (SQL_C_CHAR and SQL_C_WCHAR both return 3f 3f 3f; CHARSET= makes no difference)
db2
PASS
PASS (Db2 12.01.0500, IBM macarm64 clidriver; server amd64 emulated)
PASS (Db2 12.01.0500 in the ibmcom/db2 container; IBM clidriver 12.1.4 from the free "ODBC and CLI" zip, registered by hand as the short alias IBM DB2 ODBC DRIVER → clidriver\bin\db2cli64.dll — the 78-character name db2cli install -setup writes gets IM002 from the Windows driver manager, and the db2clio.dll it points at is not in that package). Authentication=SERVER must be in the connection string on Windows: the default SERVER_ENCRYPT negotiation fails SQL1042C (sqlerrp SQLEXSLC, rc 205, the client-side security-plugin step) from every process but db2cli.exe, which carries its own gsk8/ICC manifests — python and PowerShell P/Invoke fail identically, whatever DB2CODEPAGE, cwd or environment. The whole workload passes with that one keyword; fetch 398,715 rows/s, array ingest 81,651
informix
PASS
PASS (IDS 12.10, same arm64 clidriver; server amd64 emulated)
driver unavailable: vertica.com's macOS download is vsql only, no ODBC
PASS (Vertica Database (via ODBC) 25.03.0000, opentext/vertica-k8s:25.3.0-8-minimal bootstrapped with fixtures/setup_vertica.sh; Vertica client 25.1.0 MSI, driver name Vertica — the 25.3 client URL 404s and 25.1 drives the 25.3 server). Windows note: run the vcluster create_db step from PowerShell, not Git Bash (path conversion mangles /opt/vertica/bin/vcluster), and --password "" must be passed as an empty argument — PowerShell 5.1 drops it, leaving dbadmin with a non-empty password to reset
PASS (Materialize v26.38.2, PostgreSQL wire 9.5.0; harness rows need ADBC_BENCH_AUTOCOMMIT=1, as everywhere)
opengauss
PASS
server not runnable here: enmotech/opengauss arm64's MOT engine panics at start in the Docker Desktop VM (libnuma / numa_node_of_cpu(0) => -1 / thread identifiers exhausted → Failed to Initialize core services), with --cap-add=SYS_NICE --shm-size=1g and with --cpuset-cpus=0-7
PASS (openGauss 6.0.0, PostgreSQL wire 9.2.4; role and database created inside the container as omm, as the README says)
cratedb
PASS
PASS (CrateDB 6.4.3, PostgreSQL wire 14.0.0) — at 1f35a5c, after the entry accepted psqlodbc 18's decimal128(28, 3)
PASS (QuestDB, PostgreSQL wire 11.3.0; psqlodbc 18.00.0002; ADBC_BENCH_AUTOCOMMIT=1 for the harness rows; shares host port 19000 with the clickhouse service, so the two are never up together)
risingwave
PASS
PASS (RisingWave, PostgreSQL wire 13.14) — compose bind-mount of risingwave.toml refused by Docker Desktop for this account; run with the README's docker run and the toml under /private/tmp
PASS (RisingWave 3.0.3, PostgreSQL wire 13.14; the compose bind-mount of risingwave.toml works as-is from PowerShell)
spanner
PASS
PASS (Spanner emulator + PGAdapter, PostgreSQL wire 14.1; 300/2,000 rows as on Linux)
PASS (Spanner emulator + PGAdapter 0.55.2, PostgreSQL wire 14.1.0; compat only — the Python bench is not run against the emulator, see the Windows benchmark file)
firebird
PASS
driver unavailable: firebird-odbc-driver v3-0-1 ships Linux and Windows assets only
PASS (Firebird 5.0.4 container; Firebird ODBC 3.0.1.18 Firebird ODBC Driver with fbclient.dll from the Firebird 5 zip on PATH) — one bridge fix needed: the driver answers SQL_DRIVER_NAME FirebirdODBC on Windows where Linux sees OdbcFb, so the quirk block that turns parameter arrays off (the driver accepts SQL_ATTR_PARAMSET_SIZE and executes one set) never fired and the first run stopped with ODBC driver accepted a parameter array of 2 sets but reported neither SQL_ATTR_PARAMS_PROCESSED_PTR nor SQL_ATTR_PARAM_STATUS_PTR; matching both names (src/odbc_driver.c) makes the whole workload pass, UNION-ALL bulk form included
virtuoso
PASS
PASS (OpenLink Virtuoso (via ODBC) 07.20.3243) through a bridge built against iODBC 3.52.16, once the virtodbc quirk stopped forcing the narrow path on a four-byte-SQLWCHAR build — there the driver's narrow charset is single-byte (a héllo statement literal never matches, and CHARSET=UTF-8 reads back one byte per unit) while its wide path is correct end to end; Python fetch 252,282 rows/s, ingest 3,128. Through unixODBC it aborts at the first failing statement: the driver is iODBC-width and unixODBC's driver manager overflows its own sqlstate buffer reading the diagnostic (lurcher/unixODBC#239, openlink/virtuoso-opensource#1469)
PASS (OpenLink Virtuoso 07.20.3243 container; the Windows Virtuoso Open Source 7.2 installer's Virtuoso (Open Source) driver, virtodbc.dll)
flightsql
PASS
PASS (sqlflite (via ODBC) 00.00.0000, DuckDB 1.1.1; read-only) through a bridge built against iODBC 3.52.16 — Python fetch 8,260,509 rows/s. Through unixODBC it aborts at the first failing statement: the driver is built to iODBC's 4-byte SQLWCHAR, and unixODBC 2.3.12's driver manager overflows its own stack buffer (SQLWCHAR sqlstate[6] in extract_diag_error_w) reading the wide diagnostic on the first SQL_ERROR → __stack_chk_fail → SIGABRT with no message. Not the driver crashing, not the bridge: unixODBC's isql dies the same way, and through iODBC the same programs get the proper diagnostic
PASS (sqlflite 1.5.5 / DuckDB 1.1.1; arrow-flight-sql-odbc LATEST-win64, 00.09.0007) — with the text_as_binary fix in this tree: the driver's Windows build returns U+1F680 as U+F680 (the low 16 bits) through SQL_C_WCHAR and ? through SQL_C_CHAR (the ANSI code page), pyodbc identical, so the first run failed the astral check; its SQL_C_BINARY conversion of a text column hands the server's UTF-8 through byte-exact (probed with pyodbc), and the reader now takes that route for this driver on Windows. It is also the faster route — 5,211,319 rows/s against 2,956,638 wide, the fastest fetch of the Windows column
PASS (InfluxDB IOx (via ODBC) 02.00.0000; read-only) through a bridge built against iODBC — Python fetch 8,741,131 rows/s. Through unixODBC: the same driver-manager abort as Flight SQL (one driver binary behind all three)
PASS (influxdb:3-core, 100,002 points loaded with fixtures/load_influxdb3.py) — with the same text_as_binary fix as flightsql (the same driver binary; it failed the astral check the same way before it); 6,137,567 rows/s
ignite
PASS
driver unavailable: platforms/cpp has only linux/ and win/ OS layers; the Darwin build stops at sys/sysinfo.h
driver unavailable: the project's macOS pkg is x86_64-only (Intel Macs not targeted)
PASS (OpenSearch 3.8.0, DISABLE_SECURITY_PLUGIN=true; OpenSearch SQL ODBC Driver 1.5.0.0 — the release's opensearch-sql-odbc-artifacts.tar.gz holds a 64-bit Windows MSI; read-only entry, fixtures from load_opensearch.py)
ydb
PASS
PASS with psqlodbc 16.00.0005 built from REL-16_00_0005 (YDB, PostgreSQL wire 14.0.5); FAIL with psqlodbc 18.00.0002: psqlodbc 18.00.0002 sends SHOW DateStyle; at connect (connection.c:1109) and YDB's PostgreSQL layer answers unrecognized configuration parameter "datestyle" — a driver-version incompatibility; psqlodbc 16 (Linux) does not issue it
PASS (ydbplatform/local-ydb, PostgreSQL wire 14.0.5; psqlodbc 16.00.0007 PostgreSQL Unicode 16(x64) as on Linux, admin-extracted from psqlodbc16_x64.msi since 18 and 16 cannot coexist through the installer; adbcuser + GRANT ALL provisioned per the README after every container recreate) — the first run hung for 7 min inside the workload while the container's own health check sat at OVERLOADED (.sys_health/test "path exists but creating right now"); recreated fresh, it passes in 4 s
dremio
PASS
PASS (Dremio Server (via ODBC) 26.00.0005; read-only) through a bridge built against iODBC 3.52.16 — Python fetch 1,341,476 rows/s. Through unixODBC: the same driver-manager abort as Flight SQL (the macOS arm64 build is the iODBC-width one, dremio/warpdrive#16; the Linux x86_64 build is 2-byte SQLWCHAR and returns its diagnostics normally under unixODBC 2.3.12)
PASS (Dremio 26.0.0005 through the same Arrow Flight SQL ODBC Windows build) — with the same text_as_binary fix as flightsql (it failed the astral check the same way, U+1F680 → U+F680, before it); 1,002,050 rows/s. Admin user created over PUT /apiv2/bootstrap/firstuser; the first compat run right after boot hit a $scratch metadata race (Object 'adbc_t' not found immediately after CTAS) and passed on rerun
tdengine
PASS
PASS (3.3.6.13, vendor arm64 client)
FAIL, driver-side, the NCHAR column only (TDengine 3.3.6.13 server; taos_odbc from the Windows client package 3.4.2.5, TDengine driver). Two things had to change to connect at all: the 3.4.2.5 client cannot speak the native protocol to a 3.3.6 server (TCP connects, the server drops it with read invalid packet; not IPv6), so the entry runs over websocket through taosadapter — URL={ws://root:[email protected]:16041} with port 6041 published by a compose override — which reaches the server and passes every column but s. That column: taos_odbc takes the client character set from GetACP() (1252) and ignores CHARSET_FOR_COL_BIND/CHARSET_ENCODER_FOR_COL_BIND and a UCRT setlocale(".UTF-8") alike, so reading héllo 🚀 fails [iconv] Character set conversion for UTF-32LE to CP1252 failed for SQL_C_CHAR and SQL_C_WCHAR (pyodbc identical), and a SQL_C_BINARY read shows the value was already stored double-encoded on the way in. Only a UTF-8 system code page changes that. Fetch of the ASCII fixture works: 298,618 rows/s
access
PASS
PASS (mdbtools 1.0.1 from source, one local patch: fakeglib g_strsplit)
PASS (Microsoft Access Database Engine 2016 x64, Microsoft Access Driver (*.mdb, *.accdb) = ACEODBC.DLL, reports ACCESS (via ODBC) 04.00.0000; the checked-in access.mdb fixture, so the entry's MDB-Tools-shaped workload — read-only, no parameters, no astral check — is what ran; the first real Jet/ACE measurement of this entry)
singlestore
PASS
PASS (MySQL (via ODBC) 05.07.000032, @@memsql_version 9.1.1; MariaDB Connector/ODBC 3.2.9 built from source against unixODBC 2.3.12 — MySQL's own connector is iODBC-only on macOS; server amd64-emulated, singlestoredb-dev has no arm64 manifest; fetch ~55k rows/s, ingest ~116k rows/s warm)
PASS (MySQL (via ODBC) 5.7.32, @@memsql_version 9.1.1; MySQL Connector/ODBC 26.7.0 myodbc26w.dll, the entry verbatim with NO_SSPS=1 from the placeholder; singlestoredb-dev runs native amd64 under Docker Desktop, healthy in ~15 s; fetch 720–780k rows/s, ingest 61k rows/s on the first run and 107k warm — SingleStore compiles each query shape on first sight, as on Linux)
hana
PASS
PASS (HDB (via ODBC) 02.00.0088, SAP HANA client 2.29.25 arm64 libodbcHDB.dylib, against the Linux-hosted server over one LAN hop, connected straight to the tenant SQL port — with DATABASENAME set, HANA redirects the client to the container's internal address, which only the Docker host can reach — so the numbers are network-bound: fetch 43.5k rows/s, ingest 20–26k rows/s with parameter arrays and 62 rows/s one row per execute, one commit round trip each; pyodbc within 10% of the bridge on fetch)
PASS (HDB (via ODBC) 02.00.0088, SAP HANA client 2.29.25 libodbcHDB.dll registered as HDBODBC — the name carries the libodbchdb key, so the HANA quirks apply on Windows unchanged; saplabs/hanaexpress under Docker Desktop, 12 GB, ~16 min to Startup finished!; connected straight to the tenant SQL port 39041 — published through a socat sidecar, since the compose service publishes 39013/39017 only, and with DATABASENAME set HANA redirects the client to the container's internal address, which a Windows host cannot reach; fetch 1.44–1.48M rows/s, ingest 364–398k rows/s with parameter arrays and 1.7k rows/s one row per execute)
exasol
PASS
PASS (EXASolution (via ODBC) 2025.01.0014, Exasol ODBC 26.2.8 universal, the unixODBC variant, against the Linux-hosted server over one LAN hop, so the numbers are network-bound: fetch 229–270k rows/s, ingest 6.6–7.3k rows/s, pyodbc within noise of the bridge on both; the entry ran verbatim with only EXAHOST changed)
PASS (EXASolution (via ODBC) 2025.01.0014, Exasol ODBC 26.2.8 EXAODBC.dll registered as EXASolution Driver — installed machine-wide, because Windows' driver manager reads drivers from HKLM only and answers a DLL path in DRIVER= with IM002; exasol/docker-db under Docker Desktop, privileged, 6 GB; the entry verbatim, the three Exasol quirks apply; fetch 730–750k rows/s, ingest 40–42k rows/s with pyodbc at 105k on ingest, the same shape as Linux)
altibase
PASS
driver unavailable: Altibase publishes no macOS client (its supported-platforms list is Linux, AIX, HP-UX and a Windows-only client)
driver unavailable: Altibase's Windows ODBC client "is provided only up to Altibase version 6.5.1, and not provided to Altibase version 7 or later" (its Windows ODBC development guide), so nothing drives a 7.x server; the downloads sit behind the vendor portal in any case
kinetica
PASS (read)
driver unavailable: the server image ships Linux and Windows ODBC clients only (/opt/gpudb/downloads)
FAIL on this tree — the Windows driver (KineticaODBC.dll 7.1.9.16, windows-odbc-client.zip from the server image, registered as Kinetica ODBC 7.1) exports the W entry points but transcodes W statement text through the ANSI code page inside itself: the literal 'héllo 🚀' lands as cp1252 bytes and every later read of that row fails HY000 (50311) "Error converting invalid input with source encoding UTF-8 using ICU", identical through pyodbc; its narrow statement path hands UTF-8 through byte for byte and its wide fetch is correct (its narrow fetch is not, so the Ignite treatment does not fit). With narrow_sql set in the Kinetica quirk block on Windows the entry passes — Kinetica (via ODBC) 7.1.9.33, fetch 450–480k rows/s — a proposed bridge fix, not yet in this tree. Any Windows host also needs enable_worker_http_servers = false in the server's gpudb.conf: the driver's multi-head inserter dials the container-internal worker URLs the server advertises
ingres
PASS
driver unavailable: Actian Client for macOS is x86_64 only, no arm64 build
FAIL (astral check): Actian Client 1.5.0 for Windows (CAIIOD35.DLL 3.50.1211, registered as Ingres, from esd.actian.com behind a login, setup.exe with a response file — a bare msi installs no driver) negotiates the server's ISO88591 with Ingres 10.1 — declaring UTF8 is refused, E_GC2462 — and substitutes U+1F680 with ? on every route, literal, wide or narrow parameter, pyodbc identical; the Linux driver passes the UTF-8 bytes through, this one honours the negotiated set. On this tree the failure surfaces earlier as a session abort on the two-row array INSERT (08S01 … The connection to the server has been aborted, server E_CLFE07_BS_READ_ERR), because the bridge's iiodbcdriver key does not match the Windows driver name and the Windows tail leaves the wide path on: extending the key to caiiod35 and exempting it from that tail turns the abort into the honest AssertionError: héllo ? — a proposed one-line fix, not in this tree. Client setup a Windows host needs before any of that: a vnode (netutil) with encryption_mode off (the 1.5 client defaults Net encryption on, the 10.1 server has no mechanism, E_GC1004), character_set ISO88591, and II_TIMEZONE_NAME=GMT in the environment (the installer writes America-New_York, which its own config.dat cannot map)
ibmi
PASS
PASS (DB2/400 SQL (via ODBC) 07.05.0015; IBM i Access ODBC for macOS 1.1.0.29, libcwbodbc.dylib 07.01.029, arm64 slice, installed to /Library/IBMiAccess by an administrator — the package has no unprivileged install and the driver aborts on sign-on without its message catalogues; PUB400.COM over the plain host-server ports, SSL=0, because this driver's TLS path segfaults inside SQLDriverConnect whenever pyarrow's libraries are loaded in the same process, bisected to that combination alone, see UPSTREAM.md; the Driver= value must stay within 35 characters here too)
driver unavailable: the IBM i Access Client Solutions Windows Application Package — the only carrier of cwbodbc.dll — is behind an IBM export-control review of the downloading IBM ID, still pending; the entry itself needs nothing else and runs the moment the package lands
Older releases of verified engines
Older releases of engines already in the Verified table, each run with the stock matrix
entry and only the port changed. They add no database to the count; a release is listed
here only when the entry passed as it is.
Engine and release
Driver
Linux
macOS arm64
Notes
SQL Server 2017 (14.0.3540)
msodbcsql 18.6
PASS
not run
same NVARCHAR(MAX) chunked read and SQL_GD_BLOCK-only behaviour as the 2022 row; fetch 2.27M rows/s, ingest 101k rows/s
SQL Server 2019 (15.0.4480)
msodbcsql 18.6
PASS
not run
as 2017; fetch 2.43M rows/s, ingest 106k rows/s
PostgreSQL 9.6.24
psqlodbc 16 (Linux), 18 (macOS)
PASS
PASS
the unnest array-ingest fast path applies unchanged; Linux fetch 1.58M rows/s, ingest 511k; macOS fetch 993k, ingest 555k
PostgreSQL 10.21
psqlodbc 16 (Linux), 18 (macOS)
PASS
PASS
as 9.6; Linux fetch 1.70M, ingest 535k; macOS fetch 1.00M, ingest 423k
PostgreSQL 12.22
psqlodbc 16 (Linux), 18 (macOS)
PASS
PASS
as 9.6; Linux fetch 2.13M, ingest 546k; macOS fetch 1.17M, ingest 640k
Compatibility matrix: 53 databases verified on Linux by one workload (docs/COMPATIBILITY.md)
done
Clients measured against all 53: Python, Rust, Go, Java, C# (bench/LANGUAGE_BENCHMARKS.md — 261 of 265 Linux cells); R smoke-tested
done
Array-bound and multi-row bulk ingest, probed per driver, default on
done
Plug-and-play install: install.sh (Linux, macOS), driver manifest and driver="odbc" by name (Linux, macOS, Windows — there cmake --install writes the manifest)
done
macOS verified on a real machine: SQLite, PostgreSQL 15, SQL Server 2022 (bench/BENCHMARKS-macos.md)
done
Windows: first build 2026-08-24 (ten defects across driver, tests and harnesses, three of them the ANSI code page in three disguises); compat campaign on an 8 GB laptop, then the full 46-entry re-measure on a 32 GB machine on 2026-08-25 (45 pass; MySQL Connector/ODBC 26.7.1 retires the astral class) — bench/BENCHMARKS-windows.md
done
macOS: 53 of 53 results (45 pass, 8 driver/server unavailable); a bridge built against iODBC is a supported configuration for vendor drivers that only ship iODBC builds (MySQL Connector/ODBC for macOS) — three width bugs found and fixed on the way (docs/TROUBLESHOOTING.md)
done
2026-09-03 batch: SingleStore, SAP HANA Express, Exasol, Altibase, Kinetica, Actian Ingres and IBM Db2 for i (PUB400.COM) verified on Linux — 53 entries; macOS and Windows cells measured the same week (45 and 48 pass; every other cell names its reason in docs/COMPATIBILITY.md)
done
Windows: per-connection narrow-text route for ANSI-only drivers whose narrow path is UTF-8 (narrow_sql + narrow_params + wchar_as_utf8, keyed on the driver, no global state) — Apache Ignite passes on Windows, measured on the second Windows machine
done
Windows, to verify on the next Windows session (reported by the second Windows campaign, not reproduced on a machine still available): (1) whether cmake --install under MSVC can write the manifest key windows_amd64_mingw (CMake's MINGW is only set for a GNU toolchain; the campaign's own build log recorded windows_amd64); (2) the Go binding's SQLite test leaves the database file open after Close so t.TempDir() cleanup fails — the drivermgr Close calls AdbcConnectionRelease/AdbcDatabaseRelease synchronously, so the open handle is either a driver-side pool or a missing release in the test's reader path
next
Connection-level reader options (adbc.odbc.batch_size, sqllen_32bit) set before AdbcConnectionInit are discarded — OdbcConnectionInit copies the database defaults over them; re-apply the recorded pre-options after the copy
next
Windows: prefetch pipeline and parallel ingest — both pthreads, compiled out on _WIN32; a Win32 shim (SRWLOCK, CONDITION_VARIABLE, _beginthreadex) restores them
next
Driver bootstrap: install.sh / the Windows and macOS installers fetch the open-licence ODBC drivers a first run needs (sqliteodbc, psqlodbc, MariaDB Connector/ODBC, clickhouse-odbc) so SQLite/PostgreSQL/MySQL work with nothing else installed; vendor drivers (Oracle, Db2, SQL Server, Snowflake…) stay the user's download — their licences do not allow redistribution, and Windows already ships the SQL Server driver
next
ADBC Driver Foundry validation suite (tests/validation/RESULTS.md): PostgreSQL 293/327 pass with the other 34 declared backend limitations (2026-09-15; was 265 on 2026-09-06), SQLite 212/327 with the remainder being precision SQLiteODBC does not report; baseline findings D1–D9, D11, D14 and the PostgreSQL findings P1–P4 (TIME/TIMESTAMP precision and zone fidelity), P7 (db_schema option) and P8 (schema catalog fill) fixed; tests/test_postgres.py pins them in CI
done — released in 0.1.2 (2026-09-15)
Trust artefacts: security policy with private vulnerability reporting and a stated support window (SECURITY.md); Dependabot on every manifest; SHA256SUMS and its signature (SHA256SUMS.asc) on v0.1.0; every later release signed, attested (GitHub build provenance, gh attestation verify) and shipped with an SPDX SBOM by the release workflow
done
Foundry listing (adbc-drivers.org)
next
Release workflow: Linux x86_64/aarch64 (manylinux_2_28), macOS arm64 and Windows x64 libraries + Python wheels built on tag, attached to the GitHub Release (.github/workflows/release.yml)
done — v0.1.0 on 2026-08-25: 4 libraries, 4 wheels + sdist, crate, nupkg, jar
PyPI via trusted publishing (.github/workflows/publish-pypi.yml, dispatched by hand with the tag once the Release workflow has published it); NuGet the same way (publish-nuget.yml); crates.io from a checkout of the tag
done (0.1.0 on PyPI, crates.io and nuget.org, 2026-08-26)
Language packages: Python wheel (python/), Rust crate (rust/), NuGet (csharp/), Maven (java/), Go module (go/) — built and attached to every release (the crate tested there, the others by their own suites under tests/); PyPI, crates.io, nuget.org and Maven Central (org.adbcbridge:adbcbridge, published 2026-09-04 through the Central Portal, java/PUBLISHING.md) carry 0.1.0
done
Databases that need vendor downloads behind a login: Teradata, Snowflake, Databricks/Spark (Simba), Hive
help wanted
2. JDBC bridge (libadbc_driver_jdbc) — next
Same idea for the JDBC universe: load a JVM in-process (JNI), drive any JDBC driver, and
return Arrow batches through the ADBC ABI — so Python, R, Go, Rust and C# can use JDBC-only
drivers without a Java application. Apache Arrow already has a Java-side JDBC adapter; this
is the C-side one.
3. OLE DB bridge — later
Windows-only; covers the few remaining sources that have OLE DB providers but no ODBC driver.
Non-goals
Replacing native ADBC drivers where they exist (PostgreSQL, Snowflake, BigQuery, …): those
will always be faster. adbcBridge is for everything else, and for the long tail of enterprise
databases that will never get a native driver.
Upstream: what this project found, and gave back
Driving 53 databases through one driver across three operating systems turns up defects
that belong to other projects. This file is the record: what was found, where it was
reported, and what is documented here but not yet filed. Each reported entry stands on
evidence that anyone can reproduce without adbcBridge in the stack; the not-yet-reported
table records what the compatibility run observed, and each item is verified the same way
before it is filed.
PostgreSQL wire, simple query protocol: BEGIN, COMMIT, ROLLBACK and the savepoint statements are answered with a zero-field RowDescription before the CommandComplete, so libpq reports PGRES_TUPLES_OK instead of PGRES_COMMAND_OK; psql prints an empty table after BEGIN and the Arrow PostgreSQL ADBC driver, whose DB-API starts with autocommit off, fails [libpq] Failed to begin transaction:. psqlodbc ignores the status, so adbcBridge was never affected. Sent as a fix with a wire-level test
fixed on main — our fix PR was merged by the maintainer on 2026-09-17, the day it was opened (merge 7ed83075); found on 26.10.1-SNAPSHOT while re-checking #7188; the new test fails before the fix, and the maintainer re-ran the PostgreSQL module's integration suite on it (244 tests, 0 failures) and extended the fix so a prefixed {sql}BEGIN gets its command tag too. That savepoint statements are accepted without being implemented is tracked separately as ArcadeData/arcadedb#7846. Expected in 26.10.1
Parameter arrays on 1.5.5 and on main: with SQL_ATTR_PARAMSET_SIZE above 1, every fixed-length parameter receives set 0's value in every row under SQL_SUCCESS with the right processed count; only character and binary parameters step (the fixed-length branches read from the start of the bound buffer). Sent as a fix: each set reads its own element, with a test over six types and a NULL set
merged 2026-09-17 (ours) — approved ("Thanks for the PR! Looks good to me.") and merged by a DuckDB maintainer the day it was opened (merge 1e065eaf), green on Linux, macOS and Windows on both architectures; reproduced on the 1.5.5 release and on main, verified on a build of the fix, where the driver's whole test suite passes. Ships in the first duckdb-odbc release after v1.5.5.0; adbcBridge keeps binding row by row for DuckDB until then
SQLGetInfo(SQL_DBMS_VER) on 1.5.5: under unixODBC the process segfaults (the driver's internal SQLAllocHandle call binds to the driver manager, fails, and the error path frees an uninitialized handle); linked directly it returns SQL_SUCCESS, the right length and an empty string (read into one buffer, returned from another). Sent as a fix: the version comes straight from the engine, with a test against pragma_version()
merged 2026-09-17 (ours) — approved ("Thanks for the PR! It looks good to me.") and merged by a DuckDB maintainer the day it was opened, green on Linux, macOS and Windows; reproduced on the 1.5.5 release and verified on a build of the fix, where the driver's whole test suite passes. Ships in the first duckdb-odbc release after v1.5.5.0
Documentation contribution: an ADBC-over-ODBC entry for the I/O guide's ADBC section, showing read_sql through adbcBridge. Opened 2026-09-05
closed 2026-09-15 by a maintainer: that page is for long-established, common usages, which a driver released three weeks earlier is not yet; acknowledged on the PR, to be revisited with usage numbers in a year
A fourth --sql-backend, adbcbridge, for omniload's SQL sources: dlt's registered table-loader hook, SQLAlchemy reflection and query compilation kept, Arrow batches read through the database's ODBC driver via adbcBridge; the ODBC connection string derived from the source URI for PostgreSQL, SQL Server, CrateDB and MySQL, --sql-odbc-uri for the rest; tests on PostgreSQL 18, CrateDB 5.10.11 and SQL Server 2025/2022, a handbook page and changelog entry. Contribution rather than a report: the first integration requested from outside
merged 2026-09-17 — approved by both maintainers (2026-09-15 and 2026-09-17), the second review's five notes addressed on the branch the same day, merged by the maintainer as cb49538 ("Perfect. Thank you so much!"); adbcBridge is now the fourth --sql-backend in omniload's codebase, pinned at 0.1.3 or later, and shipped the same day in omniload v0.17.0 ("Your contribution was included in v0.17.0, thank you again")
The driver manager overwrites its own stack and heap, and the process aborts, on the first SQL_ERROR from a driver whose SQLWCHAR is 4 bytes (extract_diag_error_w, SQLWCHAR sqlstate[6]). Reproduces on 2.3.12 and 2.3.14. Filed with a driver-independent reproduction: a 40-line fake driver compiled with SQL_WCHART_CONVERT triggers it on Linux, its 2-byte twin does not; plus lldb frames from two real macOS drivers and a fix suggestion. Same crash as the earlier #227 (Informix), which had been closed without a reproduction.
closed (not planned) by the maintainer on 2026-08-25, who then committed a mismatch check the same day (a2acae7: doubled buffers, sentinel test, message before abort) — the crash is now caught rather than silent; a follow-up with a test table and a patch (report the mismatch as a diagnostic instead of aborting; prime the sentinel on the ODBC 2 path) was posted on the closed issue
The macOS driver (Homebrew 7.2.17) is built to iODBC's 4-byte SQLWCHAR and nothing says so; through unixODBC every application dies on its first SQL error. Asks for a unixODBC-width build or a documented iODBC-only statement; includes the UTF-8 statement-literal question.
The Apple Silicon build 0.9.7 is iODBC-width, undocumented, and aborts under unixODBC on any SQL error (three servers); LogEnabled=true makes SQLAllocHandle(ENV) fail with IM004 (an uncaught exception in the logger); the pkg ships arrow-odbc.ini.orig but no arrow-odbc.ini; the docs contradict themselves on Apple Silicon support.
Column-wise parameter arrays step fixed-length C types (SQL_C_SLONG, SQL_C_SBIGINT, SQL_C_DOUBLE, dates, timestamps) at the BufferLength stride, which the specification says is ignored for those types; every row receives row 1's values and the NULL indicator lands in the wrong row, with SQL_SUCCESS throughout. SQL_C_CHAR arrays are correct. Filed with three plain-ODBC C programs; reproduces on 3.0.1 and 3.5.0-rc1; row-wise binding works around it.
fix PR #308 by F.D. Castel (2026-09-07): the input side now derives the stride from the C type when BufferLength is 0, as the output side always did, and the array executor is chosen at execute time; verified on Linux/unixODBC/Firebird 5.0.4 with its CI build on 2026-09-07 — both programs from the report store five distinct rows for every C type, NULL in the right row (the release build had also moved a SQL_C_CHAR NULL from row 3 to row 1). adbcBridge keeps no_param_arrays for OdbcFb until a release carries the fix (the maintainer is collecting the rc2 merge order in #312)
Once a NULL has been bound to a SQL_BIGINT parameter with a character C type (SQL_C_DEFAULT or SQL_C_CHAR), every later SQL_C_SBIGINT rebind of that parameter writes NULL; INTEGER with the same pattern is fine. Reproduces on 3.0.1 and 3.5.0-rc1.
open — Confirmed and widened 2026-09-06 by a Firebird community contributor on Windows (Firebird ODBC 3.0.1.21, Firebird 3.0.13): SQL_NUMERIC and SQL_DECIMAL parameters lose the same way, hit through DuckDB's odbc-scanner on a 485k-row NUMERIC(9,3) column; root cause traced in the driver source (SQL_C_DEFAULT resolves to SQL_C_CHAR for BIGINT/NUMERIC/DECIMAL, setTypeText() overwrites the prepared XSQLVAR, and the rebind re-describes the parameter from the mutated var), with a fix proposed (describe INPUT parameters from the prepare-time snapshot) and a regression test shape. adbcBridge is unaffected: NULLs bind SQL_C_SBIGINT for BIGINT and decimals travel as SQL_C_CHAR text, the C type the values use. Fix PR #302 by F.D. Castel (2026-09-06): INPUT parameters are described from the prepare-time snapshot; verified on Linux/unixODBC/Firebird 5.0.4 with its CI build on 2026-09-07 — both C programs from the report correct in every shape, the 24-cell matrix 0 lost against 6 on the release build.
SQLPrepare and SQLExecDirect discard SQL_ATTR_ROWS_FETCHED_PTR and SQL_ATTR_ROW_STATUS_PTR set before them, so a block cursor never learns how many rows a fetch returned; set after them they work. Reproduces on 3.0.1 and 3.5.0-rc1. Confirmed on Windows by F.D. Castel 2026-09-06 (cause: the implementation row descriptor was rebuilt on every prepare and cleared its ROWS_PROCESSED_PTR / ARRAY_STATUS_PTR).
fix PR #303 (two lines plus a regression test on the report's query), verified on Linux/unixODBC/Firebird 5.0.4 with its CI build on 2026-09-07: correct counters in all three attribute orderings
Four "not implemented yet" gaps, each with a plain-ODBC program: SQLBindCol to SQL_C_TYPE_TIMESTAMP (while SQLGetData converts), SQL_TYPE_TIMESTAMP parameters, BOOL parameters from any C type but SQL_C_SBIGINT-as-SQL_TINYINT, and any DECIMAL column failing the whole SELECT. taos-odbc from source at 3.3.6.13.
Server-side prepared INSERT refuses a string parameter sent with MySQL type BLOB — what MySQL Connector/ODBC sends for every SQL_C_WCHAR parameter — so a Unicode ODBC client cannot insert through prepared statements: NullPointerException in the FE on 2.1.0 (stack trace attached), AnalysisException: Unsupported MySQL type: BLOB on 4.1.3. Filed with the program and both versions' output.
fix PR open — apache/doris#67306 by a Doris contributor, "closes #67301"; conflicts resolved at a maintainer's request on 2026-09-11
Two parameter-binding defects on 1.5.5.20260810, each with a standalone C program: SQL_NULL_DATA is ignored whenever a value buffer is bound and an empty string is sent instead (silently '' into String, the epoch into DateTime64, a server parse error for numeric, date, decimal and bool; SQL_DEFAULT_PARAM the same; a NULL value pointer works); and parameter arrays — the driver's own SQLExecute + SQLMoreResults-per-set protocol from #324 — stop after set 1 because SQLMoreResults executes it and answers SQL_NO_DATA, so a 5-set array lands 2 rows with no diagnostic.
fixed on master, both halves — the SQL_NULL_DATA half is fixed by our fix PR ClickHouse/clickhouse-odbc#586, opened 2026-09-18 and merged 2026-09-24 by the maintainer (slabko), merge commit eb0fdb2654: a helper that treats a parameter as NULL when the value pointer is null or the indicator is SQL_NULL_DATA, used at both sites in driver/statement.cpp, with two unit and two integration tests that failed on master. Not in a release: the reproducer was re-run against 1.5.5.20260810 on 2026-09-24 against ClickHouse 26.7.5.10 and still shows all five problems, so the shipped-in line waits for the next tag. The parameter-array half is a separate mechanism (SQLMoreResults reports result sets, not parameter sets) and is fix PR #588 (ours), opened 2026-09-24: SQLExecute now sends every set of a statement that returns no result set, and stops at the first set that does, so a SELECT keeps handing result sets back one per SQLMoreResults. The maintainer's review (2026-09-25) asked for the ODBC error-path contract, added the same day: SQL_ATTR_PARAMS_PROCESSED_PTR counts the failing set and SQL_ATTR_PARAM_STATUS_PTR reads {SUCCESS, SUCCESS, ERROR, UNUSED, UNUSED} on his five-set case, with both cases as tests; verified against ClickHouse 26.7.5.10 on a driver built from the branch, 391 of 392 of the project's own tests passing (the one failure needs CREATE USER and fails the same way on 1.5.5). Approved and merged 2026-09-29 by slabko, merge commit 74027bcf6e; master is exactly the verified branch plus that merge commit, so the branch run stands for master. Neither half is in a release yet (latest tag 1.5.5.20260810), so the matrix keeps parameter arrays off for the shipped driver; both lines become "fixed in " when a tag carries them. The issue stays open for that
Affected-row count, open since 2021: still 0 (not −1) for every write on 1.5.5 against 26.7.5.10; noted that the server already sends written_rows in X-ClickHouse-Summary on every HTTP response by default, which the driver could surface.
fixed 2026-09-15 — ClickHouse/clickhouse-odbc#585 (SQLRowCount → −1, the ODBC "unknown", where 1.5.5 answered 0) was opened and merged the same day, with a second maintainer's approval, and #335 closed as completed after five years. Verified on the PR's CI build and on the merged master build against ClickHouse 26.7.5.10: −1 after a literal INSERT and after a DELETE, the full ClickHouse compatibility entry unchanged. Ships in the first release after v1.5.5.20260810. The maintainer's reasoning, 2026-09-15: 0 was wrong and −1 is the honest value; written_rows from X-ClickHouse-Summary is not usable as an exact count because it is untrusted while async_insert is on (ClickHouse/ClickHouse#57768) and, as he then noted and we confirmed, it also counts rows written to materialized views (3 rows through INSERT … SELECT with two views reported 7), so −1 is the final answer
virtodbc.so (Linux, unixODBC) implements SQL_C_WCHAR as 4-byte wchar_t on both the parameter and the fetch side, not the 2-byte SQLWCHAR the driver manager defines: a UTF-16 hello stores as ??, héllo 🚀 as 0, wide reads widen each UTF-8 byte — all SQL_SUCCESS. The Linux counterpart of #1469.
Any Charset= value but the exact literal UTF-8 aborts the process inside SQLDriverConnect (GPF: Dkbox.c:638 Double free), including every charset Virtuoso itself lists; Charset=UTF-8 connects but returns wide columns doubly encoded with the original byte length in the indicator.
open — the abort sent upstream as a fix, openlink/virtuoso-opensource#1477 (ours, opened 2026-09-18): the cause is a double free, not a compare — con_charset_name aliases the parsed option buffer and two owners release it; the fix gives it a box of its own (libsrc/Wi/CLIsql3.c, +8/−1), after which every charset connects and an unknown one gets the intended CL035 warning. The doubly encoded wide columns under Charset=UTF-8 stay open
SQL_C_SBIGINT parameters store 0 and SQL_C_SBIGINT fetches return indicator 0 with the buffer untouched (only text declared SQL_NUMERIC/SQL_DECIMAL converts exactly); SQL_C_SLONG parameters are read as 8 bytes regardless of BufferLength.
A column-wise SQL_C_TYPE_DATE parameter array is stepped by SQLBindParameter's ColumnSize rather than by sizeof(SQL_DATE_STRUCT): ColumnSize 0 inserts row 0's date into every row, other values insert garbage dates, all SQL_SUCCESS.
sent upstream as a fix, openlink/virtuoso-opensource#1478 (ours, opened 2026-09-18): sqlc_sizeof_1() in libsrc/Wi/CLIuti.c lists the ODBC 2 names SQL_C_DATE/TIME/TIMESTAMP but not the ODBC 3 SQL_C_TYPE_* ones, so those fall to ColumnSize; the fix adds the three names under ODBCVER >= 0x0300 as the file's other switches do, verified on a driver built from develop/7
SQLDescribeCol describes a DATETIME(6) column as COLUMN_SIZE 19 / DECIMAL_DIGITS 0 (values are 26 characters; a buffer sized from the description gets 01004), and SQLColumns reports COLUMN_SIZE NULL for date/datetime; MariaDB Connector/ODBC describes the same column 26/6. Filed on the GitHub mirror (bugs.mysql.com account could not be created). A plain-ODBC probe on 2026-09-04 added the consequence and the root cause for a follow-up: SQLGetTypeInfo reports datetime as COLUMN_SIZE 21 / MAXIMUM_SCALE 0, so a client sizing its bind from the type table (pyodbc) stores 13:45:10.123456 as 13:45:10.1 — byte-identical against MySQL 8.4 and SingleStore 9.1, NO_SSPS=1 or not.
SHOW VARIABLES/SHOW COLLATION against a MySQL-wire server reporting a zero character-set width (MatrixOne 4.2.0) kill the client with SIGFPE in get_column_size() (utility.cc) during SQLExecDirect, before any fetch; the same statement against MySQL 8.4.11 is fine. gdb backtrace included.
Column-wise parameter arrays test the NULL indicator of row 0 for every row: a SQL_NULL_DATA below row 0 stores an empty string (VARCHAR) or segfaults the client inside SQLExecute (BINARY, WriteInt8Array handed the -1 as a length); a NULL in row 0 nulls the whole batch; row-wise binding refused. parameter.cpp cited, fix suggested.
open — fix PR #13610 (ours), opened 2026-09-23: the indicator was read from the un-offset buffer in modules/platforms/cpp/odbc/src/app/parameter.cpp, before the row offset was applied, so it came from a different row than the value; a negative length also reached memcpy in the binary branch. Both offsets are now applied before the indicator is tested, and a negative binary length is written as NULL. Carries a unit test for app::Parameter, which had none, covering the current-row indicator for character, binary and integer parameters, a NULL in row 0, a NULL below row 0 and the negative-length case; it needs no server. The reproducer was re-run against a build with the change and all three rows round-trip. The issue itself has had no reply since it was filed. Ignite asks for an ASF JIRA ticket and an IGNITE-NNNNN title for code changes: rather than invent a number the pull request says so openly, and contributor access has been requested on [email protected] so the ticket can be filed and the title corrected
Any character value converted to SQL_C_TYPE_TIMESTAMP/SQL_C_TYPE_DATE comes back as today's date under SQL_SUCCESS (convert.c fills an unparsed SIMPLE_TIME from localtime); a TIMESTAMP literal takes the same path because the result-set type map lacks timestamp.
open — fix PR #105 (ours), opened 2026-09-24: the character source types go through timestamp2stime() before the output switch and a string it cannot read returns COPY_INVALID_STRING_CONVERSION, which had no SQLSTATE and now maps to 22018. Syntax-checked under GCC with no new diagnostics; not built here (the project's CMake hardcodes a 2019 kernel-headers path and its tests need vcpkg, the AWS SDK and a live cluster), so its CI is the first real run. DCO check green
SQLError/SQLErrorW return the same connection error on every call and never SQL_NO_DATA, so unixODBC's SQLDriverConnect(W), which prefers SQLError for a failed connect's diagnostics, spins forever on any failed connect (closed port, useSSL mismatch, bad auth=); SQLGetDiagRecW is correct, and psqlodbc avoids it by not exporting SQLError.
open — fix PR #104 (ours), opened 2026-09-24: OPENSEARCHAPI_ConnectError now clears the connection error after reporting it, as EN_get_error() already does for the environment, so the ODBC 2 one-shot contract holds and the driver manager's loop reaches SQL_NO_DATA_FOUND; cleared after the last use of the message it points at, and not on the buffer-sizing retry path. Four lines. Same build caveat as #105; DCO check green
A BINARY column in a simple-query result is sent with format code 1 (raw bytes) where PostgreSQL always sends text, so libpq/psycopg and psqlodbc truncate the value at its first 0x00; the extended protocol returns it whole. Side by side with PostgreSQL 16 via PQfformat.
DEALLOCATE ALL fails with Cannot invoke "io.questdb.cutlass.pgwire.PGPipelineEntry.close()" because "pe" is null — even on a fresh autocommit connection with nothing prepared; a named DEALLOCATE works.
fix PR open — questdb/questdb#7568, by a community contributor, reviewed 2026-09-27 by the project's founder (bluestreak01): approve with comments, two changes asked of the author before merge. The review also found, from instrumented runs, that with the fix DEALLOCATE ALLsucceeds but releases nothing — it is parsed as DEALLOCATE "ALL", a lookup for a statement of that name, so prepared statements survive it and re-preparing the same name fails with duplicate statement (his A2, not yet filed; he offered to). That is the part this project cares about, since drivers and pools send DEALLOCATE ALL as a session reset: our report covered the error, not what happens with statements present. Watching for the issue; our reproduction and the pooling impact go on it as the reporter of the parent
A multi-statement simple query returns each CommandComplete tagged with the leading text of the whole string (BEGIN;INSERT 0, BEGIN;INSERT 1) instead of per-statement tags (BEGIN, INSERT 0 1), so tag-parsing clients such as psqlodbc get no row count inside a transaction.
SQLGetData/SQLBindCol narrow an out-of-range int8 to SQL_C_SLONG (and to SQL_C_SSHORT) silently — 9223372036854775807::bigint reads as -1 under SQL_SUCCESS where ODBC specifies 22003; pg_atol/pg_atoi in convert.c store the low bits with no range check. Stock PostgreSQL 16.
fixed — postgresql-interfaces/psqlodbc#210 by Dave Cramer, merged 2026-09-04 (SQLGetData/SQLBindCol now return SQL_ERROR with 22003 for an out-of-range integer conversion instead of the wrapped low bits); verified on the merge commit 4a5a0e7 on 2026-09-04, SQLGetData and SQLBindCol paths, results on the issue. Follow-up postgresql-interfaces/psqlodbc#211, merged 2026-09-08 from the aside in that verification, drops the C-type size from the indicator on the error rows too; verified on d747b18 on 2026-09-10. Fixed in psqlodbc 18.00.0003 (REL-18_00_0003, released 2026-09-10): the release tag built from source and re-run with the program on 2026-09-10, same results on both paths
With Protocol=7.4-0, SQLFreeHandle of a prepared statement inside a transaction still sends SAVEPOINT _per_query_svp_;DEALLOCATE …;RELEASE; on a server without SAVEPOINT (QuestDB, Materialize) the error is swallowed, the server rolls the transaction back and SQLEndTran(SQL_COMMIT) returns SQL_SUCCESS for rows that are gone. Wire capture included.
fixed — postgresql-interfaces/psqlodbc#209 by Dave Cramer, merged 2026-09-02 (rollback level 0 no longer savepoint-wraps the internal DEALLOCATE; the tested branch commit is the merged one); QuestDB confirmed; Materialize's own half, MaterializeInc/materialize#38606, merged 2026-09-14 and verified with this program the same day (see the Materialize row). Fixed in psqlodbc 18.00.0003 (REL-18_00_0003, released 2026-09-10): release tag built from source and re-run with the program on 2026-09-10, QuestDB keeps both rows when the statement is freed before the commit (stock 16.00 loses both), PostgreSQL 16.15 unchanged
DEALLOCATE "Name" does not unquote the identifier, so a statement prepared at the protocol level (Parse) with a mixed-case name — psqlodbc's _PLAN0x… — cannot be released: 26000 prepared statement "\"_PLAN0x7\"" does not exist, and inside a transaction the failure aborts it; PostgreSQL 16 releases the same statement. Filed with a psycopg side-by-side against PostgreSQL 16. The server half of the psqlodbc#208 row-loss pair.
fixed on main — MaterializeInc/materialize#38606 by antiguru (Materialize), merged 2026-09-14 (9adca52d); unquotes prepared-statement, portal and cursor names before lookup. Verified the same day on the v26.43.0-dev.0--main.g9adca52d image with the #208 program and psqlodbc 18.00.0004, Protocol=7.4-0: a statement freed before the commit keeps both rows (2/2), where the Aug 19 latest image loses both with the same driver and the stock 16.00 driver loses both on either server; results posted on the PR. Not in a Materialize release yet
SQLGetTypeInfo fails 42S22 Unknown column 'json' in 'field list' (1054) whenever sql_mode contains ANSI_QUOTES: the type-info result set is built from a template with double-quoted string literals (ma_info.c), which that mode turns into identifiers. SQLTables/SQLColumns/queries unaffected; MySQL Connector/ODBC fine on the same server.
fixed — fix PR #48 (ours), opened 2026-09-17, approved and merged 2026-09-23 by the maintainer (Pavlo Mishchenko), merge commit 182ae5b515, closing the issue the same minute: single-quoted literals in the template, with a test under ANSI_QUOTES. Verified against SingleStore 9.1.1, and the driver was rebuilt from the merged branch here on 2026-09-23 with the whole info suite green in both the client-side and the server-side prepared-statement modes. Review asked for the test to run on its own connection, following the project's odbc143, and to restore sql_mode before disconnecting; both landed in the second commit. Not yet in a release: master is still 1.2.2, so the shipped-in line waits for the next tag
The Arrow native PostgreSQL ADBC driver cannot connect to ArcadeDB's PostgreSQL-protocol plugin: its type-resolver bootstrap SELECT oid, typname, typreceive, typbasetype, typrelid, typarray FROM pg_catalog.pg_type WHERE … is answered with zero columns (Expected 5 or 6 columns from type resolver pg_type query but got 0), because the emulated pg_type knows neither typreceive nor typsend and its WHERE parser accepts only a single equality. Found by the 2026-09-05 native-driver probe; the maintainer's own diagnosis is in the issue.
fixed on main: ArcadeData/arcadedb#7179, opened by the maintainer the same day and merged 2026-09-06 (558e6042); ships in ArcadeDB 26.10.1 (October 2026, per the maintainer) and is in 26.10.1-SNAPSHOT now. Verified on the PR's CI build with the fixture types present: the native driver connects and GetObjects lists the types; with the driver's defaults it then stops at the first result set because ArcadeDB has no COPY (…) TO STDOUT (FORMAT binary) (the CockroachDB, CrateDB and YDB stop); with adbc.postgresql.use_copy=false reads work end to end and only GetTableSchema (its $1::regclass::oid lookup) returns an empty schema. Follow-up issue #7180 carried the driver's exact statements; its GetTableSchema item (the ::regclass cast) is fixed in #7187, merged 2026-09-06 (ec871770), verified on its CI build: five and eight fields with the right Arrow types. Binary COPY was tracked as #7188, implemented in #7398 on 2026-09-10 and framed like PostgreSQL's in #7607 on 2026-09-15; with all three, the native driver passes the whole probe with its defaults on 26.10.1-SNAPSHOT.
COPY (<query>) TO STDOUT, text and binary, is not supported by the PostgreSQL-protocol plugin; the Arrow native PostgreSQL ADBC driver reads every result through binary COPY by default, so with defaults it stops at the first result set. The driver's adbc.postgresql.use_copy=false statement option reads through ordinary binary-format results and works end to end on ArcadeDB, which the issue records.
fixed on main: ArcadeData/arcadedb#7398 (merged 2026-09-10) implemented COPY (<query>) TO STDOUT in text, csv and binary on both protocol paths; the one framing detail left between it and the Arrow driver's default path (the binary header sent as its own CopyData message, reported with both servers' chunk dumps on 2026-09-12) was fixed by the maintainer in ArcadeData/arcadedb#7607, merged 2026-09-15 (3bdb3451b). Verified the same morning on 26.10.1-SNAPSHOT build 99f4cb48: the Arrow PostgreSQL ADBC driver 1.12.0 with its defaults passes all seven probe steps and decodes real rows (int64, string, double, date32, bool) through binary COPY, no use_copy=false needed. The maintainer closed the issue on 2026-09-15; re-verified the same day past the probe — an empty result set (header and trailer with no rows, the case the fix names), one row, 10,000 rows in one COPY, a NULL in every column, non-BMP text, GetTableSchema and GetObjects — all correct with the driver's defaults. Ships in ArcadeDB 26.10.1
SQLColumns with a NULL CatalogName segfaults the client on a connection opened without Database=: TABLE_CAT falls back to the client library's current-database field, which stays NULL after USE, and strdup(NULL) is called (ma_statement.c). Zero matching rows, an explicit catalog, or Database= in the connection string avoid it.
fixed — fix PR #47 (ours), opened 2026-09-17, approved and merged 2026-09-23 by the maintainer (Pavlo Mishchenko), merge commit e760503629, closing the issue the same minute and with no changes requested: TABLE_CAT NULL instead of strdup(NULL) in SQLColumns and SQLPrimaryKeys, with a test in test/catalog2.c for the connection shape that triggered it. Verified against SingleStore 9.1.1. Not yet in a release: master is still 1.2.2, so the shipped-in line waits for the next tag
A DATETIME_MICROS (also DATETIME_NANOS, DATETIME) value written as a string with a space separator and a fractional second — '2024-02-29 13:45:10.123456', exactly the form psqlodbc renders a bound timestamp in — is stored as NULL while the INSERT reports success. Either half alone is fine: the T separator round-trips, and the space form without a fraction round-trips. Carried into the project's own tracker by a maintainer after we documented it in the compatibility notes, with the root cause named: a blanket catch in Type.convert() logs at FINE and returns null. Every PostgreSQL-wire client that binds a timestamp against a sub-second property is affected, not only this driver
fixed in #8096, merged 2026-09-22 by the project (Luca Garulli), who had filed the issue on 2026-09-21 as severity:critical, crediting the report and naming the root cause. The space-and-fraction form is now accepted, and a value no parser can read fails the write with an error naming it instead of storing NULL; reads stay lenient and return NULL. Verified here on 26.10.1-SNAPSHOT (build 382ae09d, 60 commits past the merge) on 2026-09-22: the literal and the bound parameter both round-trip to the microsecond, an unreadable value raises SQLSTATE 22023 through psqlodbc, and the column now reaches Arrow as timestamp[us] rather than a string. Awaiting the 26.10.1 release. ArcadeDB published an account of this and the three earlier findings on 2026-09-21: arcadedb.com
Documented here, not yet reported
Each of these is recorded with its first error and the conditions in
COMPATIBILITY.md and the per-OS benchmark files; reports follow as
time allows, and a reproduction contributed by anyone is welcome.
Project
Finding
Where recorded
psqlodbc (16 and 18)
Its SQL_C_CHAR conversion of a timestamptz column renders the session's wall-clock time and drops the offset the server sent (2024-02-29 08:45:10.123 for 08:45:10.123-05), and a bound SQL_TYPE_TIMESTAMP parameter is sent as a zone-less literal the server reads in the session zone; a client that wants instants must therefore run its sessions on UTC. Found 2026-09-15 on a PostgreSQL 16 cluster set to America/New_York; adbcBridge 0.1.3 runs SET TIME ZONE 'UTC' at connect (adbc.odbc.utc_session). Candidate for a psqlodbc report once the driver's intended behaviour for SQL_C_CHAR on timestamptz is checked against its USE_ZONE build option
A parameter array with a NULL DATE segfaults the connector; the array path also misreports MySQL's row count. adbcBridge routes those versions row by row (no_param_arrays).
bench/BENCHMARKS-macos.md batch 2
MariaDB Connector/ODBC 3.2.9
Its connect-time probe SELECT 1 FROM DUAL WHERE @@sql_mode LIKE '%ansi_quotes%' fails on servers without a DUAL table (Databend, GreptimeDB); its prepared/binary-literal path fails on Doris and StarRocks.
COMPATIBILITY.md, macOS column
MariaDB Connector/ODBC 3.2.9 (macOS arm64)
Every MySQL-wire server reads at 39–47k rows/s through it, in five languages and pyodbc alike, against 1.3–4.5M rows/s through MySQL's own connector on the same machine.
bench/BENCHMARKS-macos.md batches 3–4
MySQL Connector/ODBC 8.4.0 (Windows)
Needs NO_SSPS=1 against every non-MySQL server (No data supplied for parameters in prepared statement); reads result sets from servers without character-set session variables as 3-byte utf8, so astral characters come back as ??? (Databend, GreptimeDB, MatrixOne, MongoDB BI). Connector/ODBC 26.7.1 for Windows reads astral characters correctly but still needs NO_SSPS=1 against non-MySQL servers.
bench/BENCHMARKS-windows.md
MySQL Connector/ODBC 26.7.1 (macOS arm64, iODBC build)
Writes UTF-16 code units into iODBC's 4-byte SQLWCHAR (surrogate pairs as two units) but does not read bound SQL_C_WCHAR parameters in four-byte units consistently — plain ASCII inlined as garbage. adbcBridge binds text narrow for it on a four-byte build. Only obtainable through a JavaScript download page.
Returns a non-BMP character with only its low 16 bits (U+1F680 → U+F680) when building UTF-16 — on literals and stored data, through pyodbc and the bridge identically (sqlflite, InfluxDB 3).
bench/BENCHMARKS-windows.md
taos-odbc (TDengine, Windows build)
An ANSI build whose iconv lacks the CP1252 → UTF-8 table: statement text from the driver manager cannot be converted, so nothing executes.
bench/BENCHMARKS-windows.md
MariaDB Connector/ODBC 3.1.15 (Linux)
SQLBindCol(SQL_C_CHAR) does not NUL-terminate: a value that exactly fills the buffer returns SQL_SUCCESS with no terminator and no 01004, and a shorter buffer gets the data bytes with no terminator (SQLGetData on the same value is correct); SQLSetPos(SQL_POSITION, n) answers SQL_SUCCESS and a following SQLGetData returns row 1's data for every n; a fractional-second TIME string bound SQL_C_CHAR → SQL_TYPE_TIME is refused with 22008 Datetime field overflow even into TIME(6). adbcBridge reads bound columns by their indicators and already sets getdata_repair=false for this driver.
tests/compat/README.md, MariaDB section
MatrixOne 4.2
A parameter array bound into a BIT column aborts the server once it holds NULLs (malloc(): unaligned fastbin chunk detected; six sets with two NULLs is enough, while single parameters, NULL-free arrays and the same array into TINYINT are fine); over server-side prepared statements a bound NULL into BIT is stored as false (NO_SSPS=1 stores NULL); SQLDescribeCol sizes a TEXT column at one third of its widest value's byte length; SQLColumns reports DATA_TYPE 0 for every column. adbcBridge: booleans ingested as TINYINT, no-declared-length columns bound at long_bind_bytes. — fixed on main by matrixorigin/matrixone#27645 (2026-08-26, issue #27635) before this could be filed; verified against the 2026-08-28 nightly, the released 4.2.0 still aborts.
COMPATIBILITY.md, tests/compat/README.md
Arrow Flight SQL ODBC 0.9.7 (Linux x86_64)
SQLGetFunctions reports SQLBindParameter and SQLNumParams as supported while SQLBindParameter always answers HYC00 (only SQLDescribeParam tells the truth); SQL_ATTR_AUTOCOMMIT = OFF is HYC00, so there is no transaction control; the SQLColumns crash recorded above for sqlflite and InfluxDB 3 does not reproduce against Dremio 26.
tests/compat/README.md, Dremio section
OpenLink Virtuoso (virtodbc.dll 7.2.17, Windows)
Advertises the SQL_GD_* extensions but SQLSetPos(SQL_POSITION) fails on a block cursor; and the bound-column indicator array is written at a 4-byte stride on a block cursor (read at that stride by the ind_stride_32bit quirk).
Throws a C++ exception through the FFI boundary; a Rust process cannot unwind it and aborts.
bench/LANGUAGE_BENCHMARKS-windows.md
DuckDB ODBC 1.5.5 (Linux)
(SQLGetInfo(SQL_DBMS_VER) segfault and the parameter-array defect: both sent upstream as fixes and both merged on 2026-09-17, see duckdb/duckdb-odbc#523 and #524 above.) A fetch writes the full 2048-row vector into bound buffers whatever the rowset size. adbcBridge binds row by row and fetches 2048-row rowsets there.
COMPATIBILITY.md, tests/compat/README.md
RisingWave 3.0
A server-side statement name is not released when the client closes the statement, so psqlodbc's _PLAN0x<handle> names collide on the second prepare of an allocate/prepare/execute/free loop (XX000 Duplicated statement name). adbcBridge sets UseServerSidePrepare=0 there.
COMPATIBILITY.md
OceanBase CE 4.4.2
A server-side prepared execute in which a parameter arrives as MYSQL_TYPE_NULL (what MySQL Connector/ODBC sends for a NULL before a value has fixed the type) is refused with Object type error (4001); MySQL accepts the same sequence.
COMPATIBILITY.md
alexbrainman/odbc (Go, Windows)
Access violation inside api.SQLGetDiagRec on the first driver diagnostic, on every server but SQLite.
bench/LANGUAGE_BENCHMARKS-windows.md
psqlodbc 18.x
Sends SHOW DateStyle at connect, which YDB's PostgreSQL layer rejects; 16.x does not. (Arguably YDB's to implement.)
COMPATIBILITY.md, macOS and Windows columns
Firebird ODBC 3.0.1 / 3.5.0-rc1
No macOS build; Firebird 5's sample security database ships with no SYSDBA and bootstrapping one needs an administrator.
COMPATIBILITY.md
sqliteodbc 0.99991 (Linux)
SQLGetData(SQL_C_NUMERIC) returns a bare SQL_ERROR with zero diagnostic records (the same conversion through SQLBindCol gives HY003 invalid type 2); SQL_ATTR_PARAM_STATUS_PTR is accepted and never written, so a caller reads uninitialised status; a TIMESTAMP written through a bound SQL_C_TYPE_TIMESTAMP is stored with three fractional digits (statement text keeps six); a second SQLGetData on a fully returned column hands back a suffix under SQL_SUCCESS instead of SQL_NO_DATA; SQLRowCount on an open SELECT answers 0. adbcBridge reads decimals as text and timestamps whole.
tests/compat/README.md, SQLite section
MySQL Connector/ODBC 9.4.0 (against MongoDB BI Connector)
Two segfaults with standalone reproductions: SQLColumns crashes in columns_i_s() on the NULL NUMERIC_PRECISION mongosqld's information_schema reports for a DECIMAL column (per column, not per table; NO_I_S=1 is accepted and changes nothing); and the handshake crashes in the bundled libmysqlclient (authsm_begin_plugin_auth, strcmp() on a NULL plugin name) when the server's greeting names no auth plugin and PLUGIN_DIR is unset. adbcBridge: no_sql_columns, PLUGIN_DIR=.
COMPATIBILITY.md, tests/compat/README.md
Arrow Flight SQL ODBC 0.9.7 (Linux)
The SQLColumns segfault has a root cause: arrow::KeyValueMetadata::Get on a null pointer from GetColumns_Transformer::Transform, for any table whose Flight SQL schema carries no per-field key-value metadata — every sqlflite table, InfluxDB 3's system/information_schema tables (its iox tables are fine), never Dremio 26; a request spanning one such table loses the good rows with it, and three system tables answer SQL_SUCCESS with zero rows for tables that have columns. SQLGetFunctions also reports SQLStatistics as supported while the call answers HYC00. One-call reproduction.
tests/compat/README.md, Flight SQL and InfluxDB sections
Oracle Instant Client ODBC 23.9 (SQORA, Linux)
Changing SQL_ATTR_ROW_ARRAY_SIZE on an open cursor is accepted and then either segfaults on a later SQLFetch (bcoReturnColData; LOB or not, at switch points that depend on cursor depth) or silently loses rows (lowering strides the result set, 100,000 rows read back as 13,440-14,336; raising can rewind and re-deliver). SQLGetData on a block cursor answers HY109 although SQL_GETDATA_EXTENSIONS advertises SQL_GD_BLOCK|SQL_GD_BOUND; SQL_C_SBIGINT is refused on both sides (parameters silently, reads with 07006) and SQLGetTypeInfo(SQL_BIGINT) is empty; an unconstrained NUMBER is read through a double and loses precision beyond 2^53. adbcBridge: fixed_rowset, getdata_repair=false, bigint_param_as_string.
COMPATIBILITY.md, docs/reference/quirks.md
Apache Ignite ODBC
The C++ platform layer has only linux/ and win/; the Darwin build stops at sys/sysinfo.h.
COMPATIBILITY.md, macOS column
OpenSearch SQL ODBC
The macOS package is x86_64-only and links /usr/lib/libiodbc.
COMPATIBILITY.md, macOS column
openGauss (enmotech image, arm64)
The MOT engine panics at start inside Docker Desktop's VM (thread identifiers exhausted), with or without enable_numa = false.
COMPATIBILITY.md, macOS column
Spanner emulator
A read-write transaction left open by a killed client wedges the emulator (FATAL: UNAVAILABLE on every later connection).
COMPATIBILITY.md, Windows column
SAP HANA client ODBC 2.29 (libodbchdb, Linux, macOS and Windows alike)
Narrow statement text is decoded as Latin-1, not as the UTF-8 the driver manager hands over: the literal 'héllo 🚀' is stored as eleven characters, so a literal never matches the same value sent as a bound SQL_C_WCHAR parameter; a SQL_C_CHAR parameter is decoded the same way; eleven locale and connection-keyword variations change nothing (unknown keywords are silently accepted), and SQLExecDirectW and SQL_C_WCHAR store it exactly (adbcBridge routes statement text through the W entry points, wide_sql). SQLGetTypeInfo returns all 33 types in alphabetical TYPE_NAME order with DATA_TYPE unsorted, against the ODBC ordering contract, which puts SECONDDATE before TIMESTAMP and BINTEXT before NCLOB, so type-table-driven DDL silently drops fractional seconds. Both reproduced without adbcBridge on 2026-09-04 (C, isql, pyodbc).
COMPATIBILITY.md (SAP HANA row)
Exasol ODBC 25.2 / 26.2 (exaodbc, Linux and Windows)
SQLBindParameter refuses SQL_C_BINARY for any target (HY003), so no binary value can reach the server; a NULL parameter described SQL_DECIMAL — every integer column, since INT/BIGINT are DECIMAL aliases — cannot be bound with SQL_C_DEFAULT (SI002 C-Type not supported, HY010); SQL_GETDATA_EXTENSIONS claims SQL_GD_BLOCK, but on a block rowset only the first SQLGetData after each SQLFetch succeeds — every later one, on any row SQLSetPos(SQL_POSITION) selects, answers SQL_NO_DATA with no diagnostic record — while SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1 is 0x0 and SQLSetPos still returns SQL_SUCCESS; SQLGetData(SQL_C_BINARY) is also refused (07006). All three reproduced without adbcBridge on 2026-09-04, in C against unixODBC.
COMPATIBILITY.md (Exasol row)
Altibase ODBC 7.3 (libaltibase_odbc)
A SQL_C_WCHAR parameter above the BMP is stored as CESU-8 (a surrogate pair encoded as two 3-byte sequences) while the narrow path stores UTF-8, so the two paths disagree on the same value; SQLGetTypeInfo has no SQL_LONGVARCHAR and reports CREATE_PARAMS "precision" for VARCHAR, so a type-table-driven VARCHAR DDL comes out bare, which Altibase reads as VARCHAR(1); BYTE/VARBYTE/BLOB carry vendor SQL_DATA_TYPE codes (20001, 20003, 30) and fall through to text as hex in a generic reader. Vendor's GitHub carries no source since the 2023 withdrawal of the open-source edition; support channel only.
COMPATIBILITY.md (Altibase row)
Kinetica ODBC 7.1.9 (Linux, libKineticaODBC)
Executes parameter N with the value bound at N+1 and sends the last as NULL, under SQL_SUCCESS with an empty diagnostic queue (a one-parameter INSERT stores NULL; eight parameters fail HY000 (1040) Driver Error: type: %d); SQLDescribeParam reports every parameter SQL_VARCHAR(1024); a string parameter on SELECT ? segfaults the process; a DECIMAL(18,4) column is described three ways (SQLDescribeCol 38/0, SQLColumns 18/18, SQLGetTypeInfo 38). Reproduced through unixODBC with no pyodbc. adbcBridge marks the entry read-only. Server side: a provably false predicate is constant-folded onto SYSTEM.ITER, which cannot project a BYTES column, so WHERE 1=0 fails on any table holding binary (LIMIT 0 does not).
Exports the W entry points but transcodes W statement text through the ANSI code page before sending it: the literal 'héllo 🚀' lands as cp1252 bytes and every later read of the row fails HY000 (50311) ICU conversion, identical through pyodbc; its narrow statement path passes UTF-8 through, its narrow fetch does not.
COMPATIBILITY.md, Windows column; bench/BENCHMARKS-windows.md
Actian Ingres ODBC 10.1 (libiiodbcdriver, Linux)
Its wide entry points take a four-byte SQLWCHAR (typedef wchar_t SQLWCHAR), so under unixODBC — which selects the W path by the presence of SQLConnectW — the connection string arrives as its first character and the connect fails E_GC0138; Driver= is copied into a 32-byte buffer with no bound, so any real path aborts the process (*** buffer overflow detected *** under ConDriverInfo); a UTF-16 surrogate pair in a SQL_C_WCHAR parameter is rejected (5000B). adbcBridge's matrix loads it behind an ANSI-only forwarding shim; a patch for both source defects is prepared and build-tested. No public source repository accepts pull requests; the report goes to Actian's community or support channel.
Actian Ingres ODBC 10.1 (libiiodbcdriver, Linux), from .NET
SQLTables segfaults inside the driver's own IIODsqtb_SQLTables_InternalCall when called from a .NET process (System.Data.Odbc present, libcoreclr.so on the stack), after SQLSetConnectAttr(SQL_ATTR_AUTOCOMMIT) has already failed on the same connection; identical with autocommit on, with the native comparison off and with a 100-row workload, so neither size nor the second client. The same calls from Python, Rust and Go succeed.
bench/LANGUAGE_BENCHMARKS.md (ingres, csharp)
Actian Ingres client library 10.1 (libcompat.1.so, Linux), under a JVM
The client's EX exception facility installs process-wide signal handlers when its GCF layer loads (i_EXcatch → EXsignal → EXsetsig → sigaction) and takes over the SIGSEGV the JVM uses for implicit null checks, so a Java process that has loaded the driver dies # SIGSEGV … (sent by kill) in libcompat.1.so EXsignal within a second, before any ODBC call. A library loaded into a host process should not replace the host's signal handlers.
bench/LANGUAGE_BENCHMARKS.md (ingres, java)
Ingres 10.1 server (logging system)
A single autocommit transaction that inserts 100,000 rows is force-aborted (E_DM9059_TRAN_FORCE_ABORT, client 40001 (4706) … externally aborted) by the log's force-abort limit at the default configuration; 20,000 and 50,000 rows load. Configuration rather than defect; recorded so the empty cell's cause is on file.
Negotiates the server's ISO88591 with Ingres 10.1 (declaring UTF8 is refused, E_GC2462) and substitutes ? for U+1F680 on every route, pyodbc identical — arguably by design, recorded so the Windows cell's reason is on file.
COMPATIBILITY.md, Windows column
IBM i Access ODBC 1.1.0.29 (libcwbodbc, Linux)
The Driver= value is limited to 35 characters (30119 Key value in connection string too long at 36 — one shorter than the driver's own installed path); message catalogues and conversion tables are resolved under a compiled-in /opt/ibm/iaccess, without which every diagnostic degrades to CWBNL0202 - cwbodmsg.dll; a CLOB column can be neither array-bound for writing nor bound for reading, so the type its own SQLGetTypeInfo(SQL_LONGVARCHAR) names costs one round trip per row (8 rows/s).
COMPATIBILITY.md (Db2 for i row)
IBM i Access ODBC for macOS 1.1.0.29 (libcwbodbc.dylib, arm64)
With SSL=1 the driver segfaults inside SQLDriverConnect whenever pyarrow's libraries are loaded in the same process; bisected: bare Python connects, with adbc_driver_manager connects, with the bridge library connects, with pyarrow loaded it crashes on SSL=1 and connects on SSL=0, and pyodbc alone connects on SSL=1. libcwbcore.dylib is two-level with weak definitions and pyarrow's libarrow/libarrow_flight carry their own OpenSSL statically, so weak-symbol coalescing across images is the likely mechanism; the exact symbol is not identified (no debugger on that account, no crash report written). The Linux driver does not show it. The matrix entry runs SSL=0 on macOS.
COMPATIBILITY.md, macOS column
Docker Desktop for Windows (WSL 2.7.12)
The data VHDX never returns space to Windows while running; wsl --shutdown compacts it.
bench/BENCHMARKS-windows.md
ADBC Driver Foundry MySQL driver 0.6.0 (adbc-drivers/mysql)
Against GreptimeDB, reading a table with a DECIMAL(10,3) column fails while building the Arrow schema: invalid decimal precision/scale (1023, 0) — the server describes the column with precision 1023 and the driver passes it through unchecked (MySQL Connector/ODBC reports DECIMAL(10,3) on the same column). Also observed, and arguably by design: the CREATE TABLE it generates for bulk ingest (… INT NULL, … DOUBLE NULL) is a syntax error on Databend and StarRocks, and the statement it prepares on connect is refused by Apache Doris (Only support prepare SelectStmt or InsertStmt now), so those three MySQL-wire servers are unreachable through it. Probe of 2026-09-05.
Not a defect, recorded so nobody re-measures it: the driver reads every result through COPY … TO STDOUT (FORMAT binary) and bootstraps its types from pg_catalog.pg_type, so on servers that speak the PostgreSQL wire protocol without those two — CockroachDB, CrateDB, YDB, openGauss (binary row decode), Materialize, QuestDB, RisingWave, ArcadeDB (bootstrap query) — it initializes and then fails on the first result set, or fails at connect; on Cloud Spanner's PGAdapter its ingest creates a table without the primary key Spanner requires. Probe of 2026-09-05; the same finding is what adbcBridge's own delegation probe (0.1.1) tests for. Re-measured 2026-09-06 with the driver's adbc.postgresql.use_copy=false statement option: CockroachDB and openGauss then pass all seven steps natively; CrateDB reads but its COPY … FROM STDIN grammar and the $1::regclass::oid schema lookup still stop ingest and GetTableSchema; YDB returns every column as an opaque binary extension type because its pg_type does not describe its types the way the resolver needs.
Frequently asked questions about adbcBridge, a plain-C11 ADBC
driver that turns any ODBC data source into an Arrow-native one. Answers link to
the fuller documents where they exist. Every figure quoted here is measured; the
host and state behind each number are recorded in the benchmark files under
bench/.
Abbreviations used throughout: ADBC — Arrow Database Connectivity, Apache
Arrow's Arrow-native database API; ODBC — Open Database Connectivity, the
long-standing C call-level interface that almost every database ships a driver
for; DDL — Data Definition Language (CREATE TABLE, …); DML — Data
Manipulation Language (INSERT, UPDATE, …); DSN — Data Source Name, a
named connection profile in odbc.ini; WAL — write-ahead log.
General
What is ADBC, and what does adbcBridge add to it?
ADBC is Apache Arrow's database API: a client asks for rows and gets back Arrow
record batches, columnar and zero-copy, with no per-row conversion. A database
needs an ADBC driver to speak it. Only a handful exist natively (PostgreSQL,
SQLite, DuckDB, Snowflake, BigQuery, Flight SQL). adbcBridge is one ADBC driver —
the shared library libadbc_driver_odbc.so (.dylib on macOS, .dll on
Windows) — that fronts any ODBC driver, so every database with an ODBC driver
becomes usable from any ADBC binding: Python, R, Go, Rust, C++, C# and Java. See
the README for the architecture diagram.
Why bridge through ODBC at all?
Because the ODBC ecosystem already covers a few hundred data sources — Db2,
Oracle, Teradata, SQL Server, SAP HANA, Informix, Vertica, and the long tail of
enterprise databases that will never get a native ADBC driver. Writing one
C library that drives all of them is far less work than writing one ADBC driver
per database, and it makes those sources available to the whole Arrow ecosystem
at once.
How is this different from pyodbc, turbodbc, arrow-odbc or a JDBC bridge?
pyodbc returns Python row tuples, so a large result is converted a cell at a time
into Python objects. adbcBridge binds columns and builds Arrow batches directly,
which is both faster and language-neutral: the same driver library serves Python,
R, Go, Rust, C# and Java, not just Python. turbodbc and arrow-odbc solve the same
"ODBC to columnar" problem for a single language (Python and Rust respectively);
adbcBridge solves it once, in C, behind the ADBC application interface, so you are
not locked to one language's binding. A JDBC bridge would need a JVM in every
process — a C-side JDBC bridge is on the roadmap as a separate
future driver, not the ODBC one.
Is it faster than pyodbc?
Yes, measurably, for the Arrow use case. Reading 1,000,000 rows of
(int, double, varchar(20), date) from SQLite (median of 5,
bench/BENCHMARKS.md):
Path
Time
Relative
adbcBridge fetch_arrow_table()
0.48 s
1.0×
pyodbc fetchall() → pyarrow.Table
1.16 s
2.4× slower
pyodbc fetchall() → pandas.DataFrame
1.32 s
2.8× slower
raw SQLBindCol+SQLFetch, no Arrow (floor)
0.44 s
0.93×
adbcBridge runs within 7% of the raw ODBC floor; the remaining cost is the ODBC
driver itself, not the bridge.
Is it faster than a native ADBC driver?
For a single connection, no — a native driver talks the wire protocol and builds
Arrow directly, which is the ceiling for one connection. 1,000,000 PostgreSQL
rows take 0.42 s through the native adbc_driver_postgresql and 0.67 s through
adbcBridge over psqlodbc; on 10 M rows a single bridge connection is 0.36×
native. The bridge beats the native driver only by doing work it does not:
splitting one query across several connections.
At eight partitions on a quiet host adbcBridge reads 1 M rows 1.2–1.5× faster
than the native driver and 10 M rows 1.55× faster; the same 1 M read on a host
with 46 idle containers came in at 0.97×, so plan around the low end. Bulk
ingest does not clear parity (0.73–1.02×), because an INSERT writes about
twice the WAL of the COPY the native driver uses. Where you want native speed
and a native driver exists, adbcBridge hands the whole connection over — see
native delegation.
Then when should I not use adbcBridge?
When a native ADBC driver exists and you are reading through a single connection:
use the native driver (or let adbcBridge
delegate to it). Replacing native drivers where they
exist is an explicit non-goal in the roadmap. adbcBridge is for
everything else — the several hundred ODBC-only sources.
What is native delegation?
When AdbcDatabaseInit recognises a target that a native ADBC driver handles
(a postgresql://, sqlite:, duckdb:, snowflake://, bigquery:// or
grpc:// URI, or a Driver=…psqlodbcw.so;… ODBC string), adbcBridge loads that
native driver and forwards every call to it. The result set is the native
driver's own Arrow stream, handed back untouched: delegation costs one
function-pointer hop per ADBC call and nothing per row, and delegated fetches
measure the same as calling the native driver directly (0.20 s for the million
PostgreSQL rows, against 0.21 s native). It is controlled by
adbc.odbc.delegate (auto default / never / always) and is a best-effort
optimisation: if no native driver is installed it falls back to ODBC and records
why in adbc.odbc.delegate.last_error. Delegation is not implemented on Windows.
See Native delegation.
Which languages can use it?
Python, R, Go, Rust, C++, C# and Java — anything that speaks the ADBC driver
manager. There are convenience packages for five of them — a Python wheel, a Rust
crate, a NuGet package, a Maven jar and a Go module — attached to the GitHub
Release, and the first three also on PyPI, crates.io and nuget.org. See
Which package do I install for my language?
Install
Which ODBC driver manager do I need?
A driver manager is the C library that loads ODBC drivers and dispatches calls to
them. On Linux, install unixODBC (unixodbc-dev). On Windows, the driver
manager ships with the operating system — nothing to install. On macOS see the
next question.
Do I need unixODBC on macOS? What about iODBC?
macOS has two driver managers, and they differ in one way that matters:
unixODBC's SQLWCHAR (the wide-character unit) is 2 bytes, iODBC's is 4. A driver
compiled against one cannot be loaded through the other. Build adbcBridge against
unixODBC for the common drivers (psqlodbc, sqliteodbc, MariaDB Connector/ODBC,
…). A few vendor drivers ship only iODBC builds (MySQL Connector/ODBC for macOS,
OpenSearch's macOS package); for those, build a second copy of adbcBridge
against iODBC and use it for those drivers. One bridge build per driver manager.
The full recipe, including relinking a downloaded connector, is in
docs/TROUBLESHOOTING.md.
What is the Windows ANSI code-page issue?
The Windows driver manager transcodes every narrow (SQLCHAR) string between
the driver and the process's ANSI code page (1252 on a Western install), whereas
unixODBC and iODBC pass narrow bytes through untouched. adbcBridge was first
written on the pass-through assumption, so early Windows builds mangled non-ASCII
text. Since build 5b932c5 (2026-08-24) the Windows path goes through the wide
(W) entry points and reads every character column as SQL_C_WCHAR, which fixes
it. If you build from source, python tests\test_windows_text.py must print
all passed. Details in
docs/TROUBLESHOOTING.md.
Where is the driver manifest, and what is it for?
A driver manifest is a small odbc.toml file that lets applications ask for the
driver by the name odbc instead of by full path. ./install.sh writes it to
~/.config/adbc/drivers/odbc.toml (a directory the ADBC driver manager already
searches), so driver="odbc" resolves with nothing else set — no
ADBC_DRIVER_PATH, no LD_LIBRARY_PATH. The manifest's absolute library path is
computed at cmake --install time, so one build tree can be installed into many
prefixes. On macOS the search directories are under
~/Library/Application Support/ADBC/Drivers; on Windows the manager also reads
HKEY_CURRENT_USER\SOFTWARE\ADBC\Drivers. See
Use by name.
How do I install without root?
Run install.sh: it builds and installs into
~/.local and writes the manifest to your ADBC user config directory. PREFIX,
MANIFEST_DIR, BUILD_DIR, BUILD_TYPE and JOBS override the defaults, and
re-running it is safe.
Which package do I install for my language?
One driver library, five wrapper packages that locate and load it:
Language
Package
How you name the driver
Python
adbcbridge wheel
adbcbridge.connect(uri=...)
Rust
adbcbridge crate (bundled builds it from source)
adbcbridge::load()?
C#
AdbcBridge NuGet
Driver.Load(), Driver.Connect(...)
Java
org.adbcbridge:adbcbridge over adbc-driver-jni
AdbcBridge.driver(allocator)
Go
github.com/singhpratech/adbcbridge/go over drivermgr
adbcbridge.NewDriver(alloc)
All five are built, tested and attached to every
GitHub Release;
the wheel, crate, nupkg and jar are also on PyPI, crates.io, nuget.org and Maven
Central. Each resolves the library the
same way and raises an error when it cannot; Rust, C#, Java and Go list every
place they looked. See
Language packages.
Usage
What does a connection string look like?
uri is a full ODBC connection string. Driver= takes either a registered ODBC
driver name or the path to its .so:
import adbc_driver_manager.dbapi as dbapi
conn = dbapi.connect(driver="odbc",
db_kwargs={"uri": "Driver=SQLite3;Database=my.db;"})
Set database options alongside uri: dsn (a DSN name from odbc.ini, appended
as DSN=…), and username / password (appended as UID= / PWD=). The full
option table is in Use (Python).
DSN or Driver= — which should I use?
Either. Driver=name names a driver registered in odbcinst.ini (or gives its
.so path directly); dsn=name pulls a whole pre-configured profile from
odbc.ini. A path in Driver= is the most self-contained and is what a build
tree wants. For native delegation a DSN works only
if the driver manager can enumerate its keywords.
How do I pass parameters?
Bind them as an Arrow record batch: one column per ? placeholder, one row per
execution. In Python this is cur.execute(sql, parameters=...) / executemany;
in Rust, C#, Java and R it is prepare then bind. A query you wrote is executed
exactly as written, whatever is bound to it — only the INSERT that bulk ingest
generates is ever rewritten.
What bulk-ingest modes are there?
adbc_ingest creates or appends to a table from an Arrow stream. Rather than one
statement per row, it packs K rows into one INSERT INTO t VALUES (…),(…),…
inside a single transaction; K is probed against the driver and remembered on
the connection. Speed-ups are large — e.g. ClickHouse 16 → 911 rows/s,
Oracle 475 → 23,628, DuckDB 24,057 → 344,597. Two refinements:
PostgreSQL goes a step further and sends each batch as one array parameter
per column (INSERT … SELECT * FROM unnest(?::bigint[], …)), which is on by
default and narrow by design — only real PostgreSQL, only spellable types, and
it falls back rather than guessing.
Parallel ingest (adbc.odbc.ingest_connections=N) spreads one ingest over
N connections. It is opt-in because it is not atomic: N connections are
N transactions, so a failure can leave some batches committed.
adbc.odbc.rows_per_insert overrides the group size (1 turns the rewrite off).
Full detail in Bulk ingest.
How are transactions handled?
Autocommit / commit / rollback all work. Bulk ingest and executemany also batch
their commits: in autocommit, when more than one row is bound, the driver turns
autocommit off for the duration and commits once at the end (rolling back on
failure) instead of paying a commit per row. A transaction you opened yourself is
left alone.
What options help with large result sets?
adbc.odbc.batch_size — rows per Arrow batch (default 1024).
adbc.odbc.prefetch — 0 (default, off) up to 8; a background thread fetches
the next rowset while the current one is converted. It is worth little in
practice because most ODBC drivers have already buffered the whole result set
client-side; only psqlodbc's cursor mode leaves a real socket wait, and even
there it buys 6–10%. See Prefetch.
adbc.odbc.rowset_bytes, adbc.odbc.max_bind_bytes, adbc.odbc.long_bind_bytes
— control how wide values are bound versus re-read with SQLGetData.
For a genuinely large read, partitioning is the
mechanism that pays, not prefetch.
What are partitioned reads?
ADBC's partition contract: AdbcStatementExecutePartitions hands back N opaque
descriptors, and AdbcConnectionReadPartition turns any one back into a stream,
on any connection, in any order or process — so N connections read one query in
parallel and the pieces concatenate. adbcBridge splits only what it can prove
correct from the catalog (a PostgreSQL heap ctid, an integer primary-key range,
or YugabyteDB's tablet hash) and otherwise returns a single descriptor carrying
the original query — never slower than not calling it. It never guesses a
partition column. Set adbc.odbc.partitions. See
Partitioned reads.
Compatibility
Which databases are known to work?
53 databases are verified on Linux by one identical workload, and re-run on macOS (45 pass)
and Windows (48 pass); the full matrix with per-driver notes is in
docs/COMPATIBILITY.md. It spans SQLite, DuckDB,
PostgreSQL and its wire-compatible forks (CockroachDB, YugabyteDB, TimescaleDB,
Citus, CrateDB, QuestDB, RisingWave, Materialize, openGauss, Cloudberry, YDB,
Spanner via PGAdapter, ArcadeDB), the MySQL-wire family (MySQL, MariaDB, TiDB,
Dolt, Percona, Doris, StarRocks, MatrixOne, GreptimeDB, Databend, OceanBase,
SingleStore, MongoDB BI), SQL Server, Oracle, Db2, IBM Db2 for i, Informix,
SAP HANA Express, Exasol, Altibase, Kinetica, Actian Ingres, MonetDB, Firebird,
Vertica, ClickHouse, Virtuoso, Access, TDengine, Apache Ignite, OpenSearch, and
Arrow Flight SQL servers (sqlflite, InfluxDB 3, Dremio).
Does a database that is not on the list work?
Probably, on the generic path — adbcBridge can reach anything with an ODBC driver.
But all but a few of the 53 verified drivers needed at least one workaround, so expect
an unlisted driver to work and to have a quirk waiting. Reachability is not
verification.
What does "PASS" mean in the matrix?
That tests/compat/test_matrix.py ran one identical ADBC workload against a real
server or file and it all worked: multiple types (integers, float/double,
char/varchar, binary, date, time, timestamp, decimal, boolean), NULLs,
Unicode including an astral-plane (non-BMP) character such as an emoji,
parameters, bulk ingest, batched reads, metadata (GetObjects)
and error mapping (SQLSTATE plus native code). Some entries are marked
"read side only" where the driver has no SQLBindParameter or the server has no
CREATE TABLE. See tests/compat/README.md.
Which drivers are known to need quirks, and why?
All but a few of the 53 needed at least one. A few representative ones: IBM Db2 and Informix
ship a 32-bit SQLLEN on 64-bit Linux (adbc.odbc.sqllen_32bit); DuckDB,
MonetDB, ClickHouse, Firebird and others have parameter arrays that
silently drop values, so those are turned off; Oracle lacks SQL_C_SBIGINT,
so 64-bit ints go as numeric text; SQL Server and Db2 name a deprecated
wide-text type in SQLGetTypeInfo, so ingest DDL spells NVARCHAR(MAX) /
widest VARCHAR instead. Each quirk is keyed on the driver name or server
identity and documented with its measured reason in
docs/COMPATIBILITY.md.
Do I need a licence for the vendor ODBC drivers?
adbcBridge itself is Apache-2.0 and redistributes no vendor driver. The vendor
ODBC drivers keep their own licences, and most do not allow redistribution — so
Oracle, Db2, SQL Server, Snowflake, Teradata, SAP HANA and the like stay your
download. The open-licence drivers (sqliteodbc, psqlodbc, MariaDB Connector/ODBC,
clickhouse-odbc) are the ones a planned installer will fetch for you; see the
roadmap. Windows already ships the SQL Server driver.
Troubleshooting
The driver manager says a file is "not found" but it is right there.
unixODBC reports every load failure of a driver library as file not found,
discarding the real dlerror(). adbcBridge re-opens the path itself and puts the
real reason into the ADBC error — Permission denied, a missing dependency, a
32-bit driver in a 64-bit process, or the static-TLS problem below. A file that
genuinely is absent still says No such file or directory. See
docs/TROUBLESHOOTING.md.
I get "cannot allocate memory in static TLS block" after importing pyarrow.
This affects exactly one driver in the matrix — MySQL Connector/ODBC — and it
reproduces with plain pyodbc, no ADBC involved. Importing pyarrow pins libstdc++
to dynamic thread-local storage (TLS), after which that driver cannot load.
Loading the ODBC driver before pyarrow settles it, which the adbcbridge
Python package does automatically. Raising glibc.rtld.optional_static_tls does
not help. Full account, per-driver table and a readelf check in
docs/TROUBLESHOOTING.md.
On macOS the process dies with SIGABRT on the first SQL error.
The driver is an iODBC-width (4-byte SQLWCHAR) build loaded through unixODBC
(2-byte); unixODBC's driver manager overflows a stack buffer reading the wide
diagnostic. Build the bridge against iODBC and use those drivers through it. This
is a driver-manager bug, reported upstream
(lurcher/unixODBC#239); see
docs/TROUBLESHOOTING.md.
Project
What licence is adbcBridge under?
Apache-2.0. Every non-vendored source file carries an
SPDX-License-Identifier: Apache-2.0 header. Vendored Apache Arrow components
(nanoarrow, adbc.h, utils.c/h, options.h) are listed in
NOTICE.
What does the 0.1.x version mean?
Early. The read/write path, the 53-database matrix and the five language packages
are done and 0.1.2 is on PyPI, crates.io, nuget.org and Maven Central. v0.1.0 was
tagged 2026-08-25 with four platform libraries, four wheels plus an sdist, a crate, a
NuGet package and a jar; 0.1.1 (2026-09-05) and 0.1.2 (2026-09-15) followed. Treat the API as stabilising, not stable. See
the roadmap.
What is on the roadmap?
For the ODBC bridge: a driver-bootstrap installer that fetches the open-licence
drivers, Maven Central publication, the ADBC Driver Foundry validation
suite, and Windows parity for prefetch and parallel ingest. Beyond ODBC: a
JDBC bridge (load a JVM in-process and drive any JDBC driver) and, later, an
OLE DB bridge for Windows. Full detail and status in
docs/ROADMAP.md.
How do I report a bug, and what should I include?
ODBC drivers differ wildly, so a good report names the moving parts. Include: the
ODBC driver and version and the driver manager (unixODBC, iODBC or Windows); the
connection string with secrets removed; the failing SQL and the schema of the
columns involved; and the full ADBC error, including SQLSTATE and the native error
code. If you can, add a case to tests/test_sqlite.py that fails before the fix.
See CONTRIBUTING.md and
Contributing. A security problem is the one exception: report it
through GitHub's private vulnerability reporting, not an issue — the policy, the
response times and the support window are in Security.
How are findings given back to upstream projects?
Driving 53 databases across three operating systems turns up defects that belong
to other projects. Each is reported upstream with a reproduction that needs no
adbcBridge in the stack, and the whole record — filed reports and findings
documented but not yet filed — is kept in
docs/UPSTREAM.md. Filed as of 2026-09-04: 27 reports across fifteen
projects — unixODBC, OpenLink Virtuoso, Dremio / Arrow Flight SQL ODBC, Firebird
ODBC, taos-odbc, Apache Doris, clickhouse-odbc, MySQL Connector/ODBC, Apache
Ignite, OpenSearch SQL ODBC, QuestDB, CrateDB, psqlodbc, Materialize and
SingleStore Connector/ODBC. Three have already been fixed by their maintainers
(CrateDB, psqlodbc #207 and #208), and unixODBC shipped a mitigation.
How can I contribute?
Bug reports, driver-specific quirk fixes, type-mapping improvements, docs and
tests against uncovered drivers are all welcome. The build/test loop, coding
conventions, how to add a database or a driver quirk, and the release process are
in Contributing and CONTRIBUTING.md.
Contributing
adbcBridge is a plain-C11 ADBC driver that
bridges any ODBC data source to Apache Arrow. Contributions of every size are
welcome (security problems excepted: those go through private vulnerability
reporting, see Security): bug reports, driver-specific quirk fixes, type-mapping improvements,
docs, and tests against ODBC drivers not yet covered. This page is the working
guide; the short version and the bug-report checklist live in the repository's
CONTRIBUTING.md.
Abbreviations: ODBC — Open Database Connectivity; ADBC — Arrow Database
Connectivity; DDL — Data Definition Language; DM — driver manager
(unixODBC, iODBC, or the Windows one); quirk — a per-driver or per-server
workaround the driver applies automatically.
Getting set up
You need a C11 compiler, CMake ≥ 3.16, and an ODBC driver manager:
sudo apt install unixodbc-dev cmake # Debian/Ubuntu
brew install unixodbc cmake # macOS
# Windows: the ODBC driver manager ships with the OS
Build a debug tree:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
# -> build/libadbc_driver_odbc.so (.dylib on macOS, .dll on Windows)
The build must stay warning-free under -Wall -Wextra.
How the code is organised
Everything driver-internal is static or declared in src/odbc_internal.h; only
AdbcDriverInit (and the ADBC 1.0.0 entry point) is exported. The src/
directory:
File
What it does
odbc_driver.c
The top-level ADBC driver: the database/connection lifecycle, connection setup, option setters and getters, and the driver-quirk detection in OdbcDetectQuirks. struct OdbcDatabase holds the environment handle, connection string / DSN / credentials, reader options and the native-delegation proxy.
odbc_reader.c
The result-set reader: turns ODBC rowset fetches into Arrow record batches (ArrowArrayStream). Statement-handle refcounting, the block-cursor fetch/repair machinery, and the read-side 32-bit-SQLLEN handling.
odbc_bind.c
Parameter binding (Arrow → SQLBindParameter) and bulk ingest. struct ParamSlot is the per-parameter scratch (C/SQL type, column size, indicator, UTF-16 conversion buffer, dictionary view).
odbc_delegate.c / odbc_delegate.h
Native delegation: when a native ADBC driver exists for the target, let it serve the connection and use ODBC only as the fallback. The header is the contract — the decision happens in AdbcDatabaseInit — and defines the adbc.odbc.delegate* option names and the OdbcDelegateMode enum.
odbc_objects.c
ConnectionGetObjects: ODBC catalog functions → the ADBC nested catalog/schema/table/column object schema, reading columns defensively for minimal drivers (MDB Tools, sqliteodbc) that return empty or NULL for absent catalogs.
odbc_partition.c
Partitioned reads (AdbcStatementExecutePartitions / AdbcConnectionReadPartition): each descriptor is the original SELECT narrowed to a disjoint, half-open slice of the table. Carries the exactness argument and the per-backend split strategies.
odbc_text.c
Every string-carrying ODBC call, in UTF-8 end to end. On Linux/macOS these are thin passthroughs to the narrow entry points; on Windows they route through the wide (W) entry points, because the DM would otherwise transcode narrow text through the ANSI code page.
odbc_internal.h
The central internal header: platform and SQL-type shims, the ADBC_ODBC_EXPORT macro, struct OdbcReaderOptions (home of nearly all the quirk flags, documented field by field), and the SQLLEN read/indicator helpers.
utils.c / utils.h, options.h
Vendored from Apache Arrow ADBC — see Vendored code.
Coding conventions
Visible in the code and enforced by the tooling:
Plain C11 — no compiler extensions, no C++.
Formatting is Google style at a 100-column limit with pointers bound to the
type (char* p), via the checked-in .clang-format. Short guard clauses and
one-line bodies are kept on one line (AllowShortIfStatementsOnASingleLine,
AllowShortBlocksOnASingleLine). Install the hooks and let them do the work:
sh
pip install pre-commit && pre-commit install
pre-commit run --all-files
The existing sources predate the config, so a first touch of a file may
reformat nearby lines — keep that reformat noise in a separate commit from the
behaviour change.
- Errors go through the ODBC diagnostic helpers so SQLSTATE and the native
error code reach the caller; do not invent error strings for ODBC failures.
- Every allocation needs a matching release path, including on error returns.
Running the test suite under valgrind before a pull request is a good habit.
- Comments explain the measured reason. The distinctive style throughout the
driver is that a workaround names the driver or server it is for, states what
was observed, and where useful gives the numbers — not "work around a bug" but
which call returned what. The quirk pattern below is the clearest example.
The driver-quirk detection pattern
Quirks are detected in one place — OdbcDetectQuirks(struct OdbcConnection*) in
src/odbc_driver.c. It reads SQL_DRIVER_NAME with SQLGetInfo (falling back to
SQL_DBMS_NAME for MDB Tools, which does not implement it), lowercases it, then
runs a chain of strstr(name, "…") matches, each setting one or more flags on
conn->reader_opts. The flags themselves are declared and documented field by
field on struct OdbcReaderOptions in src/odbc_internal.h.
The pattern for a single quirk is: key on the driver name, set a
reader_opts flag, and comment the measured reason. For example, DuckDB's
parameter arrays are turned off with a comment that says exactly what breaks:
DuckDB accepts SQL_ATTR_PARAMSET_SIZE but ignores the indicator array that
goes with a column-wise parameter array: NULL parameter sets land as zeros and
the values of the sets around them are dropped. Row-at-a-time only.
Two variations matter:
When the driver name is not enough, ask the server. psqlodbc drives every
PostgreSQL-wire server (PostgreSQL, CockroachDB, YugabyteDB, TimescaleDB,
QuestDB, …), so a quirk keyed on its name would fire on real PostgreSQL too.
For those, OdbcDetectQuirks issues one small identity query (version(), or
a semantic probe) and keys on the answer. This is why the PostgreSQL
array-ingest form is enabled only when version() is a genuine PostgreSQL
banner with no fork marker.
When a name matches several SQL_DRIVER_NAME spellings (Firebird's OdbcFb
reports different names on Linux and Windows), match all of them; when a quirk
is Windows-only (a narrow_sql fix for an ANSI-only driver), guard it with
#if defined(_WIN32).
Representative flags: no_param_arrays, wchar_as_utf8, narrow_sql,
narrow_params, ind_stride_32bit, sqllen_32bit, bigint_param_as_string,
temporal_binary_param_as_varchar, pg_array_ingest. Several are also exposed as adbc.odbc.* options so
a user can force them for an unlisted driver — see the option table in
Use (Python).
Adding a database to the compatibility matrix
A verified database touches five places. Use an existing entry of the same wire
protocol as the template.
tests/compat/test_matrix.py — add an entry to the DBS dict, keyed by a
short name. An entry is a dict of: env (the environment variable naming its
ODBC driver library), conn (the connection-string template, where {drv}
expands to the driver library and {drvdir} to its directory), ddl (the
CREATE TABLE in that server's dialect), and the tolerance fields an entry
needs — for example decimal_type, ts_us, astral (whether the
server round-trips an astral-plane character), text_sortable, ident (an
identifier-folding function such as str.upper), unicode_env, and extra
(additional setup/read steps). The connection string can be overridden at run
time with <NAME>_CONN.
tests/compat/docker-compose.yml — add the server as a service (under the
extra profile if it is heavy or slow to start), with a pinned image and a
host-side port that does not collide with the others.
Fixtures — anything the entry reads that is not created at run time goes in
tests/compat/fixtures/ (as the checked-in .mdb does for Access).
tests/compat/README.md — a section giving the root-free server command,
the driver download/build steps, the run command with its expected PASS
line, and any entry notes (identifier folding, tolerance flags, server-side
setup).
docs/COMPATIBILITY.md — a row recording the
database, its driver, the PASS/read-only status, and every quirk handled with
its reason. Record the result for each operating system it was measured on.
An entry whose driver environment variable is unset is reported SKIP, so the
matrix runs cleanly wherever a given driver is not installed.
Adding a driver quirk
When a driver or server misbehaves on the generic path:
Reproduce it as small as you can, ideally with plain SQLBindCol /
SQLFetch or isql so the report is driver-independent — that reproduction is
also what the upstream record needs.
Add or reuse a flag on struct OdbcReaderOptions in
src/odbc_internal.h, documented with what it does and why.
Detect it in OdbcDetectQuirks (src/odbc_driver.c): match on the driver
name, or, if the name is shared across servers, on a server-identity query.
Set the flag on conn->reader_opts.
Comment the measured reason at the detection site — the driver name, the
observed failure, and numbers where they clarify (before/after rows per second,
the SQLSTATE returned).
Consider exposing it as an adbc.odbc.* override so a user can force it on
an unlisted driver with the same defect (as adbc.odbc.sqllen_32bit does).
Add a compat-matrix case or a tests/test_sqlite.py case that fails before
the fix, and record the quirk in the matrix row.
Running the tests
The core suite talks to a real database through the SQLite ODBC driver, so no
server is needed:
sudo apt install libsqliteodbc # Debian/Ubuntu
python -m venv .venv && .venv/bin/pip install adbc-driver-manager pyarrow
SQLITE_ODBC_DRIVER=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so \
.venv/bin/python tests/test_sqlite.py # prints ALL OK / PHASE2 OK
ADBC_ODBC_DRIVER overrides the path to the driver under test (default
build/libadbc_driver_odbc.so). Other suites: tests/test_plug_and_play.py
(install into a temp prefix, load by the name odbc), tests/test_delegate.py
(native delegation, each case skipping when its native driver or server is
missing), the C unit tests via ctest --test-dir build, and the per-language
smoke tests under tests/rust/, tests/csharp/, tests/r/ and tests/java/.
The python/ package has its own pytest suite (pytest python/tests).
The full compatibility matrix is tests/compat/test_matrix.py (see
Adding a database).
CI (.github/workflows/ci.yml) builds on ubuntu-latest, macos-latest and
windows-latest — plus a Windows x86 (Win32) job that is built Release, because
SQLLEN is 32 bits wide there and an optimiser-only test failure was once
invisible to a Debug job — and on Linux runs the SQLite suite, the Python-package
suite, and a driver-manifest discovery check against both install flavours.
Benchmarks
The benchmark harnesses are in bench/. The governing rule, from
bench/README.md: every number names the host it was
taken on and the host's state while it was taken. The files are split by
operating system, because the driver manager, the drivers and the memory model
differ enough that a Linux figure says nothing about Windows.
Each benchmark file records:
host — OS and version, CPU, RAM, driver manager and version, the ODBC driver
and version per database, and the language runtime versions used;
state — the load before and after, how many other servers were running, and
the CPU governor;
method — rows, repetitions, what is inside the timer, and how the result was
verified (row count, checksum);
the numbers, with the spread (min–max), not only the mean.
A file that says "not yet run" is telling the truth — do not promote a CI build to
a measurement. When you add a number, add its host and state alongside it; a bare
figure with no host is not a measurement anyone can trust or reproduce.
Commit messages
The style in the git log: a lowercase area prefix and a colon, an
imperative or descriptive summary, and a body that explains the measured
reason. The prefix names the area touched — driver:, docs:, bench:. For
example:
driver: Virtuoso's 4-byte indicator stride on Windows (read-side ind_stride_32bit)
docs: Windows reopened for the six obtainable drivers -- 26 pass; two bridge bugs found
bench: ...
Keep one logical change per commit, and keep a reformat-only commit separate from
a behaviour change. Make sure cmake --build build is warning-free and
tests/test_sqlite.py passes before opening a pull request.
prepare — creates (or reuses) a draft GitHub Release for the tag. A
workflow_dispatch run with no tag is a dry run against a draft that the last
job deletes.
linux / macos / windows — build the driver library on each platform
(Linux x86_64 and aarch64 under manylinux_2_28, macOS arm64, Windows x64),
run ctest, build a py3-none-<platform> Python wheel with the library
bundled, repair it (auditwheel / delocate) with the OS's own libodbc
deliberately excluded, and upload the wheel and the bare library
(<rid>.tar.gz) to the Release.
sdist, crate — the Python source distribution and the Rust crate
(cargo package, after verifying the bundled C sources are in sync).
nuget, maven — download the platform libraries from the Release and
pack the NuGet package (runtimes/<rid>/native/…) and the Maven jar
(natives inside).
finish — if every build job succeeded, flip the Release out of draft;
otherwise leave it a draft with whatever built. A dry run deletes its draft.
Files move between jobs through the Release itself, not through Actions artifacts.
A separate publish-pypi.yml publishes a tag's wheels to PyPI through trusted
publishing. v0.1.0 (2026-08-25) shipped four platform libraries, four wheels
plus an sdist, a crate, a NuGet package and a jar.
Licensing
adbcBridge is Apache-2.0. By contributing you agree that your contribution is
licensed under those terms. Every new non-vendored source file starts with the
Apache-2.0 header block ending in SPDX-License-Identifier: Apache-2.0 (use #
comments for CMake, Python, YAML and TOML). Copy the block from an existing file.
Vendored code
vendor/nanoarrow, include/arrow-adbc/adbc.h, src/utils.{c,h} and
src/options.h are vendored from Apache Arrow / Apache Arrow ADBC and kept
byte-identical to upstream; they are excluded from the formatting hooks. Do not
patch them locally — send the fix upstream and re-vendor. A new vendored component
is recorded in NOTICE.
Giving findings back upstream
Driving 53 databases through one driver across three operating systems turns up
defects that belong to other projects. The practice is to report each one upstream
with a reproduction that needs no adbcBridge in the stack, and to keep the whole
record — filed reports and findings documented but not yet filed — in
docs/UPSTREAM.md. When your work uncovers such a defect: record
it there with its first error and the conditions, add a reproduction that stands on
its own, and link the report once filed. Findings already documented and awaiting a
report are marked as such, and a reproduction contributed by anyone is welcome.
Sending a pull request
Fork the repository and branch from main.
Make the change with its test: a driver quirk comes with a matrix entry or a
tests/compat note that shows the symptom, a reader change with a case in
tests/test_sqlite.py, a docs change with the source it cites.
Run cmake --build build && ctest --test-dir build, and python tests/test_sqlite.py
with ADBC_ODBC_DRIVER pointing at the build; if you touched src/, run
rust/sync-csrc.sh so the crate's copy of the sources stays in step (CI checks it).
Open the pull request against main. CI builds on Linux, macOS and Windows and runs
the C, Python, Go, Rust, .NET and Java suites; a maintainer reviews and squash-merges.
Every merged change is credited in the commit history under your own name.
Correcting a note or a matrix row
The write-ups at https://adbcbridge.org/notes/ and the rows of docs/COMPATIBILITY.md
are the same record. If a note is wrong, or a finding differs on a driver version you have,
open an issue with the Driver finding template (or a pull request against
docs/COMPATIBILITY.md / docs/UPSTREAM.md); the note is regenerated from the corrected
record. Security problems go through private vulnerability reporting instead, see
Security.
Reporting bugs
ODBC drivers differ wildly, so a good report names the moving parts: the ODBC
driver and version and the driver manager; the connection string with secrets
removed; the failing SQL and the schema of the columns involved; and the full ADBC
error including SQLSTATE and the native error code. If you can, add a case to
tests/test_sqlite.py that fails before your change. See also the
FAQ and CONTRIBUTING.md.
Security and release verification
Supported versions
Version
Supported
0.1.x (current release)
Yes: security fixes ship as a patch release
main
Yes: fixes land here first
Anything older than the current minor
No
A security fix produces a new release on every registry the project publishes to
(PyPI, crates.io, nuget.org, Maven Central, the Go module and the GitHub Release
binaries) within the same release.
Reporting a vulnerability
Please do not open a public issue for a security problem. Use GitHub's private
vulnerability reporting for this repository:
You will get an acknowledgement within three business days. Confirmed problems get
a fix or a documented mitigation, coordinated with you, with a target of thirty days
from confirmation; the advisory is published with the release that carries the fix,
and you are credited unless you ask not to be.
In scope: the C driver library (src/), the Python, Rust, C#, Java, Go and R
bindings, install.sh, the release workflow and the published packages.
Out of scope, but still wanted: defects in third-party ODBC drivers or database
servers that the compatibility work turns up. Those go to the project concerned;
Upstream is the public record of what has been found
and reported, and a note through the same private channel is welcome if a finding
has a security angle.
Verifying a download
Every GitHub Release, v0.1.0 included, carries SHA256SUMS, a checksum of each
asset, and SHA256SUMS.asc, a detached signature of that file by the project's
release key (fingerprint 1515 33D6 5DDE 6F9D AA04 558C 95CC 478E 1985 A908, on
keyserver.ubuntu.com; the same key signs the Maven Central artifacts):
a build-provenance attestation for every asset, recorded by GitHub's Sigstore
instance, which ties the file to the exact workflow run and commit that built it:
sh
gh attestation verify adbcbridge-v0.2.0-linux-x64.tar.gz --repo singhpratech/adbcbridge
adbcbridge-<tag>.spdx.json, a software bill of materials of the source tree the
release was built from (SPDX 2.3 JSON).
Package registries verify their own uploads: PyPI and nuget.org publications use
trusted publishing from this repository's workflows, and Maven Central checks the GPG
signature above on every file.
Support window
The current minor release is supported: bug fixes and security fixes ship as patch
releases of it, on every registry at once, and main always carries them first.
When a new minor is released the previous one stops receiving fixes; there is no
long-term-support line at this stage of the project. Questions and bug reports go
through GitHub issues; the
FAQ says what a useful
report contains.
Dependencies
Dependabot watches the Python, Rust, NuGet, Maven and Go manifests and the GitHub
Actions this repository uses, and opens pull requests for security updates. The
driver library itself depends only on a C11 compiler and an ODBC driver manager.