Skip to content
Talk to our solutions team

Backend compatibility

Four SQL dialects ship in the engine: PostgreSQL, DuckDB, ClickHouse and SQLite. Every divergent SQL fragment routes through a dialect contract, and this page is the cell-by-cell record of what each one does.

Two things frame everything below.

data.svc serves PostgreSQL only. ClickHouse, DuckDB and SQLite are complete engine backends — pool, driver, transactions, compiler, dialect — but pool-config validation or the pool-factory lookup rejects them, so they are not reachable from a tenant datastore. See Datastores and database pools for the three chokepoints. The PostgreSQL column is what runs; the other three are the portability reference and the library-embedding contract.

A dialect declaration is not a gate. The compiler consults a strict subset of the dialect surface. Everything else is a statement of position with no consumer. Each section below is marked Emitted — the compiler routes through it, so the difference is live — or Declared — the dialect states a value and no code path reads it.

On the query path the compiler calls exactly these dialect methods:

Consulted forMethod
Every identifierQuoteIdent
Field, sort, group and relation namesValidateIdent
Every bound argumentPlaceholder
LIKE / ILIKE comparisonsLikeOp
Containment comparisonsContainsOp
In-list and array valuesArrayLiteral
Relative-time valuesNow, TimeExpr
LIMIT / OFFSETWriteLimitOffset
WITH / WITH RECURSIVECTEPrefix
RETURNINGWriteReturning
UpsertWriteUpsert
Soft deleteSoftDeleteSetClause
match comparisonsFullTextMatch
Raw database errorsSanitise

DDL emission is a separate interface, consumed by the migration planner — CreateTable, AddColumn, AlterColumn, CreateIndex, MapType. See Migrations.

Emitted.

PostgreSQLDuckDBClickHouseSQLite
Identifier quoting"name""name"`name`"name"
Embedded delimiter escape""""``""
Identifier length enforced63 bytesNoneNoneNone
Empty identifierRejectedRejectedRejectedRejected
NUL byte in identifierRejectedRejectedRejectedRejected
Leading/trailing whitespaceRejectedRejectedRejectedRejected
Placeholder$1$1??
Named parametersDeclared supportedDeclared supportedDeclared unsupportedDeclared unsupported

Every identifier is quoted, so the validation rules catch only what quoting cannot make safe: a NUL byte terminates the identifier, and PostgreSQL silently truncates past 63 bytes, which merges two distinct names into one column. PostgreSQL is the only dialect with a length rule. ClickHouse’s backend capability record declares a 255-byte ceiling, but its ValidateIdent does not read it, so no length is checked there.

The length rule is live. A field, sort key, group key or relation name longer than 63 bytes is rejected by the compiler’s safety guard before any SQL is built:

identifier "…" exceeds PostgreSQL 63-byte limit (NAMEDATALEN)

Named-parameter support is declared and never read — the compiler emits positional placeholders on all four dialects.

Emitted for arrays; declared for the rest — the compiler binds values as arguments rather than inlining them, so the literal renderers matter only on the paths that inline.

PostgreSQLDuckDBClickHouseSQLite
NULLNULLNULLNULLNULL
BooleanTRUE / FALSETRUE / FALSE1 / 01 / 0
String'foo', ' doubledsamesamesame
ArrayARRAY[…]ARRAY[…][…]JSON text '["a","b"]'
JSON'…'::jsonb'…''…''…'
BytesE'\\x…''\x…'::BLOBunhex('…')x'…'
Cast(expr)::type(expr)::typeCAST(expr AS type)CAST(expr AS type)

ClickHouse booleans are UInt8, so 1/0 is the canonical form. SQLite has no boolean storage class — also 1/0. SQLite has no array type at all; an array-typed field is TEXT holding JSON, and the dialect renders a JSON array string rather than an ARRAY[…] constructor.

Emitted. Relative-time values in the query DSL compile through TimeExpr.

AnchorPostgreSQLDuckDBClickHouseSQLite
NowNOW()CURRENT_TIMESTAMP::TIMESTAMPnow()CURRENT_TIMESTAMP
TodayCURRENT_DATECURRENT_DATEtoday()date('now')
YesterdayCURRENT_DATE - INTERVAL '1 day'sameyesterday()date('now', '-1 day')
Start of weekDATE_TRUNC('week', CURRENT_DATE)sametoStartOfWeek(today())date('now', 'weekday 0', '-6 days')
Start of monthDATE_TRUNC('month', CURRENT_DATE)sametoStartOfMonth(today())date('now', 'start of month')
Start of yearDATE_TRUNC('year', CURRENT_DATE)sametoStartOfYear(today())date('now', 'start of year')
Offset form<anchor> ± INTERVAL '<n> <unit>'same<anchor> ± INTERVAL <n> <UNIT> — unquoteddatetime(<anchor>, '±<n> <unit>')
Timezone storageUTC (timestamptz)Naive (TIMESTAMP)Local-aware (DateTime('TZ'))Naive (ISO-8601 text)

DuckDB maps “now” to CURRENT_TIMESTAMP::TIMESTAMP rather than NOW() deliberately: bare NOW() returns TIMESTAMPTZ, which DuckDB refuses to insert into a TIMESTAMP column without an explicit cast.

The timezone-storage mode is declared, not enforced. Nothing warns when a UTC-stored column is compared against a naive one.

Emitted for LIKE/ILIKE and containment; declared for the rest.

PostgreSQLDuckDBClickHouseSQLite
LIKELIKELIKELIKELIKE
Case-insensitive matchILIKEILIKEILIKELIKE — no ILIKE
Containment@>nonenonenone
Range overlap&&nonenonenone
Regex operatorsDeclaredDeclaredDeclaredNot supported
Vector distance <->With pgvectorNoNoNo
Trigram similarity %With pg_trgmNoNoNo

SQLite’s LIKE is case-insensitive for ASCII by default, so the dialect returns LIKE for both modes and leaves case behaviour to the column’s collation. SQLite reports no regex support — a regex operator needs a user-defined function the driver does not install.

Emitted for limit and offset; declared for null ordering.

PostgreSQLDuckDBClickHouseSQLite
LIMIT nYesYesYesYes
OFFSET mYesYesYesYes
Emission orderLIMIT then OFFSETsamesamesame
ORDER BY required for OFFSETNoNoNoNo
Null orderingExplicit NULLS FIRST/LASTExplicitAscending nulls last, descending nulls firstExplicit

All four emit the same LIMIT n OFFSET m shape. ClickHouse is the only one whose null placement is driven by defaults instead of explicit syntax, and since nothing reads the declaration the compiler emits no NULLS clause anywhere.

Emitted.

PostgreSQLDuckDBClickHouseSQLite
WITHWITHWITHWITHWITH
WITH RECURSIVEWITH RECURSIVEWITH RECURSIVENot supportedWITH RECURSIVE
Writable CTEsYesYesNoYes

Asking for a recursive CTE on ClickHouse emits WITH followed by a comment marking the feature unsupported — the dialect’s defensive fallback for a caller that skipped the capability check. Since no caller performs that check, this is the path a recursive query actually takes on ClickHouse, and the database rejects the result.

Emitted for RETURNING, upsert and soft delete; declared for the rest.

PostgreSQLDuckDBClickHouseSQLite
INSERTYesYesYesYes
INSERT … RETURNINGYesYesEmits nothingYes (3.35+)
UPDATE … WHEREYesYesAsync mutationYes
DELETE … WHEREYesYesDeclared unsupportedYes
UpsertON CONFLICT … DO UPDATE SET, EXCLUDED.<col>sameError at emissionON CONFLICT … DO UPDATE SET, excluded.<col>
Soft-delete timestampNOW()CURRENT_TIMESTAMP::TIMESTAMPnow()CURRENT_TIMESTAMP
Generated-key strategyRETURNINGRETURNINGNoneRETURNING
Bulk threshold1000 rows50001005000
Native bulk protocolCOPYAppender APINative protocolMulti-row VALUES

ClickHouse is the one dialect that refuses rather than mis-emits: WriteUpsert returns clickhouse: upsert not supported (use ReplacingMergeTree table engine instead), and the compiler propagates it as upsert: <error>. Its RETURNING writer is a no-op, so a returning-shaped insert silently produces a statement with no RETURNING clause and the caller gets no row back.

Soft delete and row history are the largest functional gap. ClickHouse declares no history strategies and no soft-delete support because its updates and deletes are asynchronous background mutations — see Row history and Datastores.

Declared. Nothing in the engine emits a lock clause.

PostgreSQLDuckDBClickHouseSQLite
FOR UPDATE / FOR SHAREYesNoNoNo
FOR NO KEY UPDATE / FOR KEY SHAREYesNoNoNo
SKIP LOCKEDSKIP LOCKEDNoneNoneNone
Row-level lockingYesNoNoNo

DuckDB is MVCC with snapshot isolation and has no row locks; ClickHouse has none; SQLite locks per transaction (BEGIN IMMEDIATE) rather than per row.

Declared. The dialect aggregate catalogs have no consumer — the aggregation vocabulary the API accepts is the query DSL’s, not these.

PostgreSQLDuckDBClickHouseSQLite
COUNT / SUM / AVG / MIN / MAXYesYesYesYes
String concatenationSTRING_AGG(f, sep)STRING_AGG(f, sep)arrayStringConcat(groupArray(f), sep)group_concat(f, sep)
PercentilePERCENTILE_CONT(p) WITHIN GROUP (ORDER BY f)samequantile(p)(f)Aborts with an error
Array aggregateARRAY_AGGARRAY_AGG, LISTgroupArray
Boolean aggregateBOOL_AND / BOOL_OR / EVERYBOOL_AND / BOOL_ORmin / max
Standard deviationSTDDEV, STDDEV_POP, STDDEV_SAMPsamestddevSamp, stddevPopstddev
VarianceVAR_POP, VAR_SAMP, VARIANCEsamevarPop, varSampvariance
Correlation / covarianceCORR, COVAR_POP, COVAR_SAMPCORRcorr
RegressionREGR_SLOPE, REGR_INTERCEPT
BitwiseBIT_AND, BIT_OR
JSON aggregateJSON_AGG, JSONB_AGG, JSONB_OBJECT_AGG
Cardinalityuniq, uniqExact
TotalTOTAL

SQLite has no percentile function. The dialect renders a statement that raises percentile not supported on sqlite at execution rather than emitting silently wrong SQL. ClickHouse maps boolean aggregates to min/max because its booleans are UInt8, where min is AND and max is OR over {0,1}.

Declared.

PostgreSQLDuckDBClickHouseSQLite
Path accessf->'a'->'b'sameJSONExtractRaw(f, 'a', 'b')json_extract(f, '$.a.b')
Path access as textf->'a'->>'b'sameJSONExtractString(f, 'a', 'b')json_extract(f, '$.a.b')
Containmentf @> …json_contains(f, …)JSONHas(f, …)EXISTS(SELECT 1 FROM json_each(f) …)
StoragejsonbJSON (string-backed)StringTEXT

SQLite path segments that are not identifier-safe are bracket-quoted ($["a b"]) rather than dot-appended.

Emitted, but unreachable. The engine installs the full-text writer as the compiler’s match hook, so a match comparison compiles through the dialect on all four backends. Nothing produces that comparison — neither query form has a spelling for match, and no other caller builds one (see Query DSL). The table is what each dialect would emit, not what any request reaches.

PostgreSQLDuckDBClickHouseSQLite
Native full-textto_tsvector(f) @@ plainto_tsquery(v)NoNoWith fts5
Fallback matchf ILIKE '%' || v || '%'f ILIKE '%' || v || '%'f LIKE '%' || v || '%'

PostgreSQL is the only dialect with a real full-text expression. The other three degrade to a substring match — same result shape, different semantics and no index use.

Declared for the connection convention; the physical namespace itself is applied by the service. See Physical schema qualification.

PostgreSQLDuckDBClickHouseSQLite
Default schemapublicmaindefaultmain
Qualified form"schema"."table""schema"."table"`schema`.`table`"schema"."table"
Qualification suppressed whenschema emptyschema emptyschema emptyschema empty or main
Cross-schema joinsYesYesYes (cross-database)Yes
Per-connection conventionSET search_pathSET schemaUSE databaseNone — a named schema is refused
Multi-schema per databaseYesYesDatabases insteadAttached databases

Only the PostgreSQL provider keeps the schema prefix in compiled SQL. The ClickHouse, DuckDB and SQLite providers install an adapter that suppresses it, so schema-per-tenant isolation is a PostgreSQL-only mechanism. SQLite goes further and issues no per-connection statement at all: setting a tenant schema is a no-op for main and an error for anything else — schema-per-tenant is not supported on SQLite; use file-per-tenant instead.

Declared. The full flag set, as each dialect answers it. Extension-gated cells are marked with the extension that flips them.

FlagPostgreSQLDuckDBClickHouseSQLite
recursive-cteYesYesNoYes
lateral-joinYesYesNo — ARRAY JOIN insteadNo
grouping-setsYesYesNoNo
rollup-cubeYesYesYes — WITH ROLLUP/CUBENo
distinct-onYesYesNoNo
window-framesYesYesYesYes
materialized-viewYesNoYesNo
partial-indexYesNoNoYes
expression-indexYesYesNoYes
generated-columnYesYesYes — MATERIALIZED/ALIASYes
row-level-securityYesNoYes — native row policiesNo
triggersYesNoNoNo
stored-proceduresYesNoNoNo
transactional-ddlYesYesNoYes
savepointsYesNoNoYes
listen-notifyYesNoNoNo
cursorsYesNoNoNo
streamingYesNoYes — LiveViewNo
geo-spatialWith postgisWith spatialNoNo
vector-opsWith pgvectorNoNoNo
json-opsYesYesYesYes
array-opsYesYesYesNo
full-text-searchYesNoNoWith fts5

datastores[].extensions is the declared list. On PostgreSQL each known name overlays types, operators and functions onto the dialect’s registries; unknown names are dropped silently.

ExtensionTypes addedOperators addedFunctions added
pgvectorvector, halfvec, sparsevec<->, <=>, <#>vector_dims, l2_distance, cosine_distance, inner_product, l1_distance, vector_norm
pg_trgm%, <%, %>, <<%, %>>, <->similarity, word_similarity, strict_word_similarity, show_trgm, show_limit
postgisgeometry, geography, box2d, box3d&&, ~, @, <<, >>, &<, &>, <<|, |>>, &<|, |&>, <#>, ~=The ST_* family — ST_AsText, ST_GeomFromText, ST_Distance, ST_DWithin, ST_Intersects, ST_Contains, ST_Within, ST_Buffer, ST_Area, ST_Length and more
citextcitext
uuid-osspuuid_generate_v1, v1mc, v3, v4, v5; uuid_nil, ns_dns, ns_url, ns_oid, ns_x500

DuckDB overlays spatial (geometry and geography types, a subset of the ST_* family) and icu (collation and timezone only — no compile-time surface). ClickHouse tracks declared extensions but overlays nothing: its features are built into the binary and configured through table engines and codecs.

The overlay reaches the dialect’s type registry. The operator overlay does not reach the query path at all: the method that would read it has no call site, so extension operators are not emittable from the query DSL under any configuration.

Declared. Each dialect classifies a field change as online (no row rewrite), rewrite (table or column rewritten) or downtime (table offline), and supplies operator hints. Nothing calls either method — the deployed planner uses its own risk gate, documented under Migrations.

ChangePostgreSQLDuckDBClickHouseSQLite
Type change, widening within a familyOnlineRewriteDowntimeRewrite
Type change, narrowing or family switchRewriteRewriteDowntimeRewrite
Nullability changeOnlineOnlineRewriteRewrite
Default changeOnlineOnlineOnlineOnline
No field-level changeOnlineOnlineOnlineOnline

PostgreSQL’s widening set is stringtext, intbigint, and floatfloat; every other type change classifies as a rewrite.

The hints each dialect carries for a rewrite are worth reading even though the planner does not print them:

BackendGuidance for a type change
PostgreSQLFull table rewrite, every row re-validated; blue-green (add column, dual-write, batch backfill, swap, drop) avoids the ACCESS EXCLUSIVE lock window
DuckDBColumnar — the column file is regenerated; ALTER … TYPE blocks the database for the duration under the single-writer model
ClickHouseRuns as an async mutation; track with system.mutations; I/O load doubles until it completes; on distributed tables coordinate with replication or dual-write to a new table
SQLiteNo in-place type change — rebuild: create the new table, INSERT … SELECT, drop the old, rename

For nullability tightening, PostgreSQL’s fully-online route is ADD CONSTRAINT … CHECK (col IS NOT NULL) NOT VALID, then VALIDATE CONSTRAINT, then ALTER COLUMN SET NOT NULL. SQLite and ClickHouse both rewrite.

Each backend declares supported, deprecated and end-of-life ranges, compared on major and minor only. data.svc never runs that check — the full table and the reason it is inert are in Datastores. No dialect branches on version — the version is carried for error text only. The version-gated declarations live in each backend’s capability record, which the library resolves when you embed it:

GateBackendFrom
Transactions, snapshot isolation onlyClickHouse24.8
Lightweight DELETEClickHouse23.8
Bloom indexesClickHouse24.1
Concurrent materialized-view refreshClickHouse24.3
RETURNINGSQLite3.35
STRICT tablesSQLite3.37

The SQLite floor is 3.37 because STRICT tables were the intended baseline, but CREATE TABLE does not emit STRICT: the driver’s automatic timestamp scanning needs declared types (TIMESTAMP, DATETIME, DATE) that STRICT rejects, and working time scans won.

  • Datastores and database pools — how a backend is selected, and the driver-level capability declarations
  • Types — the column type each logical type produces on each backend
  • Query DSL — the operator and aggregation vocabulary the API accepts
  • Migrations — the risk gate and refusals that do run
  • Row-level security — how a rule becomes a WHERE clause