Skip to Content
We are live but in Staging 🎉
Data EnginesSQLSQL Compatibility

SQL Compatibility

K3 Tables speak DuckDB-flavored SQL. Anything DuckDB parses — joins, CTEs, window functions, JSON operators, set ops, subqueries — K3 can plan and dispatch, plus a set of K3 extensions (pgvector KNN operators, graph DDL, residency verbs, RESTORE, IMPORT/EXPORT TABLE). There is an honest refusals list, and the planner picks a strategy per statement based on the table’s primary key, partitioning, and the query shape.

This page is the SQL contract: what dialect, what types, what statement shapes are supported, what’s not, and how to connect a native client.

Dialect

Base SQL dialectDuckDB SQL (parsed by sqlparser’s DuckDbDialect)
Driven byExecute RPC (POST /:bucket/tables/_execute) — sends SQL, returns structured results — plus the Postgres wire and gRPC, which all reach the same planner
Where DuckDB documents the dialectduckdb.org/docs/sql 
K3-specific divergencesSee below, then Refusals — the rest is DuckDB

Why DuckDB: it’s a single, well-documented, embeddable SQL parser + planner with strong analytical-workload focus. K3 hands DuckDB the SQL, gets a parsed plan back, then routes the operation across the write log + Delta tiers via its own dispatcher. You write standard DuckDB SQL; K3 worries about the tier routing.

Where K3 diverges from stock DuckDB

These are additions and rewrites applied before or around the DuckDB parse — none of them change the meaning of a query stock DuckDB would already accept:

DivergenceWhat happens
pgvector KNN operators (<->, <#>, <=>)Rewritten to DuckDB list_* distance calls before the parse — DuckDbDialect rejects two of the three outright. See Vector KNN.
UPSERT INTO t …Normalized to INSERT INTO t … with replace-on-PK semantics; statement_kind reports upsert.
Plane verbs (CREATE/DROP GRAPH, SHOW GRAPHS, RESTORE, IMPORT/EXPORT TABLE, SHOW VECTOR INDEXES, LOAD/RELEASE TABLE|GRAPH|VECTOR INDEX)Intercepted lexically — sqlparser has no statement for them.
FOR UPDATE / FOR SHARE / FOR NO KEY UPDATE / FOR KEY SHAREValid Postgres that DuckDB rejects. K3 strips the clause and serves the SELECT as a snapshot read — no row lock is taken. On the pg wire inside a transaction the read’s watermark folds into the commit’s optimistic guard (a conflicting write aborts with 40001); outside a transaction you get a NOTICE.
Functionally-dependent GROUP BYPostgres accepts GROUP BY <pk> while selecting other columns; DuckDB doesn’t. K3 completes the GROUP BY with each bare column projection — identical groups, identical answers.
CREATE INDEX … WITH (…)pgvector storage-parameter syntax DuckDbDialect rejects; that one statement shape is re-parsed under PostgreSqlDialect.
public. schema qualifier (pg wire only)Stripped before the SQL reaches the planner — pg schemas don’t exist here; the bucket is the namespace. CREATE SCHEMA public is an accepted no-op.
SERIAL / BIGSERIAL / GENERATED … AS IDENTITYAccepted; the column becomes long with a GENERATE_SNOWFLAKE_ID() default (there is no sequence). An explicit DEFAULT wins and keeps the declared width.
ADD/DROP CONSTRAINT, inline REFERENCESADD CONSTRAINT … CHECK is validated and enforced; FOREIGN KEY (inline or table-level, CREATE or ALTER) is validated at DDL and enforced on the INSERT side since tables v0.1.36; DROP CONSTRAINT removes a stored CHECK (a never-stored name is a no-op). See Constraints: what is enforced.
Session time zonePinned to UTC on every DuckDB connection, so date_trunc / EXTRACT / date math are host-independent.

Column types

Fifteen column types in the plane’s type vocabulary — all serialize losslessly to Delta. The pg OID column is what a Postgres-wire client sees in the RowDescription:

K3 typeDeclared in SQL aspg OID on the wireWire shape on Insert / Merge
stringVARCHAR / TEXT / CHAR / STRINGtext (25)JSON string
uuidUUIDuuid (2950)JSON string, canonical 36 chars
shortSMALLINT / TINYINT / INT2int2 (21)JSON number
intINT32 (see note)int4 (23)JSON number
longBIGINT / INTEGER / INT / INT8 / INT64int8 (20)JSON number or string (large values)
floatREAL / FLOAT4 / FLOAT(1..24)float4 (700)JSON number
doubleFLOAT / FLOAT8 / DOUBLE / DOUBLE PRECISION / FLOAT(25..53)float8 (701)JSON number
decimal(p,s)NUMERIC(p,s) / DECIMAL(p,s)numeric (1700)JSON string (exact, never a float)
booleanBOOLEAN / BOOLbool (16)JSON boolean
dateDATEdate (1082)JSON string "YYYY-MM-DD"
timestampTIMESTAMP (microseconds UTC)timestamptz (1184)JSON number (µs since epoch) or "YYYY-MM-DD HH:MM:SS"
binaryBYTEA / BLOB / BINARY / VARBINARYbytea (17)base64 string
jsonJSON / JSONBjsonb (3802)JSON value (object / array / scalar)
array<T>T[] / ARRAY<T>text (25), as a {…} array literalJSON array
struct<f:t,…>STRUCT(f t, …)jsonb (3802)JSON object
vector<N>VECTOR(N) — pgvector spellingtext (25), as a […] literalJSON array of numbers

INTEGER is stored as 64-bit. A SQL INTEGER/INT column resolves to the long vocab type, not int. Auto-generated surrogate keys are 64-bit snowflake ids, so a foreign key declared integer to match a PK’s declared type would silently truncate to its low 32 bits otherwise. Introspection therefore reports BIGINT for a column you declared INTEGER — a parity nit traded against silent data loss. The int vocab term still exists and is accepted by the structured CreateTable RPC.

long values that exceed safe-integer range (±2^53 − 1) — pass as a JSON string to preserve precision. K3 stores int64 natively, and a BIGINT past 2^53 survives every entry and read path exactly.

The advertised widths are true, not merely stable. Since tables v0.1.38 a SMALLINT advertises int2, an INT32 advertises int4, a REAL advertises float4, and UUID is a type of its own (OID 2950, the 16 raw bytes in binary format, a non-uuid refused 22P02 rather than stored). Earlier releases advertised int8/float8/text for all of them — stably wrong, which is exactly what a stability check passes.

VECTOR needs a dimension. Bare VECTOR is rejected at DDL naming the column; pgvector’s dimension-less form (typed on first insert) has no equivalent here. NUMERIC/DECIMAL default to decimal(38,9); precision above 38 (Arrow Decimal128’s max) is rejected rather than narrowed.

array / vector columns are advertised as text on the pg wire rather than a native pg array OID — both text and binary format are then trivially valid, so a binary-binding driver never breaks on them. Native array binary encoding is a follow-up.

Type fidelity — exact, or refused

The standing rule the engine enforces, and the reason these tables are safe to put money in:

A value the declared column can hold round-trips exactly, by every entry path and every read path. Where it cannot, the write is REFUSED loudly, naming the column and the constraint. Silently storing — or serving — a different number is never a legal outcome.

That is a tested contract, not a design intention. Every declared type is measured across four entry paths — SQL literal, text-format bind, binary-format bind, COPY … FROM STDINtimes two read formats (text and binary), and the advertised OID is asserted true rather than merely stable. The same matrix runs across the other wires the plane serves, so the value you recover on Postgres equals the value you recover over gRPC or Bolt after canonicalisation. It is re-run every release.

Crypto-grade numeric precision

DECIMAL(38,18) holds an 18-decimal token amount exactly. DECIMAL(38,0) holds raw wei — all 27 integer digits of it. Not rounded, not approximated, not “close enough for display”: the same digits you wrote come back, on every entry path and every read path, verified per release.

There is no float detour anywhere on that path. Decimals are Decimal128 end to end, carried on the wire as canonical decimal text, and parsed by one shared routine that the SQL literal, the text bind, the binary bind and COPY all call — so a value cannot change depending on which door it came through. A cent cannot move at 2^53, because nothing on the path is an f64.

CREATE TABLE positions ( id BIGINT PRIMARY KEY, wei DECIMAL(38,0), -- raw wei: 27 integer digits, exact tokens DECIMAL(38,18), -- 18-decimal token amounts, exact usd DECIMAL(18,2) -- ordinary money );

Declare the scale you actually need and the engine keeps it. Never store money as DOUBLE, as TEXT, or as integer-cents-out-of-caution — none of those is safer here, and two of them are worse.

The rules around the edges

  • 38 digits is a platform cap, not a tunable. Arrow’s Decimal128, the Delta Lake protocol and DuckDB each cap decimal precision at 38 independently, and Delta’s is the storage format. NUMERIC(39) is rejected at DDL, never silently narrowed. Postgres’s unconstrained NUMERIC (131 072 integer digits) has no equivalent here.
  • Bare NUMERIC is decimal(38,9), and it says so. CREATE TABLE emits a NOTICE naming the column, the type it actually got, what happens to extra digits, and the fix. Because the engine chose that scale, such a column refuses a value it would have to round — Postgres would not have lost those digits. A scale you declared rounds the way pg does: 1.005 into NUMERIC(10,2) stores 1.01.
  • Exponent forms are exact numerics. 1e-6 into a column that can hold 0.000001 stores 0.000001, on every entry path (Postgres §4.1.2.6 makes a constant carrying an exponent an exact numeric).
  • Overflow is 22003; malformed input is 22P02. A SMALLINT out of range refuses rather than wrapping, a REAL overflow refuses rather than becoming inf, and an impossible date or a non-uuid is refused rather than landing NULL.
  • JSON is served verbatim on this wire — key order, duplicate keys and integers past 2^53 all survive.
  • Two places digits do move, both deliberate and both avoidable. A decimal nested inside an array<T> or struct<…> is demoted to f64 — give money its own column. And in-engine division truncates where Postgres rounds (CAST(2.01 / 2 AS DECIMAL(10,2))1.00, pg gives 1.01; an upstream DuckDB issue, tracked): wrap the division in an explicit ROUND(…, s) when the last digit matters.
  • VARCHAR(n) / CHAR(n) lengths are parsed and not enforcedVARCHAR(5) accepts a megabyte and CHAR(10) does not blank-pad. Use a CHECK constraint, which is enforced.
  • One timestamp vocabulary, timestamptz. Offsets you send are converted, not sliced off, and stored and served as UTC microseconds. An offset-less literal is read as UTC where pg would use the session TimeZone; there is no TIMESTAMP WITHOUT TIME ZONE.

default_expression

Every column may carry a default expression — literal SQL applied at row-commit time when the column is absent from the input row. Validated at table creation:

Default expressionEffectRequired column type
"CURRENT_TIMESTAMP()" / "NOW()"Insert-time timestamp, ISO 8601 UTCtimestamp
"CURRENT_TIMESTAMP_MICROS()"Insert-time µs since epochlong
"GENERATE_UUID()" / "UUID()"36-char canonical UUID v4string
"GENERATE_ULID()" / "ULID()"26-char Crockford-base32 ULIDstring
"GENERATE_SNOWFLAKE_ID()" / "SNOWFLAKE_ID()"64-bit monotonic id (custom epoch 2024-01-01)long
"42" / "'pending'" / "true" / "NULL"Literal constantsmust match the literal
"" (empty)Column required at write time unless nullable = true

The trailing () is optional (CURRENT_TIMESTAMP and CURRENT_TIMESTAMP() are both accepted). Any other expression is rejected at table creation, and so is a function whose output type doesn’t match the declared column type — you find out at DDL, never on the first insert. A default fires only when the column is absent from the input row; an explicit null from the caller is honored as-is.

Default expressions and SQL functions are different contexts. The table above is the default_expression vocabulary, validated at DDL. In query SQL, UUIDs come from the ordinary functions — gen_random_uuid(), UUID(), and casts (UUID()::text) all work in a SELECT or DML expression. GENERATE_UUID() is not a SQL function — it is only valid as a column default_expression.

Statement shapes — supported

What you can put in an Execute SQL string:

DDL

-- Create a table with a composite primary key + partition column + JSON column CREATE TABLE events ( id BIGINT NOT NULL, user_id VARCHAR NOT NULL, occurred_at TIMESTAMP NOT NULL, event_type VARCHAR NOT NULL, payload JSON, PRIMARY KEY (id, user_id) ) PARTITIONED BY (event_type); -- Add / drop / widen columns (multiple operations in one ALTER are fine) ALTER TABLE events ADD COLUMN session_id VARCHAR; ALTER TABLE events DROP COLUMN session_id; ALTER TABLE events ALTER COLUMN id SET DATA TYPE BIGINT; -- Drop a table DROP TABLE events; DROP TABLE IF EXISTS events; -- Clear every row, keep the schema, PK, indexes, graph bindings, reservations TRUNCATE TABLE events; TRUNCATE events; -- CTAS (CREATE TABLE AS SELECT) — schema inferred from the SELECT CREATE TABLE click_summary AS SELECT user_id, COUNT(*) AS n FROM events WHERE event_type = 'click' GROUP BY user_id; -- Indexes. An index IS a table (`__idx_{table}_{column}` / `__uidx_…`). CREATE INDEX ON events (user_id); -- non-unique: exactly one column CREATE UNIQUE INDEX ON events (id, user_id); -- unique: composite allowed CREATE INDEX ON docs USING hnsw (emb vector_cosine_ops) WITH (m = 16, ef_construction = 64, async); -- Time travel, in reverse RESTORE TABLE events TO VERSION AS OF 42; RESTORE TABLE events TO TIMESTAMP AS OF '2026-05-27T10:00:00Z'; -- Catalog statements CREATE DATABASE [IF NOT EXISTS] analytics; SHOW DATABASES; -- SHOW SCHEMAS is a synonym SHOW TABLES; DESCRIBE events; -- DESC events DESCRIBE DATABASE analytics; -- Plan inspection — renders the typed plan as one row, executes nothing EXPLAIN SELECT * FROM events WHERE id = 1;

Notes:

  • PRIMARY KEY is honored — inline column-level (id BIGINT PRIMARY KEY) and table-level (PRIMARY KEY (id, user_id)) both work. The key flows to the planner so subsequent writes get keyed routing for free.
  • PARTITIONED BY (col, …) controls Delta partitioning. Reads that filter on a partition column can prune entire partitions.
  • CTAS infers types from the SELECT — int-shaped JSON numbers → long, fractional → double, strings → string, bools → boolean. For typed migrations, pass explicit columns instead via the structured CreateTable RPC.
  • DROP COLUMN and ALTER COLUMN … SET DATA TYPE are logical — recorded on the sidecar, subtracted from (or cast on) every schema surface at read time; no file rewrite. Retypes are widening-only and validated at the executor.
  • Vector-index operator classes are vector_l2_ops (the default) / vector_ip_ops / vector_cosine_ops. WITH accepts m, ef_construction, lists, quantization (hnsw: f16 | i8; ivfflat: sq8), and the K3 extension async (register building, build in the background — watch SHOW VECTOR INDEXES). An unknown WITH key is an error, never a silently-kept default.
  • RESTORE … TO TIMESTAMP AS OF requires an offset (…Z, …+02:00). It is destructive to the tail by design: writes acked after the target version are discarded and the un-drained write log is wiped so they can’t resurrect.

Reserved words as column names — quote or rename. DuckDB’s reserved words apply, and some bite common column names: at is reserved (a natural audit-timestamp name), and so are end, order, table, to, user. A bare at TIMESTAMP in a CREATE TABLE (or WHERE at > …) is a parse error. Double-quote the identifier ("at" TIMESTAMP, WHERE "at" > …) — quoting must then be used everywhere the column is referenced — or pick an unreserved name like occurred_at.

Constraints: what is enforced

Which constraint forms the engine actually enforces is load-bearing information for anyone modelling a transactional schema, so here is the complete matrix. The rule since tables v0.1.31: a constraint is enforced, rejected at DDL, or accepted with a warning — never silently ignored.

FormBehavior
PRIMARY KEY (incl. composite)Enforced. Every write is keyed; re-INSERT of a committed key → 23505.
CREATE UNIQUE INDEX (incl. multi-column)Enforced at the write path → 23505. This is the enforced way to get a uniqueness guarantee beyond the PK.
NOT NULLEnforced23502.
CHECK — literal predicatesEnforced at every write door → 23514. Grammar: comparisons of a column vs a literal or column (< <= > >= = != <>), AND/OR/NOT, IS [NOT] NULL, [NOT] IN (literals), [NOT] BETWEEN, typed DATE '…'/TIMESTAMP '…' literals. Works inline, table-level, and via ALTER TABLE ADD CONSTRAINT … CHECK (stored on the table and live from the next statement).
ALTER TABLE … ADD IMMUTABLE <name> CHECK (…)Enforced. Rows matching the predicate are sealed — any write touching them → 23P90. The closed-accounting-period lock.
CHECK with a subqueryRejected at DDL0A000 cannot use subquery in check constraint (Postgres rejects these too).
CHECK outside the grammar (arithmetic, function calls)Rejected at DDL0A000, with the error naming the supported forms. An unenforceable CHECK cannot be declared.
FOREIGN KEY — inline REFERENCES, table-level, or ALTER … ADD CONSTRAINTEnforced on the INSERT side (tables v0.1.36+, the default on managed clusters). A child row naming a missing parent → 23503. DDL only accepts a declaration the write gate can actually keep: a missing parent table → 42P01, referenced columns that are not the parent’s declared PRIMARY KEY42830, and DEFERRABLE / INITIALLY DEFERRED / ON DELETE|UPDATE CASCADE|SET NULL|SET DEFAULT0A000. MATCH SIMPLE, as in Postgres: a row whose referencing columns are all NULL or absent satisfies the constraint. Not checked: the DELETE side, autocommit UPDATE of an FK column, MERGE, and the bulk write stream — see the note below.
ALTER … ADD CONSTRAINT … UNIQUE⚠️ Accepted, NOT enforced (warned). Use CREATE UNIQUE INDEX for the enforced form.
DROP CONSTRAINT <name>Removes a stored CHECK by name. A name that was never stored (an FK a migration downgrade removes) is a silent no-op, so Alembic/Django downgrades proceed.
CREATE TRIGGER / CREATE RULE / CREATE VIEW❌ Rejected — not part of the SQL surface.

What FOREIGN KEY covers, and what you still own. An INSERT or UPSERT naming a missing parent is refused with 23503. The parent probe is not a blocking lock — this plane has none — it is a guard-only key folded into the commit: a concurrent write to that parent (including the DELETE that would orphan your child) lands a newer version than the probe observed, so the whole commit aborts with 40001 rather than letting an orphan through. Two children of the same parent take no claim on each other and both commit, so a popular parent is not a serialization point.

What is not checked is the other direction: deleting a parent that still has children succeeds, and so does an autocommit UPDATE of an FK column. Keep those in the writer — the parent-delete check is the one to write yourself, and SELECT … FOR UPDATE on the parent makes it conflict-safe.

Enforcement is not retroactive, and old migrations may now fail. An FK declared while enforcement was off was discarded rather than stored, and it does not reappear when enforcement is on — re-declare it with ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY, which is what moves enforcement onto an existing table. And a migration carrying ON DELETE CASCADE or DEFERRABLE now fails at DDL where it used to pass silently. That is the point: a clause that was never going to run no longer claims it will.

Residency verbs

LOAD TABLE / RELEASE TABLE set and clear the catalog pin readers warm and mirror from — this is the pinning verb the capacity model refers to, and it is separate from a reservation (which is a budget, never a pin list). The same shape exists for graphs and vector indexes:

LOAD TABLE orders; RELEASE TABLE orders; LOAD GRAPH social; RELEASE GRAPH social; LOAD VECTOR INDEX docs (emb); RELEASE VECTOR INDEX docs; SHOW VECTOR INDEXES;

The identifier may be double-quoted (case preserved) and a single trailing ; is allowed; exactly one identifier follows the verb.

DML — writes

-- Single-row insert (keyed) INSERT INTO events (id, user_id, occurred_at, event_type, payload) VALUES (1, 'u-101', TIMESTAMP '2026-05-27 10:00:00', 'click', '{"page":"/pricing"}'); -- Bulk insert (keyed) INSERT INTO events (id, user_id, occurred_at, event_type, payload) VALUES (1, 'u-101', TIMESTAMP '2026-05-27 10:00:00', 'click', '{"page":"/pricing"}'), (2, 'u-101', TIMESTAMP '2026-05-27 10:01:00', 'click', '{"page":"/signup"}'), (3, 'u-102', TIMESTAMP '2026-05-27 10:02:00', 'purchase', '{"sku":"A-12","amount":49.99}'); -- INSERT … SELECT (keyed-from-select). Columns map BY POSITION, not by name. INSERT INTO click_summary SELECT user_id, COUNT(*) FROM events WHERE event_type = 'click' GROUP BY user_id; -- Conflict handling on the PK INSERT INTO events (id, user_id) VALUES (1, 'u-101') ON CONFLICT DO NOTHING; INSERT INTO events (id, user_id) VALUES (1, 'u-101') ON CONFLICT (id, user_id) DO UPDATE SET user_id = EXCLUDED.user_id; INSERT INTO events (id, user_id, event_type) VALUES (1, 'u-101', 'click') ON CONFLICT (id, user_id) DO UPDATE SET event_type = 'reclick'; -- expressions & literals OK INSERT INTO events (id, user_id) VALUES (1, 'u-101') ON DUPLICATE KEY UPDATE user_id = VALUES(user_id); -- MySQL spelling, same lowering UPSERT INTO events (id, user_id) VALUES (1, 'u-101'); -- K3 spelling of insert-or-replace -- RETURNING on any DML INSERT INTO events (user_id) VALUES ('u-101') RETURNING id, occurred_at; UPDATE events SET event_type = 'x' WHERE id = 1 RETURNING *; -- UPDATE via PK (keyed → write log) UPDATE events SET event_type = 'click_pricing' WHERE id = 1 AND user_id = 'u-101'; -- UPDATE via non-PK predicate (non-keyed → ⚠️ Delta-only, see warnings) UPDATE events SET event_type = 'archived' WHERE occurred_at < TIMESTAMP '2025-01-01 00:00:00'; -- DELETE via PK (keyed) DELETE FROM events WHERE id = 1 AND user_id = 'u-101'; -- DELETE via non-PK predicate (non-keyed → ⚠️) DELETE FROM events WHERE occurred_at < TIMESTAMP '2025-01-01 00:00:00'; -- MERGE (upsert) — source is inline rows / a query / another table MERGE INTO events AS t USING (SELECT * FROM events_staging) AS s ON t.id = s.id AND t.user_id = s.user_id WHEN MATCHED THEN UPDATE SET event_type = s.event_type, payload = s.payload WHEN NOT MATCHED THEN INSERT (id, user_id, occurred_at, event_type, payload) VALUES (s.id, s.user_id, s.occurred_at, s.event_type, s.payload);

Routing rule of thumb: predicates / ON clauses that match the table’s merge_keys route through the write log (the planner picks a KEYED_* strategy). Predicates that don’t route directly to Delta — fast but bypass the log, which carries the WAL-overwrite risk discussed in Execute → UPDATE.

UPDATE and DELETE require a WHERE clause. A bare UPDATE t SET … or DELETE FROM t is refused — this is the foot-gun the rule exists to stop. Spell the intent: WHERE TRUE to touch every row, or TRUNCATE TABLE t to clear the table while keeping its schema.

ON CONFLICT notes: with no conflict target the PK is the target; a named target must be the PK or a UNIQUE index (checked at execution). DO UPDATE SET assignments take arbitrary expressions and literalsSET status = 'posted', SET n = n + 1, SET c = EXCLUDED.c — since v0.1.27 (earlier releases accepted only the full-row col = EXCLUDED.col idiom).

A plain INSERT on an existing key is a 23505, by design. An INSERT without a conflict clause is never an upsert — re-inserting an already-committed PK fails with duplicate key value violates unique constraint (SQLSTATE 23505). Handlers that must be safe on re-invocation should use ON CONFLICT (<pk>) DO UPDATE, UPSERT INTO, or the managed dodil data table upsert.

DML — reads

-- Simple SELECT SELECT * FROM events WHERE event_type = 'click' LIMIT 100; -- Aggregates (federated across partitions when applicable) SELECT user_id, COUNT(*) AS n FROM events GROUP BY user_id ORDER BY n DESC; -- Joins SELECT e.user_id, u.email, COUNT(*) AS clicks FROM events e JOIN users u ON e.user_id = u.id WHERE e.event_type = 'click' GROUP BY e.user_id, u.email; -- Window functions SELECT user_id, occurred_at, LAG(occurred_at) OVER (PARTITION BY user_id ORDER BY occurred_at) AS prev_event FROM events; -- CTEs (WITH …) WITH purchases AS ( SELECT user_id, payload->>'sku' AS sku, (payload->>'amount')::double AS amount FROM events WHERE event_type = 'purchase' ) SELECT sku, SUM(amount) AS total FROM purchases GROUP BY sku; -- Subqueries SELECT * FROM events WHERE user_id IN (SELECT id FROM users WHERE tier = 'pro'); -- Filter by a list of ids with one bound array parameter (pg wire) — no -- string-built IN (…) needed SELECT * FROM events WHERE id = ANY($1); -- e.g. psycopg: cur.execute("SELECT * FROM events WHERE id = ANY(%s)", ([1, 2, 3],)) -- SQLAlchemy: text("SELECT * FROM events WHERE id = ANY(:ids)"), {"ids": [1, 2, 3]} -- Set ops (SELECT user_id FROM events WHERE event_type = 'click') INTERSECT (SELECT user_id FROM events WHERE event_type = 'purchase');

All standard DuckDB read shapes work. The planner picks UNARY_WAREHOUSE, UNARY_MERGED_STRONG, or FEDERATED_* depending on the write-log backlog, partitioning, and aggregate-vs-scan shape — see Read freshness and Execute → SELECT.

Read freshness

Freshness is no longer a client knob. ExecuteRequest.freshness (and the CLI’s --freshness) still exist on the wire for compatibility, but the runtime ignores the requested value: reads are read-your-writes by default, and state decides which physical strategy serves them.

The coordinator runs a frontier check before every read — one in-memory call asking the writer whether the database’s write-log backlog is empty on an authoritative shard. If it is, every acked write is already durable in Delta, so eventual ≡ strong and the read takes the analytical fast path (Delta snapshot, distributed fan-out, spool, result cache). If emptiness cannot be proven — writer unreachable, shard absent, mid-failover — the read falls back to the merged write-log + Delta path. The check is deliberately conservative: a false negative costs one strong read; a false positive would cost truth.

You can still see which path served a read — the response’s QueryStrategy / ServedBy fields report it. See Execute → SELECT.

JSON columns

K3’s json column is a first-class semantic type. Stored as canonical JSON text under the hood, tagged so DuckDB recognizes it. Every DuckDB JSON operator works:

-- Field access SELECT payload->'sku' FROM events; -- returns JSON SELECT payload->>'sku' FROM events; -- returns TEXT -- JSON path SELECT json_extract(payload, '$.amount') FROM events; SELECT json_extract_string(payload, '$.sku') FROM events; -- Array / object shape SELECT json_array_length(payload->'tags') FROM events; SELECT json_keys(payload) FROM events; SELECT unnest(json_extract(payload, '$.tags[*]')) FROM events; -- Cast extracted scalars SELECT (payload->>'amount')::double AS amount FROM events; -- Round-trip a Postgres-style cast SELECT payload::json->'tags'->>0 FROM events;

For high-volume read paths, promote hot fields to typed columns; reserve json for the long tail / unstructured metadata. Note that DuckDB has no json_each — that’s the SQLite/Postgres spelling; use json_keys or unnest(json_extract(…)).

Nested array<T> and struct<…> columns store as native Delta Array/Struct, so col, col->'k', col->>'k' and json_array_length(col) all project. On the pg wire a struct reads as jsonb and an array as a {…} array literal.

Vector KNN — pgvector operators

The pgvector distance operators work over vector(N) columns — K3 rewrites them onto the plane’s distance functions at parse time:

-- L2 distance SELECT id FROM docs ORDER BY emb <-> '[0.12, -0.04, ...]' LIMIT 10; -- Negative inner product SELECT a <#> b FROM t; -- Cosine distance (with an optional pgvector-style cast) SELECT id FROM docs ORDER BY emb <=> '[1,2,3]'::vector LIMIT 5;
OperatorDistanceLowers to
<->Euclidean (L2)list_distance
<#>negative inner productlist_negative_inner_product
<=>cosinelist_cosine_distance

Exactly these three are wired. The rewrite runs before the DuckDB parse (which rejects <-> and <#> outright) and wraps both operands in CAST(… AS FLOAT[]), so a text literal, a FLOAT[n] overlay column and a FLOAT[] Delta column all unify without knowing the dimension. An explicit '[…]'::vector cast is stripped first — DuckDB has no vector type — so the literal casts to FLOAT[] directly. An operator that appears inside a string literal is left alone.

ANN behaviour is tuned with session GUCs on the pg wire: SET hnsw.ef_search = N, SET ivfflat.probes = N, SET dodil.vector_exact_scan = on, SET dodil.vector_consistency = strong|eventual.

Graph DDL

Graphs are Tables-pillar objects defined over tables in the same bucket — see Graph. The grammar:

CREATE GRAPH [IF NOT EXISTS] <name> NODES ( <table> [KEY <col>] [, …] ) EDGES ( <table> SRC <col> DST <col> [, …] ) DROP GRAPH [IF EXISTS] <name> SHOW GRAPHS
CREATE GRAPH IF NOT EXISTS social NODES (people KEY id) EDGES (follows SRC src DST dst); DROP GRAPH IF EXISTS social; SHOW GRAPHS;

KEY is optional and defaults to id. SRC and DST are mandatory — there is no sane default for an edge’s direction, so EDGES (follows) is a parse error. Identifiers may be double-quoted; the node/edge lists must not be empty. DROP GRAPH removes the catalog entry and its artifacts — the member node/edge tables are ordinary tables and are untouched.

Multi-statement batches and transactions

Semicolon-separated statements run as a sequential batch, not a transaction: in-order execution, stop-at-first-error, everything before the failure stays applied. The response carries statement_results[] (one per applied statement), error, failed_statement_index, and failed_sql. See Execute → Multi-statement batches.

A script that opens with BEGIN / START TRANSACTION and closes with COMMIT in the same call is handled differently — it is a transaction block, and it lowers to one atomic commit:

BEGIN; INSERT INTO events (id, user_id) VALUES (1, 'u-101'); DELETE FROM events WHERE id = 2; COMMIT;

Rules for a transaction block:

  • DML onlyINSERT / UPSERT / UPDATE / DELETE. A SELECT, a DDL statement, or a nested BEGIN inside the block is refused; none has atomic meaning on the write log. MERGE parses into a block but is refused at lowering — issue it as its own statement, where it is already atomic per target table.
  • One table — v1 lowers the block to a single atomic commit only when every statement targets the same (database, table). A cross-table block is built and routed but the writer rejects it UNIMPLEMENTED, so nothing durable moves.
  • Every target needs a declared PRIMARY KEY — every write in this plane is keyed.
  • Statements later in the block read your earlier ones (an intra-block overlay over committed ∪ overlay), within a table.
  • ROLLBACK anywhere discards the whole block — nothing is written.
  • Statements after COMMIT belong to a separate transaction and are not run.
  • A bare interactive BEGIN (no COMMIT/ROLLBACK in the same call) is refused over Execute: the plane is stateless. Use the Postgres wire, which buffers a real session transaction — including SAVEPOINT, RELEASE, and ROLLBACK TO SAVEPOINT — and flushes it as one atomic block at COMMIT. Inside such an open pg-wire transaction a SELECT does see the block’s own writes (v0.1.39+) — see Transactions below. The SELECT-inside-a-block refusal above is about the stateless Execute script, which has no session to read through.

Refusals

The honest unsupported list. Each of these is valid SQL somewhere, and each fails loudly rather than silently doing something else.

Statements and shapes

RefusedWhyWhat to do instead
UPDATE / DELETE with no WHEREThe historical foot-gun; refused by design, not by omissionWHERE TRUE for every row, or TRUNCATE TABLE t
Bare interactive BEGIN over ExecuteThe plane is statelessSend the whole BEGIN; …; COMMIT; script in one call, or use the Postgres wire
SELECT, DDL, or a nested BEGIN inside an Execute-RPC transaction blockThe one-shot block returns one outcome; reads have no slot (RETURNING does)Read after the block — or use the Postgres wire, where a transaction reads its own writes
CREATE TRIGGER / CREATE FUNCTION … RETURNS triggerComing — synchronous triggers are on the near-term roadmap (a plpgsql subset covering the common patterns: NEW/OLD assignment, IF/RAISE, DML in the same atomic commit). Today the statement is refused (42601)Until it lands: react to writes with an ingest rule → pipeline or an Action; enforce invariants with CHECK / UNIQUE / FOREIGN KEY
ALTER TABLE RENAME COLUMN / RENAME TONot implementedRecreate via CREATE TABLE AS SELECT with the new name
ALTER COLUMN other than SET DATA TYPEOnly the logical widening retype is implemented
ALTER TABLE that resolves to nothing to doCaught at planning
DROP INDEXAn index is a table hereDROP TABLE __idx_<table>_<column> (or __uidx_…)
DROP <object> other than a tableOnly DROP TABLE / DROP GRAPH exist
DROP TABLE a, b / TRUNCATE a, bOne target per statementIssue one per table
TRUNCATE … PARTITION (…)TRUNCATE clears all rowsUse a keyed DELETE
CREATE GRAPH … EDGES (t) without SRC/DSTNo default edge directionEDGES (t SRC src DST dst)
VECTOR with no dimensionThis is a typed planeVECTOR(768)
NUMERIC(p,s) with p > 38Arrow Decimal128’s limit — never silently narrowedReduce precision
A non-unique CREATE INDEX on more than one columnA plain index is the __idx_{table}_{column} tableOne index per column, or CREATE UNIQUE INDEX ON t (a, b)
CREATE INDEX on an expressionPlain identifiers onlyMaterialize the expression into a column
Unknown CREATE INDEX WITH key, or an unsupported (method, quantization) pairA typo’d ef_constuction must not silently keep the defaultm, ef_construction, lists, quantization, async
Anything else sqlparser parses but the surface doesn’t coverThe error names the leading words of the statement

Write-shape refusals

RefusedWhy
ON CONFLICT ON CONSTRAINT <name>Conflicts route by column, not by constraint name — name the column(s)
INSERT whose body is neither VALUES nor SELECT, or whose target isn’t a simple table name
UPDATE / MERGE SET target that isn’t a plain column
MERGE with no same-name equality pair in ON and no merge keys to fall back toThere’d be nothing to match on — add t.col = s.col
MERGE … USING anything but a bare table, VALUES, or a SELECT subquery
MERGE USING VALUES with a non-literal column, a multi-row INSERT … VALUES, or mismatched arityv1 lowers literals only
INSERT inside WHEN MATCHED, or anything but INSERT inside WHEN NOT MATCHED

Partially enforced, or accepted and ignored

FOREIGN KEY is enforced on the INSERT side since v0.1.36 — what is not checked is the DELETE side and an autocommit UPDATE of an FK column. See Constraints: what is enforced for the full matrix (CHECK is enforced since v0.1.25; unenforceable forms are rejected at DDL since v0.1.31; unenforceable FK clauses since v0.1.36). FOR UPDATE takes no blocking lock; inside a transaction the locked rows’ keys are commit-guarded (a conflicting write aborts the transaction with 40001 — real write-skew protection), outside one it is a snapshot read with a NOTICE. SET/RESET of GUCs other than statement_timeout and the vector knobs are accepted and ignored, as are DEALLOCATE, DISCARD, LISTEN, UNLISTEN, and CREATE SCHEMA public.

Multiple ADD COLUMNs in a single ALTER TABLE are supported, and so is mixing ADD / DROP / ALTER COLUMN in one statement.

Native clients

Three ways to send SQL to K3 — all speak the same dialect and reach the same planner:

Postgres wire

The richest surface: psql, psycopg, pgx, SQLx, JDBC, and ORMs (SQLAlchemy, Django, Prisma, GORM) connect to the pg adapter with the bucket as the database. See Connect & Adapters for the endpoint and credentials.

Beyond the dialect above, the pg wire adds session state the stateless Execute RPC has no place for:

FeatureNotes
Prepared statements$1..$n placeholders are rewritten to the plane’s @p1..@pn (outside string literals and -- / /* */ comments) and bound as typed values from the client-declared OID. A bound array parameter works with col = ANY($1) — the standard filter-by-a-list-of-ids idiom, no string-built IN (…) required. Extended-query Describe on a RETURNING DML answers with a zero-row SELECT … WHERE FALSE probe, so drivers that read the schema before Execute get the right columns.
TransactionsReal BEGIN / COMMIT / ROLLBACK plus SAVEPOINT, RELEASE, ROLLBACK TO SAVEPOINT. DML is buffered on the session — parameterized writes are inlined as literals at buffer time — and flushed as one atomic block at COMMIT. Buffered UPDATE/DELETE row counts come from a real server-side preview that writes nothing, never a fabricated number. RETURNING on a buffered DML returns the row (SQLAlchemy 2.0 Session works). A transaction sees its own writes (v0.1.39+): a SELECT inside the block reads committed ∪ pending, so point and filtered reads, scans, ORDER BY/LIMIT/OFFSET, count/sum/min/max/avg, multi-statement sequences (insert → update that row → read it), and joins or subqueries mixing a written table with an unwritten one all fold the staged rows. A DELETE makes the row invisible; INSERT-then-DELETE of one key shows nothing. Where exactness cannot be proven the read is refused, naming the shape and the way out — never answered from stale state: a CTE whose name collides with a written table, a relation carrying a table-function / time-travel / partition / JSON-path / TABLESAMPLE / index-hint modifier, a read the parser cannot parse, and a written table with no declared schema. There is no best-effort branch.
COPY <table> [(cols)] FROM STDIN [WITH (FORMAT text|csv)]Bulk ingest through the copy-in sub-protocol, typed against each column and flushed in 4,000-row chunks so a large COPY streams. pg text format (\N = NULL) and a naive CSV parser (quoted fields, doubled quotes; no embedded newlines in v1). COPY TO and file-path COPY are refused (0A000) — FROM STDIN only, so there is no in-engine bulk export or pg_dump yet. A COPY is not atomic: because it flushes per chunk, a value refused in chunk 2 leaves chunk 1 committed, where Postgres would roll the whole COPY back. Treat a failed COPY as partially applied and re-drive it with a replay-safe write (INSERT … ON CONFLICT … DO UPDATE).
Server-side cursorsDECLARE <name> CURSOR FOR <query> runs the query once and buffers rows; FETCH serves incrementally; CLOSE [name|ALL] frees. WITH HOLD semantics come free; scrollback is unsupported (forward only).
statement_timeoutHonored for real — SET statement_timeout = 5000 (ms) or TO '5s' / '250ms' / '2min' / '1h' / '1d'; 0 disables, RESET restores the connection default. On expiry you get pg’s own shape: SQLSTATE 57014, “canceling statement due to statement timeout”.

HTTP — Execute RPC

curl -sS -X POST "https://api.data.dodil.io/kb-prod/tables/_execute" \ -H "Authorization: Bearer $DODIL_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "bucket": "kb-prod", "sql": "SELECT event_type, COUNT(*) AS n FROM events GROUP BY event_type" }'

Response is a oneofquery / write / ddl — depending on the SQL kind. See API Reference → Execute.

CLI — dodil data table query

dodil data table query --bucket kb-prod " SELECT event_type, COUNT(*) AS n FROM events GROUP BY event_type " # JSON output for scripting dodil data table query --bucket kb-prod "SELECT * FROM events LIMIT 5" -o json

The CLI’s table query command handles both reads and writes — it picks the right RPC based on what the planner returns. Its --freshness flag is retained but inert: it prints a note and is ignored (see Read freshness).

Structured shortcuts (no SQL string)

If you’d rather not assemble SQL strings, the typed RPCs (Insert / Merge / Update / DeleteRows / Query) and their CLI commands (dodil data table insert / merge / update / delete-rows / query) accept JSON rows + predicates / match-columns. The planner routes them through the same strategies — they’re just shortcuts for callers that prefer typed inputs to SQL templating. See API Reference → Data.

Errors and retries

The SQLSTATE class is the contract clients build retry logic on — key on it, not on message text:

SQLSTATEMeaningRetry?
08006Connection failureYes — transient
58030I/O error (e.g. an object-store fault)Yes — transient
XX000Internal errorYes — transient
40001Serialization failure — an optimistic-concurrency conflict at COMMITYes — re-run the transaction
57014Statement timeout (statement_timeout)Caller’s call — the statement was cancelled, not failed
42601Syntax error — the SQL is genuinely malformedNever — it will fail identically every time
42703Undefined columnNever
42P01Undefined tableNever
23505Unique violation — plain INSERT on an existing keyNever — use ON CONFLICT DO UPDATE / UPSERT INTO if you meant upsert
23502NOT NULL violationNever
23503Foreign-key violation — a child row naming a missing parentNever — insert the parent first
23514CHECK violationNever
23P90A write touched a row sealed by an IMMUTABLE ruleNever
22003Numeric value out of range — past the declared precision or widthNever — widen the column or fix the value
22P02Invalid text representation — an impossible date, a non-boolean, a non-uuidNever
42830An FK references columns that are not the parent’s declared PRIMARY KEYNever — a DDL error
0A000Feature not supported — e.g. COPY … TO STDOUT, an unenforceable CHECK or FK clauseNever

A retry loop should match the transient classes (08006, 58030, XX000, and 40001 for transactions) and give up immediately on everything in the 42… family. Since v0.1.27 transient object-store / IO faults are correctly classed as retryable — earlier releases could misreport them as 42601, so if you built a workaround that retries syntax errors, remove it.

Coming-from-X cheatsheet

Coming fromK3 equivalent
Postgres INSERT ... ON CONFLICT (pk) DO UPDATEWorks as written — assignments take arbitrary expressions and literals (SET n = n + 1, SET status = 'posted'), not only EXCLUDED.col. ON CONFLICT DO NOTHING works too. The conflict target must still be the PK or a UNIQUE key
Postgres transactionsReal session transactions (with savepoints) over the Postgres wire; a BEGIN; …; COMMIT; script over Execute. Single-table DML only in v1 — see above
Postgres SERIAL / GENERATED AS IDENTITYAccepted — becomes a long with a snowflake-id default. Read the value back with RETURNING
Postgres COPY … FROM STDINSupported on the pg wire (text + CSV). COPY TO is not
Postgres schemas (public.foo)No schemas — the bucket is the namespace. The public. qualifier is stripped; CREATE SCHEMA public is a no-op
Postgres SELECT … FOR UPDATEAccepted, but no blocking lock is taken. Inside a pg-wire transaction every row the FOR UPDATE read returned is folded into the commit’s guard — including rows the transaction never writes — so a concurrent write to any of them aborts the commit with 40001. That is Postgres’s cross-row write-skew guarantee, spelled optimistically
Postgres FOREIGN KEYEnforced on INSERT/UPSERT (23503), validated at DDL (42P01 / 42830 / 0A000). The DELETE side and autocommit UPDATE of an FK column are not checked — see Constraints
Postgres read-your-writes in a transactionWorks — a SELECT in an open pg-wire transaction sees the block’s own INSERT/UPDATE/DELETE, aggregates included
pgvector <-> / <#> / <=>, CREATE INDEX USING hnsw|ivfflatSupported — see Vector KNN
MySQL INSERT ... ON DUPLICATE KEY UPDATEAccepted as written — same insert-or-replace lowering
BigQuery CREATE TABLE AS SELECTSame — CREATE TABLE … AS SELECT …
BigQuery MERGEIdentical syntax
BigQuery multi-statement scriptsSame model — semicolon batch, sequential, stop-at-first-error, per-statement results
DuckDB itselfSame dialect plus K3 extensions; K3 adds the HTAP routing layer. The divergences are additive
Spark SQL / Iceberg MERGESimilar — MERGE INTO ... USING ... WHEN MATCHED ... WHEN NOT MATCHED ...
Databricks / Delta RESTORE TABLE … TO VERSION AS OFIdentical spelling, including the TIMESTAMP AS OF form
Milvus load_collection / release_collectionLOAD / RELEASE VECTOR INDEX, LOAD / RELEASE GRAPH, LOAD / RELEASE TABLE
Delta Lake external toolsTables are real Delta tables — external Delta-capable engines can read them by pointing at the bucket’s Delta directory. Writes are the planner’s job; don’t write to the Delta dir externally.

See also