← All notes

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; fixed in PR #308, see the update below.

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; fixed in PR #302, see the update below.

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; fixed in PR #303, see the update below.

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 have fixes.

Update, 2026-09-07: all three are fixed upstream, within a day and a half of being confirmed. F.D. Castel, who maintains the SQLAlchemy dialect for Firebird, reproduced #300 on Windows against Firebird 3, widened it to NUMERIC and DECIMAL (he had hit it through DuckDB's ODBC scanner, where a 485,000-row column stayed NULL), traced it to setTypeText() rewriting the prepared parameter's XSQLVAR with nothing restoring it, and opened PR #302, which describes input parameters from the prepare-time snapshot. The same evening he found the cause of

301, the implementation row descriptor rebuilt on every prepare, and opened

PR #303, two lines plus a regression test on this note's query. Both CI builds were run here on Linux, unixODBC 2.3.12 and Firebird 5.0.4: the two programs from #300 give the expected rows in every shape, his 24-cell matrix goes from six lost cells to none, and the #301 program reports the right counters in all three orderings. A few hours later came PR #308 for #299, the stride defect at the top of this note: the input side now derives the element stride from the C type when BufferLength is 0, as the output side always did, and the array executor is chosen at execute time rather than prepare time. On its build both programs from the report store five distinct rows for every C type with the NULL in the right row; the release build had also been moving a SQL_C_CHAR NULL from row 3 to row 1. He is assembling the merge order for a 3.5.0-rc2 in #312. The workarounds in adbcBridge stay until a release carries the fixes, and parameter arrays go back on for this driver when it does; the matrix row and the tracker will say which release.

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.