KDB knowledge

23. PostgreSQL queries and data changes

What you will learn

Chapter 22 is about the app's tables. This chapter is about the app's queries and data changes: the SQL a PostgreSQL app sends every day.

The output under each example is real (kdb sql). Times print in KDB's style (2026-09-10T05:20:00.000Z); over the PostgreSQL protocol they have PostgreSQL's style.

The short version

PostgreSQLKDB
INSERT ... ON CONFLICT (key) DO NOTHING / DO UPDATE SET c = EXCLUDED.cWorks, with WHERE, ON CONSTRAINT and RETURNING (xmax = 0).
INSERT ... SELECT, DEFAULT VALUESWorks.
UPDATE t SET ... FROM s WHERE ..., DELETE FROM t USING s WHERE ...Works.
Subqueries in SET, WHERE and VALUESWorks, correlated ones too.
WITH, WITH RECURSIVE, WITH x AS (UPDATE ... RETURNING ...)Works. A statement with data-changing WITH queries is one transaction.
Correlated subqueries, EXISTS, IN (SELECT ...), LATERALWorks. The usual shapes become hash lookups.
FILTER (WHERE ...), agg(x ORDER BY y), DISTINCT ON, GROUP BY 1, NULLS FIRSTWorks.
UNION, INTERSECT, EXCEPT (with or without ALL), (VALUES ...) AS v(a, b)Works.
x::type, CAST, DATE '...', INTERVAL '...', E'...', CURRENT_TIMESTAMPWorks. Casts round numbers as PostgreSQL does.
INTERVAL arithmetic, AT TIME ZONE, EXTRACT(EPOCH FROM interval), make_intervalWorks (time zones: fixed offsets and zones without daylight saving, such as Asia/Kathmandu; see Part F).
= ANY($1) with Go slices, ARRAY[...], array_agg, unnest, TEXT[] resultsWorks, in text and binary form.
jsonb_build_object, json_agg, jsonb_set, ->, ->>, #>>Works. JSON sorts like jsonb (9 before 10).
pg_advisory_lock, pg_try_advisory_xact_lock, ...Real locks between sessions.

When all 2143 statements of the app run on KDB, 1971 work over HTTP (92.0%) and 1952 over the PostgreSQL protocol (91.1%) (checked on 2026-09-23). Before this work (and the schema work of chapter 22) it was 950 and 930. Most of the rest come from the test data or from table definitions the test schema left out (see the end).


Part A: Upserts

ON CONFLICT says what to do when a new row has the key of a row that is there.

CREATE TABLE page_hits (page TEXT PRIMARY KEY, hits BIGINT NOT NULL, last_at TIMESTAMP);
INSERT INTO page_hits VALUES ('/chart', 5, '2026-09-10T05:00:00Z');

INSERT INTO page_hits (page, hits, last_at)
VALUES ('/chart', 1, '2026-09-11T05:00:00Z'), ('/news', 1, '2026-09-11T05:00:00Z')
ON CONFLICT (page) DO UPDATE SET hits = page_hits.hits + EXCLUDED.hits, last_at = EXCLUDED.last_at
RETURNING page, hits, (xmax = 0) AS inserted;
page   | hits | inserted
-------+------+---------
/chart |    6 | false
/news  |    1 | true
1 row(s) inserted, 1 updated
INSERT INTO page_hits (page, hits) VALUES ('/news', 100) ON CONFLICT (page) DO NOTHING RETURNING page;
(0 rows)
0 row(s) inserted, 1 skipped (already there)

Which keys. The key list must be the primary key, a DEDUP KEY, a UNIQUE column or constraint, or a UNIQUE INDEX with exactly those columns (a partial index needs ON CONFLICT (cols) WHERE ...). ON CONFLICT ON CONSTRAINT name names it (table_pkey for the primary key). Another list is an error, as in PostgreSQL: "there is no unique key on (...)".

Rules from PostgreSQL.

Speed. DO NOTHING, and DO UPDATE SET of every other column from EXCLUDED, on the table's own key, run as INSERT OR IGNORE / INSERT OR REPLACE: big batches go in without looking rows up one by one. Other forms look each row up by its key (or index).

Part B: Copying and joined changes

INSERT ... SELECT copies the rows of a query. DEFAULT VALUES writes one row of defaults.

INSERT INTO ledger (user_id, amount, balance_after) SELECT user_id, 25, balance FROM wallets WHERE balance > 0;
INSERT INTO logs DEFAULT VALUES;

UPDATE ... FROM changes rows with values from other tables. DELETE ... USING deletes rows that have a match.

UPDATE wallets w SET balance = v.balance
FROM (VALUES (1, 10), (2, 20), (9, 90)) AS v(user_id, balance)
WHERE w.user_id = v.user_id
RETURNING w.user_id, w.balance;
user_id | balance
--------+--------
      1 |      10
      2 |      20
2 row(s) updated
DELETE FROM ledger l USING wallets w WHERE l.user_id = w.user_id AND w.balance < 15 RETURNING l.id;
UPDATE tickets t SET vote_count = (SELECT count(*) FROM votes v WHERE v.ticket_id = t.ticket_id);
DELETE FROM tickets WHERE NOT EXISTS (SELECT 1 FROM votes WHERE votes.ticket_id = tickets.ticket_id);
UPDATE ai_models SET is_default = TRUE WHERE (provider, model) = (SELECT provider, model FROM ai_models ORDER BY rank LIMIT 1);

Part C: WITH queries

A WITH query names a result for the statement. KDB runs it the first time the statement reads it, once, and keeps its rows for the statement.

WITH RECURSIVE runs its second part again on the rows found last, until no new rows come. UNION (without ALL) stops at rows it has seen, so a loop in the data ends.

WITH RECURSIVE upline(user_id, referred_by, lvl) AS (
  SELECT user_id, referred_by, 0 FROM referrals WHERE user_id = 4
  UNION ALL
  SELECT r.user_id, r.referred_by, u.lvl + 1 FROM referrals r JOIN upline u ON r.user_id = u.referred_by WHERE u.lvl < 20
)
SELECT user_id, lvl FROM upline ORDER BY lvl;
user_id | lvl
--------+----
      4 |   0
      3 |   1
      2 |   2
      1 |   3

A recursive query that never stops is stopped after 100,000 rounds or 10 million rows, with an error.

WITH queries that change data. The app's wallet top-up changes a row, writes a ledger row from the changed row, and returns it, in one statement:

WITH upd AS (UPDATE wallets SET balance = balance + 25 WHERE user_id = 1 RETURNING user_id, balance),
     led AS (INSERT INTO ledger (user_id, amount, balance_after) SELECT user_id, 25, balance FROM upd)
SELECT user_id, balance FROM upd;
user_id | balance
--------+--------
      1 |     125

Part D: Correlated subqueries and LATERAL

A correlated subquery uses a column of the outer row.

SELECT t.ticket_id, t.title,
       (SELECT count(*) FROM votes v WHERE v.ticket_id = t.ticket_id) AS votes,
       EXISTS (SELECT 1 FROM votes v WHERE v.ticket_id = t.ticket_id AND v.user_id = 8) AS i_voted
FROM tickets t ORDER BY t.ticket_id;
ticket_id | title           | votes | i_voted
----------+-----------------+-------+--------
        1 | Dark mode       |     2 | true
        2 | Faster charts   |     1 | false
        3 | Nepali calendar |     0 | false

How KDB keeps this fast. It does not run the subquery once per outer row.

LATERAL lets a FROM subquery use the tables before it:

SELECT t.title, last_vote.user_id
FROM tickets t
LEFT JOIN LATERAL (SELECT user_id FROM votes v WHERE v.ticket_id = t.ticket_id ORDER BY user_id DESC LIMIT 1) last_vote ON TRUE
ORDER BY t.ticket_id;
title           | user_id
----------------+--------
Dark mode       |       8
Faster charts   |       7
Nepali calendar |    NULL

A LATERAL subquery with an aggregate becomes a hash join; one with ORDER BY ... LIMIT runs per outer row (answers are kept for rows with the same key).

Part E: More query forms

SELECT symbol,
       count(*) FILTER (WHERE qty >= 10) AS big_trades,
       string_agg(price::text, ' -> ' ORDER BY ts) AS path
FROM trades GROUP BY 1 ORDER BY 1;
symbol | big_trades | path
-------+------------+-----------
CITY   |          2 | 510 -> 512
COAST  |          0 | 900 -> 905
SELECT DISTINCT ON (symbol) symbol, ts, price FROM trades ORDER BY symbol, ts DESC;  -- the newest trade of each symbol
symbol | ts                       | price
-------+--------------------------+------
CITY   | 2026-09-10T05:20:00.000Z |   512
COAST  | 2026-09-10T05:30:00.000Z |   905
FormMeaning
agg(x) FILTER (WHERE cond)The aggregate only sees rows where cond is true.
array_agg(x ORDER BY y), string_agg(x, ',' ORDER BY y)The rows come in that order.
DISTINCT ON (a) ... ORDER BY a, bThe first row of each a in ORDER BY order.
GROUP BY 1, ORDER BY 2Position of a select column. An alias works too: GROUP BY day.
ORDER BY sum(x), HAVING count(*) > 1Aggregates that are not select columns.
NULLS FIRST, NULLS LASTNULL counts as bigger than every value (first with DESC), as in PostgreSQL.
LIMIT ALL, OFFSET n ROWS, FETCH FIRST n ROWS ONLYAlso QuestDB's LIMIT lo, hi.
UNION, INTERSECT, EXCEPT [ALL]Without ALL the result has no copies. INTERSECT goes first.
(VALUES (1, 'a'), (2, 'b')) AS v(n, s)A small table of values.
(a, b) < (x, y), (a, b) = (SELECT ...)Row comparisons.
FROM (SELECT ...)The subquery needs no alias.

Part F: Casts, times and intervals

SELECT '12.5'::numeric(6,1) AS n, 2.5::int AS rounded, E'tab\there' AS e,
       (DATE '2026-01-31' + INTERVAL '1 month')::date AS next_month;
n    | rounded | e        | next_month
-----+---------+----------+-----------
12.5 |       3 | tab	here | 2026-02-28

Intervals keep months, days and time apart, as in PostgreSQL:

SELECT INTERVAL '1 day' - INTERVAL '1 hour' AS i, EXTRACT(EPOCH FROM INTERVAL '1 month') AS month_seconds,
       make_interval(days => 3, hours => 2) AS m;
i               | month_seconds | m
----------------+---------------+----------------
1 day -01:00:00 |       2592000 | 3 days 02:00:00

justify_hours, justify_days and justify_interval move 24 hours into a day and 30 days into a month. For comparing, a month counts as 30 days.

AT TIME ZONE follows PostgreSQL:

SELECT (TIMESTAMP '2026-09-10 12:00:00' AT TIME ZONE 'Asia/Kathmandu')::text AS utc_instant,
       (TIMESTAMPTZ '2026-09-10 06:15:00+00' AT TIME ZONE 'Asia/Kathmandu')::text AS local_clock;
utc_instant            | local_clock
-----------------------+--------------------
2026-09-10 06:15:00+00 | 2026-09-10 12:00:00

A TIMESTAMP column holds UTC instants in KDB, so KDB treats now(), CURRENT_TIMESTAMP, timestamptz casts and TIMESTAMPTZ columns as instants, and other TIMESTAMP values as clock times. Zones are fixed offsets ('+05:45', 'UTC', 'Asia/Kathmandu', ...); zones with daylight saving time are refused.

Part G: Arrays and JSON

KDB keeps an array as a JSON array. Over the PostgreSQL protocol it is a real array (text[] is type 1009, bigint[] 1016, ...) in text and binary form, so pgx scans it into a Go slice and sends Go slices as parameters.

CREATE TABLE feeds (id BIGINT PRIMARY KEY, tags TEXT[], roles JSONB);
INSERT INTO feeds VALUES (1, '{market,ipo}', '{"admin": "sita"}'), (2, ARRAY['hydro'], '{}');
SELECT id, unnest(tags) AS tag FROM feeds ORDER BY id, tag;
id | tag
---+-------
 1 | ipo
 1 | market
 2 | hydro
FormMeaning
x = ANY($1), x <> ALL($1)$1 is an array (a Go slice, '{1,2}', or ARRAY[...]). A constant array becomes an IN list, so indexes and key ranges still work.
ARRAY[1, 2], ARRAY(SELECT ...), '{a,b}'::text[]Make an array.
tags[1], cardinality(tags), array_length(tags, 1), array_to_string, string_to_array, array_append, array_remove, array_positionRead and change arrays (from 1, as in PostgreSQL).
array_agg(x), (array_agg(x ORDER BY y))[1]Collect values.
unnest(array)In SELECT: one row per element. In FROM: a table (FROM unnest($1::int[]) AS u(id)).
tags @> ARRAY['a'], &&, <@Contains, overlaps, is contained.

JSON functions: jsonb_build_object, jsonb_build_array, json_agg / jsonb_agg (with ORDER BY), to_jsonb, row_to_json(t), jsonb_set(doc, path, value, create), jsonb_typeof, jsonb_array_length, ->, ->>, #>, #>>.

UPDATE feeds SET roles = jsonb_set(roles, ARRAY['editor'], to_jsonb('ram'::text), true) WHERE id = 1 RETURNING roles;
roles
-------------------------------
{"admin":"sita","editor":"ram"}

JSON values sort like PostgreSQL's jsonb: numbers by value (9 before 10), strings by text, and null < strings < numbers < booleans < arrays < objects.

Part H: Advisory locks

Advisory locks are named by a number (or two). KDB keeps them between sessions of one database.

FunctionMeaning
pg_advisory_lock(key)Waits until no other session holds the key, then holds it until pg_advisory_unlock or the session ends.
pg_try_advisory_lock(key)The same without waiting: true or false.
pg_advisory_xact_lock(key), pg_try_advisory_xact_lock(key)Held until COMMIT / ROLLBACK (or the end of the statement outside BEGIN).
pg_advisory_unlock(key), pg_advisory_unlock_all()Let it go. unlock returns false when the session did not hold it.
..._shared formsSeveral sessions may share the key; an exclusive lock waits for all of them.

Part I: QuestDB apps

The dialect. ALTER DATABASE qdb SET dialect = 'questdb' makes a database read SQL the QuestDB way:

SET dialect = 'postgres' goes back. The setting is kept in _system and applies every time the database opens (chapter 21 has the other ALTER DATABASE settings).

CSV import. POST /imp takes a CSV file the way QuestDB does (chapter 8 has the details):

curl -F "data=@Ochl_Day.csv" "http://127.0.0.1:9900/imp?name=Ochl_Day&timestamp=time&partitionBy=DAY&fmt=json"

Functions. to_str(time, 'yyyy-MM-dd HH:mm:ss') formats a time with QuestDB's letters.

What is different from PostgreSQL

The app's queries on KDB

tests/app_compat/run_compat.mjs runs all 2143 statements of the app against a fresh server with sample data.

StatementsHTTP beforeHTTP nowPostgreSQL protocol beforenow
All 2143950 (44.3%)1971 (92.0%)930 (43.4%)1952 (91.1%)
SELECT (786)549734548734
WITH (20)015015
INSERT (209)2614226142
UPDATE (164)8115679155
DELETE (72)55565555
Schema statements (889, chapter 22)238866221849

"Now" is the run of 2026-09-23. Over the PostgreSQL protocol a few QuestDB ALTER and RENAME statements of the test fail because the test tool replays them in another order (the column or table is already there), and a few test values ('test' for a number) are refused earlier, when the parameters are read.

Most of the statements that still fail are not about query support:

Speed