Notes · 2026-08-28

Three ways Firebird's ODBC driver loses your parameters without saying so

Firebird ODBC 3.0.1 and 3.5.0-rc1 stride fixed-type parameter arrays by BufferLength, write NULL for every SQL_C_SBIGINT after a character bind, and discard ROWS_FETCHED_PTR set before SQLPrepare.

Firebird ODBC — libOdbcFb.so on Linux, FirebirdODBC.dll on Windows, the same driver answering SQL_DRIVER_NAME as OdbcFb and FirebirdODBC respectively — has three defects in this area: two in parameter binding, one in statement attributes. All three reproduce on 3.0.1 and on 3.5.0-rc1, in plain ODBC, with plain-ODBC C programs and no ADBC in the picture.

1. Column-wise parameter arrays step fixed C types by BufferLength

The ODBC specification says BufferLength is ignored for fixed-length C types; the stride for a column-wise array of them is sizeof(the C type). OdbcFb steps them by BufferLength anyway, for SQL_C_SLONG, SQL_C_SBIGINT, SQL_C_DOUBLE, dates and timestamps alike. Every row receives row 1's values, and the NULL indicator lands in the wrong row, with SQL_SUCCESS throughout. SQL_C_CHAR arrays are correct.

There is no diagnostic to catch and no status element to check — the driver accepts SQL_ATTR_PARAMSET_SIZE, executes what looks like the whole array, and the data is quietly wrong. Row-wise binding (SQL_ATTR_PARAM_BIND_TYPE set to the row struct's size) works around it; turning parameter arrays off entirely and sending one row per execute is the safer choice for a general client, which is what the matrix entry does. FirebirdSQL/firebird-odbc-driver#299, open.

2. A NULL BIGINT bound as character poisons the parameter for good

SQL_BIGINT is the one SQL type whose default C type in the ODBC specification is not the matching SQL_C_SBIGINT but SQL_C_CHAR. So a NULL bound with SQL_C_DEFAULT on a SQL_BIGINT parameter arrives as a character parameter. OdbcFb takes that literally, retypes the parameter, and never re-derives it on the next SQLBindParameter: every later SQL_C_SBIGINT rebind of that parameter writes NULL. The same pattern on INTEGER is fine.

What it looks like from the outside — every 64-bit integer after the first NULL in a column written as NULL, silently:

ingested [0, 1000000007, 2000000014, 3000000021, 4000000028, None, 6000000042, …]
read back [0, 1000000007, 2000000014, 3000000021, 4000000028, None, None, None, …]

The fix on the client side is to bind NULLs of SQL_BIGINT as SQL_C_SBIGINT explicitly rather than letting SQL_C_DEFAULT resolve them, which is what a NULL of every other integer type already resolves to. A test payload that puts a NULL in the last row will never catch this; put a value after every NULL. FirebirdSQL/firebird-odbc-driver#300, open.

3. SQL_ATTR_ROWS_FETCHED_PTR set before SQLPrepare is discarded

SQLPrepare and SQLExecDirect throw away SQL_ATTR_ROWS_FETCHED_PTR and SQL_ATTR_ROW_STATUS_PTR set on the statement before them, so a block cursor never learns how many rows a fetch returned. Set after them, both work. The order is the whole workaround. FirebirdSQL/firebird-odbc-driver#301, open.

The DDL choice that decides how a string column reads

SQLGetTypeInfo(SQL_LONGVARCHAR) names BLOB SUB_TYPE TEXT, so a client that generates its CREATE TABLE from the driver's own type table will spell an unbounded string column that way. OdbcFb reads a BLOB one row at a time. On the language harnesses' first pass a 100,000-row table with such a column read back at 5.5k–7.7k rows/s, and odbc-api at the same 7.5k; spelling the column VARCHAR(8191) instead, all five agree at 259k–301k rows/s on fetch and 34k–40k on ingest, where they had been 5–8k both ways. One earlier run put the same contrast at 8,256 rows/s against 1,004,277, and ingest at 5,705 against 40,670.

That extreme could not be reproduced afterwards — on 2026-08-28 a BLOB column and a VARCHAR column both read at 300k+ rows/s, in plain ODBC and through the bridge, on 3.0.1 and 3.5.0-rc1 — so treat the exact ratio as measurement-dependent rather than as a fixed property of the driver. The DDL choice stands on its own merits regardless: a VARCHAR(8191) is legal in any character set, can be filtered and indexed, and reads through ordinary bound columns. Its price is that values over 8,191 characters are refused on insert.

Two more things to know

Firebird's dialect has no multi-row VALUES-104 Token unknown at the second row-group's comma — and no Oracle-style INSERT ALL. The third form it does take is INSERT INTO t (cols) SELECT CAST(? AS <type>), … FROM RDB$DATABASE UNION ALL SELECT …. A bare ? alone in a select list has no type Firebird can infer, hence the CAST. The engine allows 256 relation contexts per statement, which caps such a statement at about 250 row-groups.

And there is no macOS build: the driver's releases ship Linux and Windows assets only, so the macOS column of the matrix records the entry as driver-unavailable rather than as a failure. Bootstrapping a test server has its own obstacle — Firebird 5's sample security database ships with no SYSDBA, and creating one needs an administrator.

Upstream

All three defects were found on 2026-08-28 and reported the same day, each with its plain-ODBC reproduction and both driver versions' output: FirebirdSQL/firebird-odbc-driver#299, FirebirdSQL/firebird-odbc-driver#300 and FirebirdSQL/firebird-odbc-driver#301. All three are open.

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