PostgreSQL Compatibility¶
VaireDB speaks the PostgreSQL wire protocol, and PostgreSQL is the contract: where VaireDB and PostgreSQL disagree about what a statement means, PostgreSQL is right and VaireDB has a gap. DataFusion (read path) and DuckDB (write path, one instance per shard) are implementation layers, not part of the promise β a form only they have is out of scope.
This page is the public rendering of the project's gap analysis: what a PostgreSQL client cannot fully do today, axis by axis. Anything not listed here works and agrees with PostgreSQL, on the six axes below β with two caveats that are worth reading before you rely on that sentence.
The full record
The census this page summarizes lives in the repository, one row per divergence with its
cause and its SQLSTATE:
docs/specs/gap-analysis.md.
How to read the status labels¶
| Status | What it means for your client |
|---|---|
| π‘ Partial | The statement runs and the rows are PostgreSQL's, but something around them is not: an advertised column type, a decoration that is refused rather than applied, or an error under the wrong SQLSTATE. |
| β Silently wrong | Parses, runs, returns a plausible value that is not PostgreSQL's β no error, no warning. The dangerous class. There are none. |
| β Refused | Rejected loudly with a SQLSTATE, and the message names the spelling that does work where one exists. |
| π« Not planned | Refused on purpose, with a reason: it has no meaning in a shared-nothing cluster, or it is a DuckDB-only form PostgreSQL does not have. |
Nothing returns a wrong answer quietly
Every construct that used to β eight of them β is closed. On the six axes below, a query either agrees with PostgreSQL or fails visibly. That is the property to design against: you do not need to double-check results, you need to handle errors.
Status by axis¶
Counted as rows of the census, where one row can cover several spellings, so this compares axes rather than individual constructs.
| Axis | π‘ Partial | β Silently wrong | β Refused | π« Not planned |
|---|---|---|---|---|
| Statements | 15 | β | 9 | 8 |
| Data types | 6 | β | β | 8 |
| Aggregate functions | 2 | β | 3 | β |
| Window functions | 1 | β | 5 | 1 |
| Operators, literals and casts | 12 | β | 19 | β |
| Joins and set operations | 2 | β | 4 | β |
| Total | 38 | β | 40 | 17 |
For what is supported on each axis, see the supported SQL surface.
Statements¶
Every statement is either routed and correct, or refused by name with a SQLSTATE. The π‘ rows are not half-built statements: the statement works, and a decoration of it that sharding cannot honour is refused rather than quietly under-applied.
| Statement | What is refused |
|---|---|
ALTER TABLE |
Dropping the shard key; touching a pseudonymized column; retyping or dropping a column a constraint or index covers; every ADD/DROP CONSTRAINT form but UNIQUE (<shard key>); RENAME TO while an index exists. RENAME TO / SET SCHEMA must be the only action in the statement. |
DROP |
Several names in one statement; CASCADE / RESTRICT. |
TRUNCATE |
Several tables in one statement; CASCADE / RESTRICT; RESTART IDENTITY. |
CREATE INDEX / DROP INDEX |
UNIQUE off the shard key; a UNIQUE index narrowed by WHERE or NULLS NOT DISTINCT; an unnamed index; an index over an expression. |
CREATE / ALTER / DROP SCHEMA |
CASCADE, AUTHORIZATION; dropping public; renaming a schema that still holds a relation. sales.t and sales_t cannot coexist β the qualifier folds into the physical shard-table name. |
CREATE VIEW / ALTER VIEW |
MATERIALIZED; the dialect decorations (TEMPORARY, WITH (β¦), a typed column); a view over a metadata schema; a cyclic definition; ALTER VIEW β¦ RENAME TO. Views are read-only. |
MERGE INTO |
Any source but a co-located table sharded by the join column or an inline VALUES list; an ON that does not pin the shard key; UPDATE SET <shard key>; RETURNING; per-action WHERE; a merge into a table with pseudonymized columns. |
COPY |
PROGRAM; any format but CSV and PARQUET, and the format must be stated (PostgreSQL's default is TEXT); any option but HEADER / DELIMITER / QUOTE, and those only for CSV β a Parquet file carries its own names and types. FROM STDIN / TO STDOUT are CSV-only: a Parquet footer is written last and read first, so it is not a row-at-a-time stream. |
| Transaction blocks | Inside a block: UPDATE / DELETE, DDL, and reads of a table the block has written. Writes buffer in the coordinator and ship at COMMIT. Isolation levels are accepted and ignored. |
SET / SHOW / RESET |
search_path by name; SET LOCAL, SET ROLE, SET SESSION AUTHORIZATION, SET TRANSACTION, SET NAMES; SHOW TABLES and that family. A value outside the set the coordinator's behaviour already matches is refused by value, naming what VaireDB does instead. |
EXPLAIN |
EXPLAIN of a write β a write is planned by each shard's engine, so there is no coordinator plan; every option but ANALYZE / VERBOSE. |
DESCRIBE |
FORMATTED / EXTENDED β they promise storage detail the coordinator does not hold. |
β Refused outright, because they are single-node DuckDB utilities or catalog features with
no cluster meaning: ATTACH / DETACH, CHECKPOINT, EXPORT / IMPORT DATABASE (use
COPY), INSTALL / LOAD, CREATE SECRET (VaireDB has its own path for anonymization
secrets), USE, CALL, COMMENT ON, CREATE MACRO.
Data types¶
No type gap refuses a value. Every value on this axis round-trips faithfully; what diverges is the type VaireDB advertises for it β with one exception, the last row.
| Item | Divergence |
|---|---|
π‘ UBIGINT column |
Advertised numeric, not bigint β PostgreSQL has no unsigned integer type. |
π‘ STRUCT column |
Advertised text. |
π‘ ENUM column |
Advertised text. A PostgreSQL enum has no fixed type OID to advertise. UUID, JSON and CHAR/VARCHAR advertise their own. |
π‘ ENUM ordering |
Sorts by string value, not declaration order. |
π‘ VARCHAR(n) / CHAR(n) length |
The type OID is right, but the length is not on the wire, so psql's \d prints character varying without its (64). |
π‘ VARCHAR(n) / CHAR(n) not enforced |
A value longer than the declared length is stored and returned, where PostgreSQL raises 22001. Treat n as documentation and validate in the application. |
Any declared type VaireDB does not recognize degrades to text with its values intact, which is
what keeps it forward-compatible with types DuckDB adds.
π« Eight types are refused by name at CREATE TABLE, ADD COLUMN and ALTER COLUMN TYPE,
each naming what to write instead β they used to be accepted and then fail or lie on read:
| Refused | Use instead |
|---|---|
HUGEINT / INT128, UHUGEINT / UINT128 |
BIGINT, or DECIMAL(38,0) |
BIT / BITSTRING / VARBIT / BIT VARYING |
BOOLEAN, or VARCHAR for the '0'/'1' spelling |
BIGNUM / VARINT |
DECIMAL(38,s) |
UNION |
one nullable column per member, or JSON |
VARIANT |
JSON |
MAP |
JSON, or a pair of array columns |
TIMETZ / TIME WITH TIME ZONE |
TIMESTAMPTZ, or TIME with the offset in its own column |
Aggregate functions¶
The strongest axis, for one structural reason: DuckDB never computes an aggregate. Every aggregate is evaluated by DataFusion above the shards, and the distributed merge cannot emit a partial result as a finished one.
| Item | Divergence |
|---|---|
π‘ avg, stddev*, var*, variance over exact integers |
Value, type and PostgreSQL's sixteen decimal places, but the scale is constant where PostgreSQL picks one per value: the average of 1 and 2 prints 1.5000000000000000. A variance above 10Β²Β² raises 22003 instead of rounding. |
π‘ json_agg / jsonb_agg of a bare JSONB column |
Quotes the document as a string instead of embedding it. Write json_agg(v::json) β the cast embeds. |
β rank(a, b) WITHIN GROUP (ORDER BY x, y) |
The multi-column hypothetical-set form. The one-column form of all five ordered-set aggregates answers, as do both percentiles' fraction and array overloads. |
β GROUPING SETS ((), ()) |
The empty set repeated. GROUPING SETS (()) alone answers. |
β xmlagg |
No XML type. |
Window functions¶
All 11 PostgreSQL window functions are present, and the frame engine is correct: all five units,
peer groups, and integer, float and INTERVAL offsets.
| Item | Divergence |
|---|---|
π‘ ntile($1), lag(x,$1), nth_value(x,$1) |
Correct provided your client declares a parameter type OID. |
β FILTER (WHERE β¦) OVER (β¦) on a collecting aggregate β array_agg, string_agg |
Every null-skipping aggregate answers: count (including count(*)), sum, avg, min, max, and the boolean, bit, stddev and variance families. A plain (non-windowed) FILTER is unaffected. Use a subquery for the collecting pair. |
β EXCLUDE {CURRENT ROW \| GROUP \| TIES \| NO OTHERS} |
Not parseable yet β the one genuinely missing feature on this axis. |
β rank / row_number / dense_rank / percent_rank / cume_dist OVER (ORDER BY <pseudonymized col>) |
A window is an ordering, and a digest has no plaintext order on the server. |
β first_value / last_value / nth_value / lag / lead over a pseudonymized column ordered by it |
Same reason β it would return the digest of the wrong row. |
π« IGNORE NULLS |
PostgreSQL does not implement the standard's null-treatment option and always behaves as RESPECT NULLS, which is what VaireDB does. RESPECT NULLS is accepted. |
PARTITION BY over a pseudonymized column is sound
HMAC is deterministic and injective, so digest equality is plaintext equality. Grouping and equality over a pseudonymized column are exact; only order and value are destroyed. See Column Pseudonymization.
Operators, literals and casts¶
The widest axis. The read path's expression surface and type layer are closed, including
predicate and LIMIT push-down into the shards.
| Item | Divergence |
|---|---|
| π‘ An integer literal | Typed bigint where PostgreSQL types it integer, so SELECT 1 is described as bigint. The value is PostgreSQL's, and a cast or a column reference carries its own type. |
π‘ 1_000 |
Answers 1000, typed numeric where PostgreSQL types it integer. |
π‘ TIMESTAMP 'β¦' literal |
A literal is bounded to 1677β2262 by nanosecond typing. A TIMESTAMP column is microseconds and unaffected. |
π‘ TIMESTAMPTZ 'β¦+02' literal |
Normalized to UTC correctly, but the offset is not shown back. |
π‘ now() / current_timestamp |
The instant is right and read once, on the coordinator, but carries no time zone where PostgreSQL's is timestamptz. transaction_timestamp() is per-statement, because a transaction block buffers. clock_timestamp() and timeofday() are per-call, as PostgreSQL's are. |
π‘ ::json / ::jsonb and ->, ->>, #>, #>> |
Casts validate and all four accessors answer, including negative indices and the '{a,b}' path spelling. Two residues: ::jsonb does not normalize key order or whitespace, and a missing key is NULL where PostgreSQL's json accessors raise. |
π‘ '\xDEADBEEF'::bytea |
The cast is PostgreSQL's, with both its SQLSTATEs, over a literal, NULL or a parameter. An implicit stringβbytea coercion, a DEFAULT, and text-format bytea output are residues. |
π‘ COLLATE |
C, POSIX, ucs_basic and default name byte order, which is what VaireDB does, so they are accepted. Every other collation is refused by name rather than parsed and ignored. |
π‘ \|\| on arrays |
The string form is correct on both paths; the array overloads differ between the two engines. |
π‘ '{1,2,3}'::INT[] |
PostgreSQL's array text form works on read; DuckDB renders lists as [1, 2, 3] on write. |
π‘ X'DEADBEEF' |
Answers a binary value; PostgreSQL gives bit(32). |
π‘ <=> |
MySQL null-safe equality β no PostgreSQL equivalent, and DuckDB reads it as vector distance. |
β Refused, grouped by cause β the message names the spelling that answers where one exists:
- No parser support yet:
**,///DIV,CAST(x AS T ARRAY),{'a': 1}/MAP {'a': 1},U&'\0041',N'foo',B'1010',INTERVAL '1-2' YEAR TO MONTH. - DuckDB-only, so out of scope:
GLOB/~~~,str[n]andstr[a:b]string subscripting. - Meaning cannot be reproduced exactly, so it is refused rather than approximated:
@?(jsonpath is a second language β the four accessors above answer),@@and full-text search,'abcde'::VARCHAR(2)(the length is enforced nowhere, sosubstr()is named instead),LIKE ANY (array), a computedSIMILAR TOpattern orLIKE β¦ ESCAPE '<non-backslash>'(a literal pattern is translated before evaluation; a runtime one cannot be),to_number(text, text)(to_charis unaffected),struct.field(writestruct['field']),MAP(['a'],[1]), and correlatedEXISTS/IN (subquery)in the select list beyond an equality correlation.
Column labels
An unaliased expression column carries the name PostgreSQL derives, or ?column? where
PostgreSQL has none, and a label that repeats is sent as often as PostgreSQL sends it β
SELECT sum(a), sum(b) is two columns both called sum.
Joins and set operations¶
Every row on this axis was measured in both layouts the shard map produces, co-located and shuffle, and none differs between them: the shard map may change the plan, never the answer.
| Item | Divergence |
|---|---|
| π‘ A join on a pseudonymized column | An equi-join relates two tables correctly, including under a GROUP BY above it. What the digest does not preserve is refused rather than answered: an ordering join (a.email < b.email), an ORDER BY on the column, and a comparison against plaintext. |
| π‘ An untyped literal branch of a set operation | β¦ UNION ALL SELECT '9' comes back as text where PostgreSQL resolves to the typed branch's integer. The rows are PostgreSQL's; an ORDER BY over the column sorts lexicographically. |
β x <op> ANY \| SOME \| ALL (subquery) outside a predicate AND chain |
Every operator answers in a WHERE, HAVING, QUALIFY or join ON, as an operand of a top-level AND. A select list, ORDER BY, NOT, CASE and OR are refused. = ANY, <> ALL, IN and EXISTS are untouched. |
β WHERE k NOT IN (<correlated subquery>) |
Write NOT EXISTS plus the IS NULL test it needs β NOT EXISTS is two-valued. NOT IN over NOT NULL columns answers, correlated or not. |
β Two USING shapes |
A wildcard beside a qualified join key, and the merged key beside the same key per side under one name β PostgreSQL answers with two result columns of the same name, which a DataFusion schema cannot hold. |
β A bare USING / NATURAL key in WHERE over a side the rewrite cannot read |
Three shapes: a left side that is itself a join, two joins in one query block sharing a key name, and a key reached from a nested block. The qualified spelling answers. |
Supersets: things VaireDB accepts and PostgreSQL does not¶
These are deliberate, and will not be narrowed to PostgreSQL parity:
QUALIFYβ DuckDB/Snowflake syntax PostgreSQL does not have.count(DISTINCT x) OVER (β¦)β PostgreSQL rejectsDISTINCTin a window aggregate.count(DISTINCT (a,b)), and negativenth_valueoffsets.INTERVAL 1 DAYunquoted.DISTINCT ON (col)is the reverse case β a PostgreSQL extension, and it works.
Standing limitations¶
Properties of the design rather than backlog, and the cause of most of the π‘ statement rows:
- No cross-shard atomicity. No two-phase commit. A transaction spanning more than one node
set is refused at
COMMITunlessallow_cross_shard_transactionsis set; a partial multi-shard write reports40003with how much was written. - Uniqueness only over the shard key.
UNIQUEandPRIMARY KEYare enforceable only when they include the shard key.FOREIGN KEYis never enforced;CHECKalways is. - The shard key is immutable.
UPDATEof the shard-key column is refused; row relocation does not exist. - A transaction block buffers. Only statements the coordinator can answer truthfully without running them are allowed inside one.
- No role model, therefore no privilege checks. Most visibly,
COPYreads and writes any path the coordinator process can. - Pseudonymization is one-way and write-path only. Order and value are unrecoverable; equality and grouping are exact.
- Text ordering is byte order.
- A statement is either correct on every shard or refused. Nothing is applied partially: a decoration that only affects performance is stripped, and anything that changes results is refused by name.
Also not planned, each refused with its reason: sequences and SERIAL, SET search_path,
user-defined types and domains, ANALYZE and VACUUM, and the DuckDB-only PIVOT, UNPIVOT,
SUMMARIZE and SET VARIABLE. See the roadmap.
Two caveats¶
Scalar functions have not been measured
The six axes above do not include PostgreSQL's scalar functions β the string, math,
pattern, conditional, array and datetime families. They have never been run against the
PostgreSQL oracle the way the six axes were, so "not listed here" is weaker evidence for
them than for anything else on this page. A function that does not exist answers 42883 and
is loud; a function both engines have under PostgreSQL's name but evaluate by a different
rule would not be. That measurement pass has not run yet.
VARCHAR(n) is not enforced
A value longer than the declared length is stored and returned, where PostgreSQL raises
22001. It is the one divergence on this page that is neither fixed nor deliberate β the
check does not exist yet. Validate lengths in the application until it does.
How this was measured¶
Against a live 5-node cluster (1 coordinator, 1 scheduler, 3 core nodes), byte-diffed against a
PostgreSQL 16.15 oracle, with a DuckDB 1.5.5 CLI as a second oracle for forms PostgreSQL
cannot parse, and result types and column labels read from the extended protocol's Describe.
Every entry on this page has a test behind it, and every refusal is probed for over-reach: each test asserts, alongside the refusal, that the neighbouring form which loses no clause still answers. That is what keeps a refusal honestly scoped rather than a blanket rejection.