A bound NULL becomes an empty string, and a parameter array stops at set 1
Writing through clickhouse-odbc
Reading through clickhouse-odbc 1.5.5 is uneventful. Writing is where the surprises are, and
each of the three below can pass unnoticed: the call returns SQL_SUCCESS and the wrong
data, or no data, is what lands in the table. They were found running one workload through
the driver against ClickHouse 26.7.5.10 and reported on 2026-08-29.
A bound NULL is sent as an empty string
SQL_NULL_DATA is ignored whenever a value buffer is bound to the parameter. The driver
sends the parameter empty instead of null, and what happens next depends on the column type:
Stringsilently stores'';DateTime64silently stores the epoch;Int32,Float64,Date,DecimalandBoolare rejected by the server with a parse error, so at least you find out.
SQL_DEFAULT_PARAM behaves the same way. What works is binding the parameter with a NULL
value pointer — then the null reaches the server as a null. That is the workaround, and it
is the one the compatibility entry uses.
Two related facts make this harder to spot than it should be: SQL_DESCRIBE_PARAMETER is
N, and SQLDescribeParam answers SQL_UNKNOWN_TYPE, so a client cannot ask the driver
what it is about to do with a parameter.
A five-set parameter array lands two rows
Parameter arrays here are not ODBC's. The driver implements its own protocol, described in
clickhouse-odbc#324: SQLExecute sends set 0, and each SQLMoreResults sends the next set. On 1.5.5 that sequence breaks
after one step. The first SQLMoreResults does send set 1 — and then answers SQL_NO_DATA,
after which nothing advances. A five-set array therefore lands two rows, under
SQL_SUCCESS followed by SQL_NO_DATA, with no diagnostic saying anything was dropped. A
client that follows the specification instead — one SQLExecute for the whole array — lands
one row.
Both defects were filed together, each with a standalone C program, as ClickHouse/clickhouse-odbc#582, open.
The NULL half sent upstream as a fix on 2026-09-18: ClickHouse/clickhouse-odbc#586.
Two lines in driver/statement.cpp decided whether a parameter is NULL from the value pointer
alone and never looked at the indicator; a small helper now checks both, and four new tests
(two against the request the driver builds, two against a live server) fail on master and
pass with the change. The parameter-array half is a different mechanism, SQLMoreResults
reporting result sets rather than parameter sets, and stays open for its own pull request.
Update, 2026-09-24: the NULL half is merged. slabko approved and merged
#586 into master (eb0fdb2654),
six days after it was opened, with no changes asked for.
It is not in a release yet. The reproducer was re-run against the current release,
1.5.5.20260810, on 2026-09-24 against ClickHouse 26.7.5.10, and still shows all five
problems — the String column storing '' and the numeric, date and decimal parameters
failing server-side. So the advice below stands for anyone on a released driver, and this
line becomes "fixed in <release>" when a tag carries it.
Issue #582 stays open, because it reported two defects and this fixes one. The parameter-array half needs its own change, and the offer to prepare it is on the pull request.
Update, 2026-09-29: the parameter-array half is merged too. slabko approved and merged
#588 into master (74027bcf6e),
five days after it was opened. SQLExecute now sends every parameter set of a statement that
returns no result set, and stops at the first set that does, so a SELECT keeps handing its
result sets back one per SQLMoreResults. The review asked for the ODBC error-path contract,
and the merged change carries it: SQL_ATTR_PARAMS_PROCESSED_PTR counts the failing set and
SQL_ATTR_PARAM_STATUS_PTR reads {SUCCESS, SUCCESS, ERROR, UNUSED, UNUSED} on a five-set
insert whose third value overflows an Int16. Both cases are tests in the driver's suite.
Verified against ClickHouse 26.7.5.10 on a driver built from the merged change: a five-set
insert lands five rows, a three-set SELECT returns three result sets, and 391 of the
project's 392 tests pass (the one failure needs CREATE USER and fails the same way on the
release). With #586 and #588 together, every problem in this note's two reproducers is fixed
on master. Neither is in a release yet — the latest tag is still 1.5.5.20260810 — so the
advice below holds for the shipped driver, and both lines become "fixed in <release>"
when a tag carries them.
On the released driver, treat parameter arrays as unusable here: the compatibility matrix
turns them off for this driver and batches rows into a multi-row INSERT instead.
No affected-row count, since 2021
SQLRowCount answers 0 for every write. Not −1, which ODBC defines as "not available" — 0,
which a caller reads as "nothing was written". That has been open since 2021, and it still
reproduces on 1.5.5 against 26.7.5.10 —
ClickHouse/clickhouse-odbc#335 (comment),
which also notes that the server already sends written_rows in the X-ClickHouse-Summary
header on every HTTP response by default, so the number the driver would need is already
arriving.
What was asked. The comment re-measured the 2021 report on 1.5.5, showed the 0 on every
write, and asked for either of two things: −1, so that a caller can at least tell "unknown"
from "nothing written", or the real number, since written_rows is already in every
response header.
What the maintainer said, 2026-09-15. Returning 0 is wrong and will become −1. The header
is not the answer yet: with async_insert on, the server acknowledges before the rows are
written and written_rows cannot be trusted (ClickHouse/ClickHouse#57768). With
async_insert off it should be usable; how to handle the two modes is still open.
Fixed on 2026-09-15 in ClickHouse/clickhouse-odbc#585,
merged the same day it was opened. The change is small and exact: the statement's
SQL_DIAG_ROW_COUNT now starts at −1 instead of 0, in the two places it is reset and in
SQLRowCount itself, with a test that inserts three rows and expects −1. Verified on the pull
request's CI build against ClickHouse 26.7.5.10: −1 after a literal INSERT and after a
DELETE, everything else in the compatibility entry unchanged. #335 is closed after five years.
What it means through adbcBridge. After an INSERT, UPDATE or DELETE executed as a
statement, the affected-row count is −1, the ADBC "unknown", instead of a 0 that looked like
a fact. The bulk-ingest count is unchanged: it comes from the rows adbcBridge sent, not from
the driver. It ships in the first clickhouse-odbc release after 1.5.5; until then the
compatibility row says 0 for the release and −1 for master. The exact count stays on the
table for when async_insert is off.
One HTTP request per execute, and what that costs
The driver talks to the server over HTTP, one request per execute. With one row per
statement that is about 16 rows per second — the floor is the request, not ClickHouse.
Batching rows into a single multi-row INSERT is what moves it: the same 300-row ingest runs
at 911 rows per second, a 57x difference, and around 1,000 rows/s in the general case.
Two limits shape the batch. The server's HTTP form parsing caps a request at 1,000 fields,
which bounds how many rows one INSERT can carry. And the driver will happily SQLPrepare a
500-row-group INSERT and then refuse to execute it, so a client has to discover the real
ceiling by halving — 500 down to 125 for a four-column table — rather than by asking.
None of this needs a driver change to work around; it needs the client to stop sending one row per round trip.
What to take away
If you write to ClickHouse through ODBC on 1.5.5: bind nulls with a NULL value pointer, do
not trust parameter arrays, do not trust SQLRowCount, and batch your inserts. All four are
things a caller can do today; three of them are on file upstream, two with reproductions that
need nothing but a C compiler and unixODBC.
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/#clickhouse, and the finding is on file with its reproduction in docs/UPSTREAM.md of the repository.