23. PostgreSQL queries and data changes
- Category: Tutorial
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.
- Part A. Upserts:
INSERT ... ON CONFLICT,RETURNING,xmax. - Part B.
INSERT ... SELECT,UPDATE ... FROM,DELETE ... USING, subqueries in data changes. - Part C.
WITHqueries: plain,RECURSIVE, andWITH x AS (UPDATE ...). - Part D. Correlated subqueries,
EXISTS,LATERAL, and why they stay fast. - Part E. More query forms:
FILTER,DISTINCT ON,UNION/INTERSECT/EXCEPT,GROUP BY 1. - Part F. Casts, times and intervals.
- Part G. Arrays and JSON.
- Part H. Advisory locks.
- Part I. QuestDB apps:
ALTER DATABASE ... SET dialect,/imp,to_str. - What is different from PostgreSQL, and speed.
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
| PostgreSQL | KDB |
|---|---|
INSERT ... ON CONFLICT (key) DO NOTHING / DO UPDATE SET c = EXCLUDED.c | Works, with WHERE, ON CONSTRAINT and RETURNING (xmax = 0). |
INSERT ... SELECT, DEFAULT VALUES | Works. |
UPDATE t SET ... FROM s WHERE ..., DELETE FROM t USING s WHERE ... | Works. |
Subqueries in SET, WHERE and VALUES | Works, 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 ...), LATERAL | Works. The usual shapes become hash lookups. |
FILTER (WHERE ...), agg(x ORDER BY y), DISTINCT ON, GROUP BY 1, NULLS FIRST | Works. |
UNION, INTERSECT, EXCEPT (with or without ALL), (VALUES ...) AS v(a, b) | Works. |
x::type, CAST, DATE '...', INTERVAL '...', E'...', CURRENT_TIMESTAMP | Works. Casts round numbers as PostgreSQL does. |
INTERVAL arithmetic, AT TIME ZONE, EXTRACT(EPOCH FROM interval), make_interval | Works (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[] results | Works, 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
EXCLUDED.columnis the value of the new row.page_hits.columnis the value that is there now.- Write the table name (or the alias of
INSERT INTO t AS a) before a column. A plain name is ambiguous in PostgreSQL. RETURNINGgives the rows that were inserted or updated.xmaxis 0 for an inserted row and 1 for an updated one.DO UPDATE ... WHERE cond: rows that do not match stay as they are and are not returned.DO NOTHINGskips the row. Without a key list it means "any unique key".
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.
- A key with a NULL never clashes.
- Two new rows with the same key in one
DO UPDATEstatement are an error ("cannot change the same row twice"). WithDO NOTHINGthe first one wins. SERIALnumbers are used also for rows that end as updates (so ids can have gaps).
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;
- A row of the table changes once, also when it matches several rows of
FROM(as in PostgreSQL, one of them is used). - The join is a hash join:
WHERE w.user_id = v.user_iddoes not compare every pair. - Subqueries work in
SET,WHEREandVALUES, also ones that use the row being changed:
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
- The data changes run first, in the order written. Then the main query runs.
- The statement is one transaction: when a part fails, nothing is written.
- Such a
SELECTlocks the tables it changes, as any writer does (chapter 15), and is never kept in the result cache.
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.
- When the subquery joins the outer row with
=(v.ticket_id = t.ticket_id), KDB runs it once for all keys (grouped byticket_id), puts the answers in a hash table, and every outer row looks its answer up. The work is one pass overvotesplus one lookup per ticket. count(*)of a key without rows is 0, andsumis NULL, as in PostgreSQL.EXISTS,NOT EXISTS,IN (SELECT ...)andNOT IN (SELECT ...)work the same way (a set of keys).- Other shapes (
v.amount > t.limit) run once per different outer value, and the answers are kept for the statement. - A subquery that uses no outer column runs once, before the query.
- A value subquery that finds two rows is an error, as in PostgreSQL. Add
LIMIT 1or an aggregate.
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
| Form | Meaning |
|---|---|
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, b | The first row of each a in ORDER BY order. |
GROUP BY 1, ORDER BY 2 | Position of a select column. An alias works too: GROUP BY day. |
ORDER BY sum(x), HAVING count(*) > 1 | Aggregates that are not select columns. |
NULLS FIRST, NULLS LAST | NULL counts as bigger than every value (first with DESC), as in PostgreSQL. |
LIMIT ALL, OFFSET n ROWS, FETCH FIRST n ROWS ONLY | Also 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
- Every PostgreSQL type name works after
::and inCAST:int4,float8,numeric(10,2),text,varchar(20),timestamptz,interval,text[],bigint[], ... - A cast to a whole number or to
numeric(p,s)rounds half away from zero. Other places (a value for anINTcolumn, an argument of a saved function) still refuse2.5. x::textof a time gives PostgreSQL's text:2026-09-10 12:00:00(with+00for atimestamptz).- A text constant next to a number is read as a number:
'1.50' + 1is2.50(a numeric parameter sent as text works too). Text from a column or a function is not:doc ->> 'n' + 1is an error; write(doc ->> 'n')::int + 1. md5(text)andinitcap(text)work as in PostgreSQL.CURRENT_TIMESTAMP,CURRENT_DATE,LOCALTIMESTAMP,now()are the time the statement started. KDB times are UTC.DATE - DATEis a number of days. A month added to the 31st ends on the last day of the next month.
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:
timestamp AT TIME ZONE zreads a clock time of zonezand gives the instant (atimestamptz).timestamptz AT TIME ZONE zshows an instant as the clock time of zonez(atimestamp).
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
| Form | Meaning |
|---|---|
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_position | Read 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.
| Function | Meaning |
|---|---|
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 forms | Several sessions may share the key; an exclusive lock waits for all of them. |
(a, b)with twointkeys is another lock than onebigintkey, as in PostgreSQL.- A session may take its own lock again; it must unlock it as many times.
- Waiting ends after 30 seconds with an error (PostgreSQL waits forever).
- A statement alone outside a transaction waits without holding any table lock, so the session that holds the key can go on working. Inside
BEGIN ... COMMITit waits holding the locks of the tables the transaction used; keep such waits short. - Over HTTP without a session, locks end with the request.
Part I: QuestDB apps
The dialect. ALTER DATABASE qdb SET dialect = 'questdb' makes a database read SQL the QuestDB way:
- A plain
INSERTinto a table with aDEDUP KEYreplaces the row with the same key (QuestDB'sDEDUP UPSERT KEYS). CAST(time AS LONG)gives microseconds since 1970 (PostgreSQL mode: milliseconds).
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×tamp=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
WITHqueries that change data run before the main query and the main query sees their changes (PostgreSQL uses one snapshot). The app's statements give the same answers.- A recursive
WITHquery stops with an error after 100,000 rounds or 10 million rows. - Advisory locks wait at most 30 seconds.
- Time zones are fixed offsets;
AT TIME ZONE 'Europe/London'is refused. - A plain column name in
ON CONFLICT DO UPDATE SETmeans the row that is there (PostgreSQL calls it ambiguous). - Casting a whole number from
double precisionrounds half away from zero (PostgreSQL: to the nearest even number). - Arrays have one dimension in binary form; nested arrays are sent as text.
refdata.companiesmeans the tablecompaniesof the databaserefdataon the same server (PostgreSQL would look for a schemarefdata). You can read it inSELECT, joins, subqueries andINSERT ... SELECT. A statement changes only the database it runs in.public.companiesis your own table. The help topic Queries across databases has the details.
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.
| Statements | HTTP before | HTTP now | PostgreSQL protocol before | now |
|---|---|---|---|---|
| All 2143 | 950 (44.3%) | 1971 (92.0%) | 930 (43.4%) | 1952 (91.1%) |
| SELECT (786) | 549 | 734 | 548 | 734 |
| WITH (20) | 0 | 15 | 0 | 15 |
| INSERT (209) | 26 | 142 | 26 | 142 |
| UPDATE (164) | 81 | 156 | 79 | 155 |
| DELETE (72) | 55 | 56 | 55 | 55 |
| Schema statements (889, chapter 22) | 238 | 866 | 221 | 849 |
"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:
- 44 "no unique key" for
ON CONFLICT: the test schema (schema_kdb.sql) was written for an older KDB and left out composite keys. With the app's ownCREATE TABLEthey work. - Sample values that do not fit (
'test'for a number), and template parts the test cannot fill ({{spec.conflict}}). - A few features of other chapters: QuestDB's
tables()functions, andCOPYsent over HTTP (over the PostgreSQL portCOPYworks, chapter 14).
Speed
- A statement without the new forms runs the same code as before: the checks are a few flag tests.
ON CONFLICTon the table's key withDO NOTHINGor a fullDO UPDATE SET ... EXCLUDEDwrites batches as fast asINSERT OR REPLACE.- Correlated subqueries and
LATERALwith=joins cost one pass over the inner table plus one hash lookup per outer row. - A constant
= ANY(...)becomesIN (...): indexes, key ranges and the column engine still work. - Queries with
FILTERorORDER BYinside an aggregate use the row engine.