Arrow type fidelity¶
What Arrow type each engine's driver returns for a declared OBML type. A
semantic layer's promise is that a measure declared decimal(18, 2) arrives as
an exact fixed-point number; whether it does is a property of the driver and its
cast rendering, not of the SQL.
Measured 2026-09-06 with scripts/probe_types.py, against all eight engines
live. Each case is rendered through the engine's own cast_to_obml_type, so
this is what OBSL emits rather than a hand-spelled approximation.
uv run python scripts/probe_types.py all # human-readable
uv run python scripts/probe_types.py --json all # regenerate the data below
Verdicts¶
| Verdict | Meaning |
|---|---|
EXACT |
The declared type came back unchanged. |
WIDENED |
Still fixed-point, at a wider precision or scale. An engine widening a SUM is doing the right thing; a widened cast is the engine's own decimal rules, recorded rather than judged. |
FAMILY |
Right family, different width - an int64 where the model said integer. |
ZONED |
A timestamp came back carrying a timezone. OBML's timestamp is a wall clock. |
TEXT |
A number carried as a string with every digit intact - ADBC does this for Postgres NUMERIC, whose precision Arrow's decimal cannot hold. db_executor parses the cells back to Decimal, so nothing is lost. |
LOSSY |
The fixed-point type became a float, or a string that does not carry the digits. This is the one that costs data. |
The matrix¶
| Declared | DuckDB | Postgres | MySQL | ClickHouse | Snowflake | BigQuery | Databricks | Dremio |
|---|---|---|---|---|---|---|---|---|
decimal(18,2) |
EXACT | TEXT | WIDENED | EXACT | WIDENED | WIDENED | EXACT | EXACT |
decimal(38,9) |
EXACT | TEXT | WIDENED | EXACT | EXACT | EXACT | EXACT | EXACT |
decimal(19,2) big |
EXACT | TEXT | WIDENED | EXACT | WIDENED | WIDENED | EXACT | EXACT |
SUM decimal(18,2) |
WIDENED | TEXT | WIDENED | WIDENED | WIDENED | WIDENED | WIDENED | WIDENED |
integer |
EXACT | EXACT | FAMILY | EXACT | FAMILY | FAMILY | EXACT | EXACT |
bigint |
EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT |
double |
EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT |
string |
EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT |
boolean |
EXACT | EXACT | LOSSY | EXACT | EXACT | EXACT | EXACT | EXACT |
date |
EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | EXACT | FAMILY |
timestamp |
EXACT | EXACT | EXACT | ZONED | EXACT | EXACT | EXACT | EXACT |
Arrow types returned¶
| Declared | DuckDB | Postgres | MySQL | ClickHouse | Snowflake | BigQuery | Databricks | Dremio |
|---|---|---|---|---|---|---|---|---|
decimal(18,2) |
decimal128(18, 2) |
extension<arrow.opaque[storage_type=string, type_name=numeric, vendor_name=PostgreSQL]> |
decimal256(76, 2) |
decimal128(18, 2) |
decimal128(38, 2) |
decimal128(38, 9) |
decimal128(18, 2) |
decimal128(18, 2) |
decimal(38,9) |
decimal128(38, 9) |
extension<arrow.opaque[storage_type=string, type_name=numeric, vendor_name=PostgreSQL]> |
decimal256(76, 9) |
decimal128(38, 9) |
decimal128(38, 9) |
decimal128(38, 9) |
decimal128(38, 9) |
decimal128(38, 9) |
decimal(19,2) big |
decimal128(19, 2) |
extension<arrow.opaque[storage_type=string, type_name=numeric, vendor_name=PostgreSQL]> |
decimal256(76, 2) |
decimal128(19, 2) |
decimal128(38, 2) |
decimal128(38, 9) |
decimal128(19, 2) |
decimal128(19, 2) |
SUM decimal(18,2) |
decimal128(38, 2) |
extension<arrow.opaque[storage_type=string, type_name=numeric, vendor_name=PostgreSQL]> |
decimal256(76, 2) |
decimal128(38, 2) |
decimal128(38, 2) |
decimal128(38, 9) |
decimal128(28, 2) |
decimal128(38, 2) |
integer |
int32 |
int32 |
int64 |
int32 |
int64 |
int64 |
int32 |
int32 |
bigint |
int64 |
int64 |
int64 |
int64 |
int64 |
int64 |
int64 |
int64 |
double |
double |
double |
double |
double |
double |
double |
double |
double |
string |
string |
string |
string |
string |
string |
string |
string |
string |
boolean |
bool |
bool |
int64 |
bool |
bool |
bool |
bool |
bool |
date |
date32[day] |
date32[day] |
date32[day] |
date32[day] |
date32[day] |
date32[day] |
date32[day] |
date64[ms] |
timestamp |
timestamp[us] |
timestamp[us] |
timestamp[us] |
timestamp[ms, tz=Europe/Berlin] |
timestamp[ns] |
timestamp[us] |
timestamp[us] |
timestamp[ms] |
What the table measures, and what OBSL delivers¶
Every row above is a measurement of a driver. That is not the same as what a caller receives, because OBSL reconciles a result against the type the model declared before anyone sees it. Four rows read alarming and are not.
Postgres TEXT on every decimal costs nothing. ADBC represents NUMERIC as
arrow.opaque[storage_type=string, type_name=numeric] because Postgres NUMERIC
is arbitrary precision with NaN and Infinity, Arrow's decimal128 caps at 38
digits and needs the scale up front, and typmod is -1 for a computed
expression. Preserving the exact digits as text is the faithful choice - it is
what lets a NUMERIC wider than Arrow's 38 digits survive at all - and
db_executor parses those cells back to Decimal before any caller sees them.
Measured: 12345678901234567.89::numeric(19,2) comes back
Decimal('12345678901234567.89').
This row read LOSSY until 2026-09-06, which contradicted this page's own
definition of the verdict: nothing became a float, and the string carries every
digit. It has its own verdict now.
ClickHouse ZONED on timestamp is intrinsic. ClickHouse has no naive
DateTime - the type is an instant rendered against the server timezone. Every
OBSL surface reconciles it against the declared wall clock; see the Flight
alignment in ob_flight/server_execution.py.
MySQL LOSSY on boolean and Dremio FAMILY on date are reconciled.
MySQL has no boolean type, so a declared one arrives as int64 1; Dremio
returns date64[ms] where the other seven give date32[day]. Both are still
true of the drivers, and neither reaches a caller: service/result_schema.py
casts them to the declared type on every surface.
MySQL, a dimension declared boolean
driver returns int64 1, 0
REST returns boolean true, false
pgwire advertises OID 16 t, f
Reconciliation is an allowlist of exactly those two cases, not a general cast.
A resultType names a family, so an engine answering more precisely than the
declaration - a decimal(38, 2) behind a measure declared float - is not
drift and is left alone. Timestamps are excluded too: ClickHouse's zoned-for-
naive case needs the wall clock preserved value by value rather than converted.
When reconciliation is refused¶
A declared boolean holding something other than 0, 1 or NULL is not cast:
Arrow maps every nonzero to true, and asserting that a column holding 7 is
true invents content the model never claimed. The column keeps the engine's
type, the response reports that type rather than the declared one, and the
declaration is carried as a warning:
{
"code": "DECLARED_TYPE_NOT_APPLIED",
"message": "Column 'Tier' was not reconciled to its declared type: declared boolean
but the column holds values other than 0 and 1; left as int64"
}
So type always describes the bytes beside it, and warnings tells you where
the model wanted otherwise.
What CI asserts¶
Every type defect OBSL has actually shipped lived between the driver and the caller, not in the driver - the cache codec typing a column from the values it happened to hold (#410), the executor never importing pyarrow so no result carried a schema at all (#412), the same reconciliation missing from one read path at a time (#414), and one cache entry meaning two different things depending on which surface wrote it (#429) - and a driver-level probe is blind to all of them.
What a cache entry holds¶
The blob is the driver's Arrow table, so a cached column has the type the
warehouse gave it: timestamp[us] stays timestamp[us], date32 stays
date32, binary stays binary. Serialisation to the JSON shapes below
happens on delivery, once, in the same function for a hit and a miss.
It was not always so. REST and pgwire used to encode their already-serialised
rows, storing an ISO string for a timestamp and base64 for binary, while
Flight stored its table verbatim - in the same shared key. So format=arrow
returned a string column under an envelope that called it a datetime, and
which types you got depended on which surface ran the query first.
So tests/integration/test_type_fidelity.py asserts through
db_executor.execute_sql, the path REST, pgwire and the CLI share, on DuckDB -
the one engine needing no credentials, and therefore the one every CI machine
can reach. The other seven stay in this table rather than in CI: a suite that
skips six of eight rows reports green for a matrix nobody measured.
Two structural guards cover what those defects had in common, both of which
were one rule duplicated across surfaces: the Arrow-to-hint mapping is a single
function rather than one per surface (asserted by identity, in the Flight
driver's suite), and any code rebuilding a cached result must reconcile the
table and hand its findings to the response
(tests/architecture/test_cached_hits_reconcile.py).