Notes · 2026-08-25

Where PostgreSQL-wire servers part company with PostgreSQL

Seven ways CrateDB, QuestDB, Materialize, RisingWave, ArcadeDB, Spanner and YDB answer psqlodbc 16 differently from PostgreSQL 16, each with the error text it produces.

The same driver, seven servers, seven surprises

Speaking the PostgreSQL wire protocol is not the same as behaving like PostgreSQL. Running one identical workload through psqlodbc against seven servers that speak that wire — and against stock PostgreSQL 16 as the control — showed exactly where each of them parts company. Every item below was found in the 2026-08-25 campaign or the work around it; the ones that were filed were filed on 2026-08-29 and 2026-09-01.

CrateDB: one command tag for a whole multi-statement query

With autocommit off, psqlodbc sends BEGIN;<statement> as a single query string. PostgreSQL answers with a CommandComplete per statement — BEGIN, then INSERT 0 1. CrateDB tagged each result with the leading text of the whole string instead: BEGIN;INSERT 0, BEGIN;INSERT 1. A tag-parsing client gets no row count, so SQLRowCount succeeded without writing its out-parameter for any write or DDL inside a transaction.

Filed as crate/crate#20085. Fixed by crate/crate#20088, merged 2026-08-31 and shipping in 6.4.4.

QuestDB: a binary column truncated at its first NUL

A BINARY value round-trips only as long as it holds no 0x00. QuestDB returns a BYTEA column with format code 1 — raw bytes — even in the simple query protocol, where PostgreSQL always sends text. psqlodbc reads the field as a NUL-terminated string and hands back only the bytes before the first 0x00, silently, under plain SQL_SUCCESS, whatever the C type or binding: 0x0001 comes back empty, 0x010002 as 01. The server keeps the whole value — its own length() reports the full length. psqlodbc is not alone: psycopg 3 over libpq reads the same simple-query field as text too, since the protocol promises text there. Any bound parameter, which forces the extended protocol, returns all three bytes.

Filed as questdb/questdb#7566; fix PR questdb/questdb#7569 open. Found on the way: DEALLOCATE ALL fails with

Cannot invoke "io.questdb.cutlass.pgwire.PGPipelineEntry.close()" because "pe" is null

even on a fresh autocommit connection with nothing prepared, while a named DEALLOCATE works — questdb/questdb#7567, fix PR questdb/questdb#7568 open.

Materialize: DEALLOCATE "Name" does not unquote

psqlodbc names every server-side statement _PLAN0x<statement-handle address> — mixed case, so it must be quoted. Materialize does not unquote the identifier before looking it up, so a statement prepared at the protocol level cannot be released:

26000 prepared statement "\"_PLAN0x7\"" does not exist

Inside a transaction that failure aborts it, which is the server half of a row-loss pair with a psqlodbc defect. PostgreSQL 16 releases the same statement. Filed with a psycopg side-by-side as MaterializeInc/materialize#38605; fix in review as MaterializeInc/materialize#38606, opened 2026-09-01, which unquotes prepared-statement, portal and cursor names before lookup.

Related: Two psqlodbc bugs that lost rows and narrowed integers, and two traps

RisingWave: a statement name outlives its statement

Over the extended protocol, RisingWave 3.0 keeps a server-side statement name after the client closes the statement, so psqlodbc's _PLAN0x<handle> names collide the second time the same handle address is reused:

XX000 Failed to prepare the statement: Duplicated statement name

The ordinary allocate/prepare/execute/free loop hits it on its second query, with or without parameters; handles kept open concurrently get distinct names and all succeed, and SQLExecDirect never collides. Setting psqlodbc's UseServerSidePrepare=0 — the simple protocol has no statement names — avoids it entirely. Documented here, not yet filed.

ArcadeDB: a timestamp literal stored as NULL

'2024-02-29 13:45:10.123456' written into a DATETIME_MICROS property is stored as NULL, silently, under SQL_SUCCESS. The ISO-8601 form '2024-02-29T13:45:10.123456' round-trips to the microsecond. This is not an exotic corner: the space form is exactly how psqlodbc renders a bound SQL_TYPE_TIMESTAMP parameter, so an ordinary bound timestamp hits it. Write timestamps as ISO text against this server. In the same family, a NULL BOOLEAN arrives over the wire as false with a non-NULL indicator.

Spanner's emulator: a killed client wedges it

A read-write transaction left open by a client that was killed wedges the Spanner emulator: every later connection is refused with FATAL: UNAVAILABLE. It was recorded when a benchmark run hung after the compatibility pass (the emulator reporting cross-database references are not implemented) and killing it left nothing able to connect. The entry's own passes were recorded on fresh emulators; recreate it.

YDB: a driver-version incompatibility

psqlodbc 18.00.0002 sends SHOW DateStyle; at connect, and YDB's PostgreSQL layer answers unrecognized configuration parameter "datestyle", so the connect fails. psqlodbc 16 does not issue it, and the YDB entry passes through 16 everywhere.

Related: Two psqlodbc bugs that lost rows and narrowed integers, and two traps

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