Dialects¶
Seven SQL dialects ship — bigquery, databricks, duckdb, postgres, redshift,
snowflake, trino — and every emitter renders through the same port, so the choice of
dialect is independent of the choice of target.
Shipped dialects¶
--dialect |
Port | Notes |
|---|---|---|
duckdb |
DuckDBDialect |
The default in every example; runs in-process, so the execution test tier uses it |
postgres |
PostgresDialect |
variant is JSONB, and TRY_CAST has no keyword — see below |
redshift |
RedshiftDialect |
variant is SUPER; the first port to withhold capabilities — json_path, normalize and arrays are refused, not approximated |
trino |
TrinoDialect |
The federated engine; used by the lakehouse example over Iceberg |
snowflake |
SnowflakeDialect |
The first cloud port; timestamp is TIMESTAMP_NTZ by name and every current instant is SYSDATE() — see below |
bigquery |
BigQueryDialect |
timestamp is DATETIME, never the instant type; decimal lands on NUMERIC or BIGNUMERIC by its bounds; declares every capability |
databricks |
DatabricksDialect |
Databricks SQL and nothing else — the port renders text and runs no Spark; variant is STRING, and there is no transaction |
Passing any other name is an EmitError naming the seven. An eighth port is a
register_dialect() call away — the port protocol is public, and the capability flags
below exist so a new one refuses what it cannot express instead of approximating it.
Physical types¶
The seven logical types, as each port spells them:
| Logical | duckdb |
postgres |
trino |
redshift |
snowflake |
bigquery |
databricks |
|---|---|---|---|---|---|---|---|
string |
VARCHAR |
TEXT |
VARCHAR |
VARCHAR(MAX) |
VARCHAR |
STRING |
STRING |
int |
BIGINT |
BIGINT |
BIGINT |
BIGINT |
NUMBER(38, 0) |
INT64 |
BIGINT |
decimal(p,s) |
DECIMAL(p, s) |
DECIMAL(p, s) |
DECIMAL(p, s) |
DECIMAL(p, s) |
DECIMAL(p, s), p at most 38 |
NUMERIC(p, s) while s ≤ 9 and p − s ≤ 29, else BIGNUMERIC(p, s) while s ≤ 38 and p − s ≤ 38; refused past both |
DECIMAL(p, s), p at most 38 |
bool |
BOOLEAN |
BOOLEAN |
BOOLEAN |
BOOLEAN |
BOOLEAN |
BOOL |
BOOLEAN |
date |
DATE |
DATE |
DATE |
DATE |
DATE |
DATE |
DATE |
timestamp |
TIMESTAMP |
TIMESTAMP |
TIMESTAMP |
TIMESTAMP |
TIMESTAMP_NTZ |
DATETIME |
TIMESTAMP_NTZ |
variant |
JSON |
JSONB |
JSON |
SUPER |
VARIANT |
JSON |
STRING |
variant is the only row where the choice carries meaning. Postgres maps to JSONB —
the binary, indexable, canonicalized form — rather than JSON, which is a text blob
preserving key order and duplicates, properties variant never promises. Redshift has no
JSONB and its SUPER is a different data model, navigated with PartiQL rather than with
jsonb's operators, so that port refuses json_path instead of translating it.
Snowflake's column has three choices of its own. int is NUMBER(38, 0) because that is
what the engine stores — its BIGINT is a documented synonym for exactly that, not a
64-bit integer. A decimal past 38 digits of precision is refused at emit by name: the
engine has no wider fixed-point type to fall back to. And timestamp is spelled
TIMESTAMP_NTZ explicitly, never bare TIMESTAMP, which on Snowflake is an alias an
account or a session may point at the session-zoned TIMESTAMP_LTZ — the one type the
always-UTC zoneless timestamp must never become.
BigQuery's timestamp is DATETIME, chosen rather than inherited: GoogleSQL's TIMESTAMP
is an instant, read and written through a zone, and DATETIME is the zoneless wall clock
the always-UTC timestamp is defined as. Its decimal is placed by the declaration's
integer digits, p − s, and not by p alone, because GoogleSQL bounds a parameterized
type's precision relative to its scale: decimal(38, 9) fits NUMERIC, decimal(30, 0)
does not and lands on BIGNUMERIC, and a declaration past both bounds is refused rather
than widened into a type other than the one declared. Databricks spells timestamp
TIMESTAMP_NTZ for Snowflake's reason — its bare TIMESTAMP is session-zoned — and has no
JSON column type, so variant is STRING and its reads take the colon accessor,
payload:shipping.country.
The eighth logical type, vector(scalar, dimensions), has no physical spelling on any
port and is refused by name: a vector column belongs to a retrieval target, not to a
warehouse's DDL (see Retrieval).
There are no floats in the type system, and a decimal stays exact end to end — with one
named exception, divide on DuckDB, which that engine cannot express exactly. It is
described below rather than left to be discovered.
Where the ports spell things differently¶
DuckDB, Postgres, Trino and BigQuery declare every capability, so nothing in their
columns is a feature gap. redshift withholds three, and snowflake and databricks one
each — Unicode normalization, which none of the three engines has under any name, so a
normalize rule is refused at emit rather than rendered as a call the engine never
defined — and the cells they refuse are marked.
These are the places one engine needs different SQL for the same meaning, and the port
supplies it — the reason a spec compiles to different text without meaning anything
different.
| Construct | duckdb |
postgres |
trino |
redshift |
snowflake |
bigquery |
databricks |
|---|---|---|---|---|---|---|---|
Zone interpretation (to_utc) |
x AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC' |
same as DuckDB | CAST(AT_TIMEZONE(WITH_TIMEZONE(x, 'Europe/Berlin'), 'UTC') AS TIMESTAMP) |
CONVERT_TIMEZONE('Europe/Berlin', 'UTC', x) |
CONVERT_TIMEZONE('Europe/Berlin', 'UTC', x) |
DATETIME(TIMESTAMP(x, 'Europe/Berlin'), 'UTC') |
CAST(TO_UTC_TIMESTAMP(x, 'Europe/Berlin') AS TIMESTAMP_NTZ) |
Null-on-failure cast (the coercible marker) |
TRY_CAST(x AS BIGINT) |
CASE WHEN PG_INPUT_IS_VALID(x, 'BIGINT') THEN CAST(x AS BIGINT) END |
TRY_CAST(x AS BIGINT) |
TRY_CAST(x AS BIGINT), and CASE WHEN CAN_JSON_PARSE(x) THEN JSON_PARSE(x) END for variant |
TRY_CAST(CAST(x AS VARCHAR) AS NUMBER(38, 0)) |
SAFE_CAST(x AS INT64) |
TRY_CAST(x AS BIGINT) |
Nested read $.payload.shipping.country |
payload ->> '$.shipping.country' |
JSON_EXTRACT_PATH_TEXT(CAST(payload AS JSON), 'shipping', 'country') |
JSON_EXTRACT_SCALAR(payload, '$.shipping.country') |
JSON_EXTRACT_PATH_TEXT(payload, 'shipping', 'country', TRUE) |
JSON_EXTRACT_PATH_TEXT(payload, 'shipping.country') |
JSON_EXTRACT_SCALAR(payload, '$.shipping.country') |
payload:shipping.country |
normalize rule |
NFC_NORMALIZE(x) |
NORMALIZE(x, NFC) |
NORMALIZE(x, NFC) |
refused — no UNICODE_NORMALIZE capability |
refused — no NFC function | NORMALIZE(x, NFC) |
refused — no NFC function |
reject_id digest |
SHA256('v') |
ENCODE(SHA256(CONVERT_TO('v', 'UTF8')), 'hex') |
LOWER(TO_HEX(SHA256(TO_UTF8('v')))) |
SHA2('v', 256) |
SHA2('v', 256) |
TO_HEX(SHA256('v')) |
SHA2('v', 256) |
Reject raw payload |
JSON_OBJECT('a', a) |
JSON_BUILD_OBJECT('a', a) |
JSON_OBJECT('a': a) |
OBJECT('a', a) |
OBJECT_CONSTRUCT_KEEP_NULL('a', a) |
JSON_OBJECT('a', a) |
TO_JSON(NAMED_STRUCT('a', a)) |
| Current instant | CURRENT_TIMESTAMP |
CURRENT_TIMESTAMP |
CURRENT_TIMESTAMP |
CONVERT_TIMEZONE('UTC', CAST(GETDATE() AS TIMESTAMP WITH TIME ZONE)), cast back |
SYSDATE() |
CURRENT_DATETIME('UTC') |
CAST(TO_UTC_TIMESTAMP(CURRENT_TIMESTAMP(), CURRENT_TIMEZONE()) AS TIMESTAMP_NTZ) |
Calendar row source (dim_date) |
GENERATE_SERIES(...) |
GENERATE_SERIES(...) |
UNNEST(SEQUENCE(...)) |
a cross-joined ten-row generator numbered by ROW_NUMBER(), the day added to the date as an integer |
TABLE(GENERATOR(ROWCOUNT => n)) with DATEADD |
UNNEST(GENERATE_DATE_ARRAY(...)) |
EXPLODE(SEQUENCE(...)) |
Four of those are not stylistic. Postgres has no TRY_CAST keyword, and SQLGlot renders
one as a plain CAST — which would turn "quarantine the uncastable row" into "abort the
run", so the port wraps the engine's own input parser instead of approximating it with a
regex. Trino's sha256 takes and returns varbinary, so the plain spelling does not
even plan; its to_hex is uppercase, and reject_id has to agree across engines byte
for byte. Trino parses only the SQL-standard JSON_OBJECT spelling. DuckDB has no
NORMALIZE at all.
Snowflake adds four of its own, none stylistic either. Its TRY_CAST accepts a string
source only and SQLGlot renders it as a plain CAST otherwise, so the port casts the
operand to VARCHAR first. Its object builder drops any pair whose value is NULL, which
would make a quarantined payload disagree with every other port's, so the reject payload
uses the _KEEP_NULL variant. Its CURRENT_TIMESTAMP is session-zoned, so every current
instant — including the replay's resolved_at stamps — is SYSDATE(), the engine's UTC
instant already typed TIMESTAMP_NTZ. And SQLGlot spells the calendar's series as
ARRAY_GENERATE_RANGE(...), a scalar function the engine cannot select from, so the port
renders the row generator instead, with the count read from the calendar's own bounds.
Redshift's whole column is its own for the same reason: its AT TIME ZONE promotes a
zoneless value through the session zone the way Trino's does, so the PostgreSQL chain
does not cancel and the three-argument CONVERT_TIMEZONE(source, target, ts) says it in
one call; it defines no JSON_OBJECT, so the SUPER constructor OBJECT carries the
reject payload; and a neutral CAST(x AS JSON) renders there as a no-op, which is why
variant construction is JSON_PARSE and its null-on-failure form has to be guarded by
CAN_JSON_PARSE — SUPER has no TRY_CAST. The port inherits none of PostgreSQL's
rewrites: each was audited against Redshift separately, and the two that survived are
imported as named helpers. The nested read in that table is a bronze path, declared
string, and JSON_EXTRACT_PATH_TEXT over a text column returns text correctly, so the
port renders it — with null_if_invalid set, so a payload that is not JSON yields NULL
for the coercible rule to read rather than aborting the load; what the port refuses is
the json_path transform's variant extraction, which would declare variant and
produce a string.
BigQuery's column is decided rather than inherited too. SAFE_CAST is its null-on-failure
cast and holds under the quality system's lowering, including the cast of a wide decimal,
which becomes SAFE_CAST(x AS BIGNUMERIC) rather than a NUMERIC the value cannot fit.
GoogleSQL's IN takes a single-column subquery only, so the replay's resolution UPDATE,
which marks superseded reject rows with a row-value IN on every other port, is rewritten
to a correlated EXISTS with the update's target aliased _row. Its REGEXP_EXTRACT
addresses one capturing group, so a regexp transform naming another is refused by name.
And its RE2 refuses a repetition bound above 1000, so a pattern rule's {1001} is
refused at parse as outside the portable regex subset — on every dialect, since DuckDB and
Trino are RE2 engines too. A bare BEGIN opens a script block there, so
begin_transaction is BEGIN TRANSACTION.
Databricks has no transaction at all: every statement is its own Delta commit and BEGIN
is rejected, so begin_transaction is the empty string and the dbt replay envelope opens
and commits nothing — its header says each statement commits on its own, and that a
failure part-way leaves the earlier ones committed. Its TO_UTC_TIMESTAMP(x, zone) reads
a wall clock in the named zone and returns the UTC one, the direction to_utc means
(SQLGlot's FROM_UTC_TIMESTAMP runs the other way), and the same call around
CURRENT_TIMESTAMP() with CURRENT_TIMEZONE() is how the session's clock becomes UTC's
for every current instant, the replay's resolved_at stamps included. DATE_TRUNC('DAY',
x) replaces SQLGlot's TRUNC, and reserved names are backticked.
Arrays, and the shape _quality_flags takes without them¶
redshift does not declare DialectFeature.ARRAY. Redshift's SUPER can hold an
array and ARRAY() constructs one, but no table column can be declared VARCHAR[], and
a capability flag names what the engine can express as a column — so the port withholds
it rather than emitting a declaration Redshift rejects.
That choice is visible in the emitted artifacts. _quality_flags and failed_rules
share one physical contract with two lowerings, and this port takes the second:
array dialects (duckdb, postgres, trino, snowflake, bigquery, databricks) |
redshift |
|
|---|---|---|
| A flagged row | ['email_shape', 'positive_total'] |
'email_shape,positive_total' |
| A clean row | the empty array, never NULL | the empty string, never NULL |
| Order | lexicographic by rule name | lexicographic by rule name |
_quality_ok |
derived from the array's length | derived from the delimited string |
Both shapes carry the same flag set — rule names are identifier-constrained at parse, so
the comma needs no escaping, and the dialect-matrix tier asserts set equality across the
two lowerings. What differs is what a consumer writes: on redshift, a query asking
whether a row carries a given flag matches a delimited member rather than indexing an
array, and the reject table's failed_rules carries the same delimited text. A mart's
has_quality_flags is generated per shape and needs nothing from the reader.
Two divergences the ports absorb for you¶
Both of these are engine behaviour a spec cannot see, and both are the same shape: one spelling, two meanings, and no error to tell you which you got. Trino is in both; DuckDB is in the first and Redshift shares Trino's rule in the second. Neither is a caveat you have to work around any more — the port closes both — but both are worth knowing, because each moved data on upgrade.
parse_ts: ISO8601 and the T separator¶
ISO 8601 permits three spellings of the separator — T, t, and (by the profile most
tools follow) a space — and each engine takes a different subset:
| Input | DuckDB | PostgreSQL | Trino |
|---|---|---|---|
2026-01-06T12:00:00 |
parses | parses | NULL |
2026-01-06t12:00:00 |
raises | parses | NULL |
2026-01-06 12:00:00 |
parses | parses | parses |
Snowflake needs no fourth column: its port normalizes the separator the same way before
any cast, the strictly-safe choice until the live corpus shows which raw spellings its
AUTO format would have parsed on its own. BigQuery and Databricks normalize it the same
way; BigQuery also strips a trailing Z, which its DATETIME parser does not take, and
Databricks needs the rewrite most, since under ANSI mode a lowercase t raises there
rather than returning NULL.
No error was ever raised for the Trino column. Outside the quality system it was a silent
NULL; inside it the generated coercible rule read "the projection is NULL although the
source was not" as a coercion failure, so every row of the source was quarantined
while the diagnosis pointed at data that was fine. DuckDB's lowercase cell is the loud
version of the same problem: a plain cast stops the run outright, and inside the quality
system it quarantines the row.
Both ports normalize the separator before the cast now, through the same function, so
every spelling parses and a value that is genuinely not a timestamp still fails as one.
The rewrite is applied to the text rather than to the cast, so it survives the cast
becoming a TRY_CAST for an entity in the quality system. PostgreSQL needs neither and
adds neither, and neither does Redshift — its datetime input takes the T separator the
way PostgreSQL's does, so that port carries no rewrite here.
parse_date: ISO8601 is deliberately not normalized. An ISO date has no T, and the
case that would need it — a full timestamp handed to a date parser — is not helped:
Trino cannot cast 2026-01-06 12:00:00 to DATE either, so rewriting only turns a NULL
into a hard INVALID_CAST_ARGUMENT.
The offset guard beside it is not a divergence and is not absorbed here: all three
engines truncate a +01:00 identically, so the refusal lives in the shared rendering
path and every port inherits the same guard around whatever its own cast spelling is. It is documented with the transform, in
Transforms.
to_utc, the zone argument, and the zone that came back¶
Two problems, one after the other, both now closed.
Trino's AT TIME ZONE promotes a zoneless timestamp with the session zone before
converting, leaving the instant unchanged — so the zone argument moved nothing but the
display. Trino renders with_timezone instead, and every shipped port agrees that a 12:00
value read as Europe/Berlin is the instant 11:00Z — Redshift shares Trino's promotion
rule, which is why its port renders the three-argument CONVERT_TIMEZONE rather than the
PostgreSQL chain.
Every engine's zone interpretation then returns a zone-aware value, while timestamp
is defined as always UTC and maps to a zoneless type. The instant was right and
everything derived from it read the display rule rather than the instant: on DuckDB and
PostgreSQL date(ordered_at) moved with the reader's session zone, and on Trino it
was the local date in whatever zone the mapping named — so two rows at one instant,
mapped from two shops in two zones, landed in different days. Each port now normalizes
to UTC and drops the zone, and the emitted column is the zoneless type the entity model
declared.
If you built tables on a version before either fix, the timestamps in them moved when you
upgraded, and any date bucket derived from a to_utc column moved with them. That is the
point, and it needs a restatement.
Nothing will tell you that. Both fixes live in port rendering, so the IR is
byte-identical across the upgrade and so is project_fingerprint; a
plan between the two versions is refused outright for
crossing bloomery_ir_versions, and would report no change even if it were not. The
emitted SQL is where the difference is.
So find the affected models yourself and rebuild them:
grep -rl to_utcyour mapping specs. Every entity with ato_utcstep is affected, and so is every mart reading one of its timestamp columns — including any date role bucketed from one.- Rebuild those from bronze rather than incrementally:
sqlmesh plan --restate-modelnaming each, ordbt run --full-refresh --selectover the same set. An incremental run only rewrites new partitions and leaves the old rows on the old semantics, which is worse than not upgrading — one column, two meanings, no boundary marked. - Rebuild downstream marts after the silver models, since a mart's date buckets are derived and will not move on their own.
Diff a sample before and after: a row whose local time is late in the day in a zone east of UTC is the one that moves, and it moves by a whole day.
Every transform produces the type it declares¶
A conformance battery probes every (transform, input type) pair the typechecker
admits against real DuckDB, PostgreSQL and Trino, and compares the engine's own
column type against the transform's declared output. It asserts set equality per
port, so a divergence that appears fails the suite and one that is repaired fails
it too until its row is deleted. The register is empty: on all three engines,
every transform now produces what it says it produces. The four cloud ports are not
probed by it — no tier runs those engines in a container — so their spellings are pinned
at the rendering level, and each has a lane against the engine's own compiler that says
whether the rendered statements analyse: EXPLAIN USING JSON on Snowflake and a dry run
on BigQuery, both on main under repository credentials; EXPLAIN EXTENDED and
DESCRIBE QUERY on a Databricks warehouse, weekly; and EXPLAIN on a Redshift cluster,
which no workflow runs today. A claim on this page about one of those four is a
rendering-level claim unless its lane made it.
Getting there closed defects on all three ports — four transforms PostgreSQL
could not run at all, eight Trino cases where a coalesce or nullif literal
was not coerced to its column's type, a parse_ts format branch that stored a
different instant depending on who ran it, and decimal arithmetic that widened
past the tracked (p, s) everywhere. None of them had golden coverage: no
fixture used json_path, divide, multiply, round or abs, which is why
they survived.
One divergence remains and cannot be repaired here:
divide is inexact on DuckDB. The engine has no exact decimal division —
/ is float division and // is integer division — so the division itself
happens in binary floating point, and what the port can do is narrow the result
back to the declared decimal. PostgreSQL and Trino divide exactly. The emitted
column has the right type on all three; only DuckDB's value has been through a
float, and only for divide.
If exactness matters on DuckDB, multiply by the reciprocal instead: {multiply:
"0.01"} rather than {divide: 100} stays in decimal arithmetic end to end. The
shipped examples do exactly that, and say why.
A catalog recipe's / is inexact on every engine. The divide transform is
marked so PostgreSQL and Trino keep it in exact decimal arithmetic, but a
recipe's expr: is parsed SQL, not a built transform, and carries no marker —
expr: line_total / quantity renders as CAST(line_total AS DOUBLE PRECISION) /
quantity, a binary-float division narrowed back to the declared decimal
(logs/T-0002.md D-004). The narrowing bounds the error the way DuckDB's divide
is bounded above; values needing more than ~15 significant digits can still
round. Marking a recipe's division exact needs operand types the recipe grammar
does not carry yet — until it does, prefer a divide/multiply transform chain
where the division must be exact, and keep recipe expr: division for
screen-precision derivations.
Notes¶
- The dialect is not the target.
--target sqlmesh --dialect postgresand--target dbt --dialect postgresshare every line of dialect logic; a semantics bug cannot exist in only one target's SQL. - Rendering never mutates the neutral AST. One expression tree is shared across ports, so each rewrite works on a copy — a port that edited in place would leave the next one rendering its neighbour's spelling.
- Reserved identifiers are quoted on every port. An entity named
orderemitssilver."order"on DuckDB, PostgreSQL, Redshift and Trino,silver."ORDER"on Snowflake, where an unquoted name folds to uppercase and a quoted one is taken verbatim — the folded spelling is the one that names the same object as the unquoted model name — andsilver.`order`on BigQuery and Databricks, whose quoting character is the backtick. - Capability flags are how a port stays honest. A port that cannot express a
null-on-failure cast, an array, a text digest, Unicode normalization, or a JSON object
refuses the constructs that need them rather than emitting something close.
duckdb,postgres,trinoandbigquerydeclare all of them;redshiftwithholds three — arrays, Unicode normalization and variant extraction — so a project usingjson_pathor anormalizerule meets a refusal on that dialect and compiles on the others, and its quality flags take the delimited-string shape rather than an array column;snowflakeanddatabrickswithhold Unicode normalization alone. No shipped example meets those refusals today; the test suite provokes them against a deliberately incapable port. - The engine test tier runs the emitted SQL against real DuckDB, PostgreSQL and Trino
containers, which is where claims on this page are checked. The four cloud engines have
no container, so each is established by the ladder S-0012 names: offline rungs on every
pull request — unit, golden, and a re-parse of every rendered statement by the engine's
own grammar; a surrogate lane, marked
surrogateand neverengine, that runs onmain— thesivchari/snowflake-emulatorimage for Snowflake, thegoccy/bigquery-emulatorimage for BigQuery at a pinned tag, a PostgreSQL container for Redshift'spostgres-compatiblefixtures, and local Spark for Databricks; and the engine's own compiler as the oracle, under credentials a pull request cannot reach. A green surrogate says the emulator accepted the statements, and its test names say so. - The local Redshift lane checks the PostgreSQL-compatible subset; only a live cluster
checks the dialect. There is no Redshift container to run locally, so the local
lane submits the port's SQL to a real PostgreSQL standing in for a cluster
(
tests/engines/test_redshift_surrogate.py). A green run there says PostgreSQL accepted these statements — no more, and the test names and its marker say so rather than naming the engine. Fixtures are split into two classes for exactly this reason (tests/support/redshift.py):postgres-compatible, which the local lane runs, andredshift-native— everything reachingSUPER, PartiQL, a Redshift-only function or Redshift's own type rules — which it never runs, because a green over one of those would be a claim a PostgreSQL container cannot make. Nothing but a live cluster can check the native class, and no lane runs one today — so treat every Redshift-specific statement on this page as a rendering-level claim, not an engine observation.