Notes · 2026-08-29

A parameter array in Ignite's ODBC driver takes every row's NULL flag from row 0

Apache Ignite's ODBC driver drops a NULL below the first row of a column-wise parameter array - a character column stores an empty string, a BINARY column segfaults the client inside SQLExecute.

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=OFFWITH_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.