Notes · 2026-09-03

Three things Exasol's ODBC driver gets wrong about C types and block cursors

Exasol ODBC 25.2/26.2 refuses SQL_C_BINARY outright (HY003), fails a NULL SQL_DECIMAL parameter bound SQL_C_DEFAULT (SI002), and claims SQL_GD_BLOCK it cannot deliver.

Three defects in Exasol's own ODBC client, measured against Exasol 2025.1.14 with client 25.2.1 on Linux (libexaodbc.so) and 26.2.8 on Windows (EXAODBC.dll). Each is reachable from plain ODBC: two of them fail the whole statement, and the third hands back no data with no diagnostic record.

1. SQL_C_BINARY is refused for every target

Exasol has no binary column type. BLOB is recognised and refused —

[0A000] [Exasol][Exasol Driver]Feature not supported: data type BLOB
        (Session: ...) (-466560) (SQLExecDirectW)

— and VARBINARY(10), BINARY(10), RAW(10) and BYTEA are not words its parser knows (42000, "syntax error, unexpected IDENTIFIER_PART_"). That much is a server limitation you can design around. The driver-side half is not: SQLBindParameter rejects the C type outright, whatever the target column is.

[HY003] [Exasol][Exasol Driver]Invalid application buffer type: SQL_C_BINARY
        (-30139779) (SQLBindParameter)

Measured against a VARCHAR(50) column and again against a HASHTYPE one. SQLGetData with SQL_C_BINARY is refused too, with 07006. So a byte string cannot reach the server by any bound route. What works is SQL_C_CHAR into a VARCHAR: the same bytes store and read back byte for byte (b"\x01\x02""\x01\x02").

2. A NULL parameter described SQL_DECIMAL cannot be bound SQL_C_DEFAULT

The ordinary way to bind a NULL is to ask SQLDescribeParam what the server wants and then bind a NULL pointer with SQL_C_DEFAULT. Exasol answers, at SQLExecute, and fails the whole statement rather than the one parameter:

[SI002]  (-47869058) [Exasol][Exasol Driver]C-Type not supported.
[HY010] (-30139812) [Exasol][Exasol Driver]Error creating prepared statement header.

A raw unixODBC probe, one NULL parameter per statement, isolates which type it is:

column type described as SQL_C_DEFAULT SQL_C_CHAR
DECIMAL(10,3) SQL_DECIMAL (12, 3) SI002 OK
INT SQL_DECIMAL (20, 0) SI002 OK
VARCHAR(50) SQL_VARCHAR OK OK
DATE SQL_TYPE_DATE OK OK
TIMESTAMP(6) SQL_TYPE_TIMESTAMP OK OK
BOOLEAN SQL_BIT OK OK
DOUBLE SQL_DOUBLE OK OK

SQL_DECIMAL alone — which is much wider than it sounds, because Exasol has no narrow integer type. INT, INTEGER, SMALLINT and BIGINT are all aliases of DECIMAL(18,0) / DECIMAL(36,0), and SQLDescribeParam reports SQL_DECIMAL for every one of them, so a NULL in any numeric column takes this path. Bind those as SQL_C_CHAR with a NULL data pointer and they are accepted.

3. SQL_GD_BLOCK is claimed, and SQLGetData works once per SQLFetch

SQL_GETDATA_EXTENSIONS is 0xfSQL_GD_ANY_COLUMN | ANY_ORDER | BLOCK | BOUND. On the strength of that you would bind a wide text column into a modest buffer and repair clipped values with SQLSetPos + SQLGetData. On a five-row rowset with a 32-byte buffer, one row carrying a 100-character value:

row 1 bound='v0'                   indicator=2
row 3 bound='W...................' indicator=100     <- clipped
SQLSetPos(2) rc=0  SQLGetData rc=0            value='v1'
SQLSetPos(3) rc=0  SQLGetData rc=SQL_NO_DATA  value=''   <- the clipped row
SQLSetPos(4) rc=0  SQLGetData rc=SQL_NO_DATA  value=''   <- and the cursor stays there

A plain-ODBC probe on 2026-09-04, C against unixODBC, isolated the actual rule, and truncation is not part of it: only the first SQLGetData after each SQLFetch succeeds, whichever row SQLSetPos selected. With no long value anywhere, rows 2–5 still answer SQL_NO_DATA — with no diagnostic record. With the clipped row visited first it returns its full 100 characters and the short rows after it fail. With SQL_ATTR_ROW_ARRAY_SIZE 1 and one SQLFetch per row, every row reads fine. SQLFetch resets the driver's per-column "already returned" state and SQLSetPos does not.

The driver's own capability bits give no supported way round it either: SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1 is 0x0 — not even SQL_CA1_NEXT, let alone SQL_CA1_POS_POSITION — yet SQLSetPos(SQL_POSITION) returns SQL_SUCCESS rather than refusing. Block fetching into bound columns is correct throughout: right data, right indicators, a proper 01004 on the truncated row. The workaround is to leave a long column unbound, at a real cost — 1.54M rows/s against the 3.32M/s the wrong bound read managed. It is not a corner case, because SQLGetTypeInfo(SQL_LONGVARCHAR) names LONG VARCHAR, which is VARCHAR(2000000) here, so an ordinary generated table has an unbindable text column in it.

One server-side trap worth the same page

exadt records the container's Docker address in /exa/etc/EXAConf (PrivateNet = 172.17.0.2/16 on a default bridge) the first time the volume is initialised, and refuses to start when the container later comes up on a different one — the private interface with address '172.17.0.2/16' either does not exist or is down, container Exited (1). Any reboot where another container claims that address first is enough. Fix it without re-initialising: edit PrivateNet to the address docker inspect now shows, set Checksum = COMMIT (the file refuses a silent edit), and restart. A fixed address on a user-defined network avoids it.

Status

Found on 2026-09-03; all three were reproduced on 2026-09-04 in C against unixODBC, with no bridge code in the stack. They are recorded in docs/UPSTREAM.md under "documented here, not yet reported"; no vendor issue is filed yet.

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/#exasol, and the finding is on file with its reproduction in docs/UPSTREAM.md of the repository.