Oracle's SQORA driver takes a rowset change, then crashes or loses rows
What this covers
Findings against Oracle Instant Client ODBC 23 (libsqora, the SQORA driver) driving
Oracle 23ai. The macOS environment finding is from the 2026-08-24 run; the rowset and
type findings from the 2026-08-25 campaign. The rowset one was reproduced with plain
SQLBindCol/SQLFetch, with nothing of the bridge on the stack.
The rowset is fixed for the life of a cursor, and nothing says so
SQL_ATTR_ROW_ARRAY_SIZE is meant to be changeable mid-cursor. On SQORA the change is
accepted — SQLSetStmtAttr succeeds and SQLGetStmtAttr reads the new value back — and
then goes wrong in one of three unannounced ways.
Raising it segfaults inside libsqora on a later SQLFetch, in bcoReturnColData
through bcoCacheFetch: a per-rowset slot sized at execute time. It happens on a
(NUMBER, CLOB) cursor and equally on cursors with no LOB at all, and whether a given
raise dies depends on how deep the cursor already is — 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 rows depending on where the change falls, with
SQL_NO_DATA and no diagnostic.
The only safe rule is to settle the rowset before the first fetch and never touch it
again. That has a cost, because the usual repair for a value that outgrew its bound buffer
is unavailable here too: above an array size of 1, SQLGetData answers HY109 and
SQLSetPos HY109, and SQLFetchScroll answers 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 CLOB is read one row at a time, and you can see it from any language
A column with no real declared width — CLOB, NCLOB, BLOB, LONG, all reported as
2,147,483,647 wide — has no real size to bind at, and given the above a bound value
that was truncated cannot be repaired. It has to stay unbound and be read a row at a time with SQLGetData. This is not
theoretical: generated ingest DDL spells an Arrow string as CLOB on Oracle, and the
resulting four-column read runs at 66k–122k rows/s against 1.1M–2.4M rows/s for
the same four columns from servers whose strings are ordinary VARCHAR.
That gap is the driver's, not any binding's. The five bindings stay
within 1.9× of each other while paying it, and Rust's odbc-api reads the same result set
at 136,334 rows/s with arrow-odbc at 145,299 — the same band. Result sets with no such
column keep the full block cursor.
NLS_LANG=.AL32UTF8 has to be set before the first Oracle call
Without it, non-ASCII text is not merely misread — the corruption is written, and the
server stores U+FFFD. On Linux, setting it in-process before SQLDriverConnect is enough.
On macOS it is not: the variable has to be in the environment before libsqora loads, so
it must be exported before the process starts. A harness that set it in-process was too
late, and the Unicode step failed with hello ?.
No SQL_C_SBIGINT, on either side
64-bit integers have no working C type here. SQL_C_SBIGINT parameters are refused
silently, reads are refused with 07006, and SQLGetTypeInfo(SQL_BIGINT) returns nothing
at all. The workaround is to send and receive 64-bit integers as numeric text described
SQL_NUMERIC, which is what the bigint_param_as_string quirk does for both Oracle and
Virtuoso.
A related precision trap sits next to it: an unconstrained NUMBER column is described
SQL_FLOAT and read through a double, so 9223372036854775807 comes back as
9223372036854780000. The same value in a NUMBER(19) column is exact. And the empty
string is NULL on both the literal and the bound path.
Write through multi-row VALUES, not through parameter arrays
Oracle 23.26 takes the standard multi-row VALUES (…),(…) form, prepared or direct, so
the INSERT ALL INTO t VALUES (…) INTO t VALUES (…) SELECT 1 FROM dual fallback — the
form Oracle releases without a multi-row VALUES need — is never reached there; worth
knowing before writing that fallback into your own code. Take the plain form when you can
get it: SQORA accepts a parameter array and then abandons it part way through the batch,
so a caller using arrays is paying close to one execute per row. Switching this workload to the multi-row form was a 50–86× ingest gain on
Oracle: 475 rows/s to 23,628 at one batch size, 514 to 44,008 at another.
None of these has a vendor issue filed. They sit in the "documented here, not yet reported" table of docs/UPSTREAM.md with their conditions and first errors, so there is no fix version to name; a reproduction contributed by anyone is welcome.
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/#oracle, and the finding is on file with its reproduction in docs/UPSTREAM.md of the repository.