Notes · 2026-08-24

Oracle's SQORA driver takes a rowset change, then crashes or loses rows

Oracle Instant Client ODBC 23 (SQORA) accepts SQL_ATTR_ROW_ARRAY_SIZE on an open cursor and then segfaults in bcoReturnColData or silently drops rows; CLOB columns read a row at a time.

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.