A parameter array in Ignite's ODBC driver takes every row's NULL flag from row 0
What you see
Bind a two-column, three-row column-wise parameter array on INSERT INTO t (a, b) VALUES
(?, ?) with SQL_ATTR_PARAMSET_SIZE=3 and indicators {SQL_NTS, SQL_NULL_DATA, SQL_NTS}.
Everything reports success: rc=0, SQL_ATTR_PARAMS_PROCESSED_PTR 3, every entry of the
parameter-status array SQL_PARAM_SUCCESS. Then read row 2 back and b IS NULL is false —
the column holds an empty string. A fixed-width column stores whatever bytes sit in that
row's value slot. On a BINARY or VARBINARY column the client does not get that far:
__memcpy_avx_unaligned_erms
ignite::odbc::query::BatchQuery::MakeRequestExecuteBatch
ignite::odbc::query::BatchQuery::Execute
ignite::odbc::Statement::ExecuteSqlQuery
ignite::SQLExecute
The server is untouched in all three cases. A NULL in row 0 goes the other way and nulls the
whole batch. Row-wise binding is not an escape: SQL_ATTR_PARAM_BIND_TYPE answers HYC00
Only binding by column is currently supported.
What is actually happening
odbc/src/app/parameter.cpp writes a parameter like this:
void Parameter::Write(BinaryWriterImpl& writer, int offset, SqlUlen idx) const
{
if (buffer.GetInputSize() == SQL_NULL_DATA) { writer.WriteNull(); return; }
ApplicationDataBuffer buf(buffer); // the copy...
buf.SetByteOffset(offset);
buf.SetElementOffset(idx); // ...is where the row offset is applied
GetInputSize() reads *GetResLen(), and GetResLen() applies the buffer's own element
offset — still 0 at that point. So the NULL test always inspects row 0's indicator and every
row of the chunk inherits row 0's NULL-ness. A NULL further down is therefore written as a
value: the SQL_CHAR branch reads that row's indicator, -1, and SqlStringToString returns
the empty string for any negative length that is not SQL_NTS, while the SQL_BINARY branch
hands the same -1 to writer.WriteInt8Array as a length and memcpy runs off the end.
The workaround is one execute per row, where the element offset really is 0. Reported on
2026-08-29 as apache/ignite#13537, with the
plain-ODBC reproduction above and parameter.cpp cited; open.
The server rule behind it: no table without a primary key
Every Ignite SQL table is a cache and its PRIMARY KEY is the cache key, so:
CREATE TABLE nopk (a BIGINT, b VARCHAR)
-> 42000 No PRIMARY KEY defined for CREATE TABLE
Generated ingest DDL has no notion of a key, and no column of an arbitrary payload can be
one: Ignite allows neither a NULL key (23000 Null value is not allowed for column 'A') nor
the duplicates an append would then insert (23000 Failed to INSERT some keys because they
are already in cache). Nor can the server fill one in — a DEFAULT must be a constant
(Non-constant DEFAULT expressions are not supported for RANDOM_UUID()), and IDENTITY,
AUTO_INCREMENT and SERIAL are all HYC00 AUTO_INCREMENT columns are not supported. This
is a property of the server, not of its driver, and no client-side workaround exists: write
into a table that declares its own key.
There is no Linux driver to download
Apache ships libignite-odbc.so for Windows only, as two .msi installers; the Linux
binary release carries the C++ sources it would be built from. The image has them under
/opt/ignite/apache-ignite/platforms/cpp, and the ODBC driver alone builds root-free in
about two minutes against system unixODBC and OpenSSL headers with -DWITH_ODBC=ON
-DWITH_CORE=OFF — WITH_CORE=OFF is what keeps it short, since the C++ core module embeds
a JVM and the ODBC driver needs neither JVM nor JNI. On macOS there is nothing to build:
platforms/cpp carries only common/os/{linux,win} and the Darwin build stops at
concurrent_os.cpp:18 fatal error: 'sys/sysinfo.h' file not found.
On Windows the statement text has to go narrow
ignite.odbc.dll (2.18.0, registered as Apache Ignite) exports no W entry points at all,
so the Windows driver manager maps every wide call onto it through the system ANSI code page:
a wide fetch of héllo 🚀 comes back héllo 🚀 and a statement literal 'héllo' matches
nothing, pyodbc identical for both. Its own narrow path is untouched UTF-8 in both directions,
so sending caller statement text through the narrow entry points, and keeping the narrow fetch
route, passes the whole workload and is 2.5× faster: 775,660 rows/s against 316,230.
Related: Five Windows-only ODBC failures are one thing wearing different clothes
Two smaller things worth knowing. SQL_DRIVER_VER and SQL_DBMS_VER are both the hardcoded
02.04.0000, neither the server's version nor the protocol actually negotiated. And the
driver truncates a character parameter to the ColumnSize given to SQLBindParameter,
client-side and with no warning, so pass the real width.
Where this comes from: the adbcBridge compatibility matrix runs one workload through every database's ODBC driver on Linux, macOS and Windows and records each failure with its first error; the row for this database is at https://adbcbridge.org/matrix/#ignite, and the finding is on file with its reproduction in docs/UPSTREAM.md of the repository.