Developing on DuckDB, deploying elsewhere¶
DuckDB is the backend you can run on a laptop. It needs no credentials, it starts in memory, no query costs you anything, and a test suite can hold a whole semantic view in a fixture. So it is where most Semolina code gets written first, and How to test query code without a warehouse builds a test fixture around exactly that.
It is also the only backend many test suites ever run against, and that is where the gap opens. Semolina makes the three backends agree on column names and on which rows a query returns. It does not make them agree on the types your sample data never exercises or on the exception classes a driver raises, and a DuckDB-only suite can’t see either. This page covers what still differs, what has been measured, and what has not.
The same query, one set of column names¶
Left to itself, a warehouse names each result column after the expression it computed.
Two independent decisions inside the warehouse would produce that name: it wraps a metric
in whatever function its dialect uses to evaluate one, and it folds unquoted identifiers to
its preferred case. Semolina takes the choice away by aliasing every selected column to its
Python field name. Take Sales.query().metrics(Sales.revenue).dimensions(Sales.country):
Backend |
Metric wrapper |
Identifier folding |
Select list Semolina sends |
|---|---|---|---|
Snowflake |
|
upper case |
|
Databricks |
|
lower case |
|
DuckDB |
none |
lower case |
|
The aliases are quoted, so the warehouse keeps their case, and a
Row takes its keys from them: revenue and country on
all three. row.revenue and row["country"] read the same column whichever backend ran
the query, and so does a Pydantic DTO with plain field names.
So column names do not differ. The rest of this page is about what still does.
Fact queries return the same rows¶
A query that selects facts and no metric is grouped on Snowflake and Databricks with
GROUP BY ALL, so it comes back with one row per distinct combination of values.
DuckDB’s semantic_view() returns facts one row per source row, so Semolina sends
SELECT DISTINCT for a DuckDB fact query, and the three backends return the same rows.
What still differs is what DuckDB will accept: semantic_view() can’t select facts and
metrics together, so Semolina refuses that combination with ValueError on DuckDB and
sends it to Snowflake and Databricks as written.
Decimals are not a DuckDB difference¶
Decimals look like another DuckDB difference and are not. A DECIMAL column arrives as a
decimal.Decimal on DuckDB and on Snowflake – both measured, the Snowflake
observation coming from a recorded decimal128(38, 0) result. On Databricks it does too
from version 0.2.0 of the ADBC driver; earlier releases delivered decimals as Arrow strings.
See Databricks decimals and intervals.
Treat a decimal metric as arriving as a Decimal on every backend, then:
json.dumps on a row holding one raises TypeError on your laptop as readily as in
production.
What makes it look DuckDB-specific is the sample data. The database the tutorial builds
declares revenue as an INTEGER, so nothing computed over it is ever a
Decimal, and serialization code written against those examples has never met the
type it will meet against your warehouse. The forgiving thing is the fixture, not the
engine.
So switching to Snowflake will not introduce this bug and staying on DuckDB will not
protect you from it. What surfaces it is a decimal column, whichever backend holds one.
Warehouse types and what lands in your row covers which columns arrive as a Decimal and why the
warehouse rather than Semolina decides that;
the untyped route covers the encoder to give
json.dumps.
Driver errors, mostly unmeasured¶
When a connection cannot be opened, because the warehouse is unreachable or refuses the
credentials, Semolina raises SemolinaConnectionError on
every backend. When a query reaches the warehouse and then fails, what reaches your handler
is the ADBC driver’s own exception, re-raised unchanged. Which subclass you get is the
driver’s decision, and the names do not mean what they sound like. Measured against DuckDB,
a missing view or table, invalid SQL syntax, and a reference to a column that does not exist
all raise adbc_driver_manager.InternalError. A value that fails to cast raises
ProgrammingError instead. Nothing about “internal” describes a typo in a view name.
Snowflake and Databricks have not been measured. The recorded cassettes the test suite replays contain only successful queries, so there is no observation of what either driver raises for any of those four failures, and guessing from the DuckDB result would be guessing twice: once about the driver, once about the warehouse behind it.
Error classification is per-driver knowledge you acquire by watching your own driver, and that a handler written against DuckDB’s classification is a handler written against one measurement. The “Catch the exception the driver actually raised” section of How to run Semolina inside FastAPI has the measured table; Serve a dashboard endpoint has the shape of a handler that does not depend on it.
What Semolina smooths over, and what it leaves alone¶
You could imagine Semolina normalizing all of this: aliasing every projected column back to its model field name, casting decimals to floats, mapping driver exceptions onto its own hierarchy. It does the first, never the second, and the third only for a connection that could not be opened.
The names are Semolina’s to choose, because Semolina wrote the query. The alias is in the
SQL, where .to_sql() shows it, and the name it gives is the one you declared on the
model, so nothing about the result is hidden by it. Casting decimals would mean rounding
your revenue on the way past. Wrapping a failed statement’s exception would mean
Semolina’s guess about a failure standing between you and the driver’s message, which is
where the detail lives. A failed connection is different: Semolina opened it, so
it can say so without guessing, and the driver’s error is still there as __cause__.
The cost of the other two lands on you as portability work, and this page exists because that cost is easy to defer until deployment day. The benefit is that nothing arrives rounded or reclassified, and that what you debug is what the warehouse said.
Working across the gap¶
None of this makes DuckDB the wrong place to develop. It makes DuckDB the wrong place to validate the half of your code that reads results.
A DuckDB test proves the parts that are backend-independent: that your query builder emits the query you meant, that your filters narrow what you expected, that your aggregation logic is right. It proves nothing about column types, and it cannot fail on a decimal your sample data does not contain. Treat a green DuckDB suite as evidence about query construction and as silence about result handling.
Result-reading code is the part to write against the warehouse you deploy to, from the
first line rather than at the end. Converting into typed objects with
into() is the portable form of this, because the
type each column arrives as is declared once per field, and the schema check refuses a
declaration the warehouse does not honour. How to map results into Pydantic models with .into() covers it.
You do not need a connection to find out what the other backends would be sent.
.to_sql(dialect=...) renders a query for any dialect from your laptop, which is the
cheapest way to see how a metric will be wrapped, how identifiers will be folded and what
each column is aliased to before you have credentials for anything. See
Inspect generated SQL.
When you want tests that assert against real warehouse behaviour, the route is to record
it once rather than to approximate it. pytest-adbc-replay captures the real Arrow
results from a credentialed run into a cassette and replays them offline afterwards, so
the column names and column types your assertions see are the ones your warehouse
produced. How to test query code without a warehouse covers recording and replay.
See also¶
Warehouse types and what lands in your row – why money arrives as a
Decimaland what the warehouse decides about metric typesHow result columns are named – how result columns get their names, and the types they arrive as on each backend
How to map results into Pydantic models with .into() – convert a result into Pydantic instances with
into()the untyped route –
dict(row), and the encoderjson.dumpsneeds before it will touch aDecimalInspect generated SQL – render a query for another dialect without executing it
How to test query code without a warehouse – the DuckDB fixture, and recording your warehouse with
pytest-adbc-replayHow to run Semolina inside FastAPI – error handling in a request handler, including the measured driver exception table
What is a semantic view? – how the three warehouses implement semantic views