KDB knowledge

UNION and subqueries

Put the rows of two queries together, and use one query inside another.

A subquery is a SELECT inside another statement, always in ( ). UNION ALL puts the rows of two or more SELECTs one after the other.

Example: Brokers who bought or sold TELCO: a subquery with UNION ALL inside (sample data: sample)

SELECT id, name
FROM brokers
WHERE id IN (SELECT buyer FROM trade_log WHERE symbol = 'TELCO'
             UNION ALL
             SELECT seller FROM trade_log WHERE symbol = 'TELCO')
ORDER BY name;

Result:

id (INT)name (STRING)
45Imperial Securities
1Keystone Securities
58Northgate Securities
49Online Securities
34Vision Securities

(5 rows)

UNION ALL

Syntax:

SELECT ... [FROM ... WHERE ... GROUP BY ...]
UNION ALL
SELECT ... [FROM ... WHERE ... GROUP BY ...]
[UNION ALL SELECT ...] ...
[ORDER BY result_column | position [ASC | DESC], ...]
[LIMIT n [OFFSET m]]

Runs each SELECT and puts all their rows into one result. Rows that are the same are kept (that is the meaning of ALL).

PartMeaning
each SELECTa normal query: it can have FROM, joins, WHERE, GROUP BY, SAMPLE BY, HAVING
ORDER BY, LIMIT at the endwork on all rows together

Example: Broker 58: what it bought and sold, per symbol (sample data: sample)

SELECT symbol, 'buy' AS side, sum(quantity) AS shares FROM trade_log WHERE buyer = 58 GROUP BY symbol
UNION ALL
SELECT symbol, 'sell' AS side, sum(quantity) AS shares FROM trade_log WHERE seller = 58 GROUP BY symbol
ORDER BY symbol, side;

Result:

symbol (SYMBOL)side (STRING)shares (LONG)
CITYsell20
COASTbuy760
COASTsell1200
TELCObuy1000
VOLTbuy200
VOLTsell50

(6 rows)

Example: Turnover of each broker on one day (bought + sold) (sample data: sample)

SELECT broker, sum(amount) AS turnover, count(*) AS trades
FROM (
  SELECT buyer AS broker, amount FROM trade_log WHERE date IN '2026-09-10'
  UNION ALL
  SELECT seller AS broker, amount FROM trade_log WHERE date IN '2026-09-10'
) t
GROUP BY broker
ORDER BY turnover DESC;

Result:

broker (INT)turnover (DOUBLE)trades (LONG)
45128816011
4911735809
347732609
587616908
172607011

(5 rows)

A GROUP BY over a UNION ALL like the one above is grouped part by part: each part sums its own rows in the column engine and the outer query adds up one row per broker and part, so it costs about as much as the two parts on their own. EXPLAIN shows it as aggregates pushed into the 2 UNION ALL parts. See GROUP BY with many groups for what it covers.

Only sum, count, min and max can be added up part by part. With avg, first, last or count(DISTINCT ...) the parts are not grouped first, but their rows still go straight into the groups without being kept: EXPLAIN says read part by part into the grouping (not kept).

Example: EXPLAIN: grouped part by part, and read part by part (sample data: sample)

EXPLAIN SELECT broker, sum(amount) AS turnover, count(*) AS trades
FROM (SELECT buyer AS broker, amount FROM trade_log WHERE date IN '2026-09-10'
      UNION ALL
      SELECT seller AS broker, amount FROM trade_log WHERE date IN '2026-09-10') t
GROUP BY broker;
EXPLAIN SELECT broker, avg(amount) AS avg_amount
FROM (SELECT buyer AS broker, amount FROM trade_log WHERE date IN '2026-09-10'
      UNION ALL
      SELECT seller AS broker, amount FROM trade_log WHERE date IN '2026-09-10') t
GROUP BY broker;

Result:

EXPLAIN SELECT broker, sum(amount) AS turnover, count(*) AS trades FROM (SELECT buyer AS broker, amo

plan (STRING)
aggregates pushed into the 2 UNION ALL parts: each part groups its own table first
scan result of t (0 rows)
group by 1 key(s), 2 aggregate(s)
engine: row (reads a subquery or view result)

(4 rows)

EXPLAIN SELECT broker, avg(amount) AS avg_amount FROM (SELECT buyer AS broker, amount FROM trade_log

plan (STRING)
scan rows of t read part by part into the grouping (not kept)
group by 1 key(s), 1 aggregate(s)
engine: row (reads a subquery or view result)

(3 rows)

Example: One activity list from news and forum posts, newest first

Setup:

CREATE TABLE news (id LONG PRIMARY KEY, title STRING, published_at TIMESTAMP);
CREATE TABLE forum_posts (id LONG PRIMARY KEY, title STRING, created_at TIMESTAMP);
INSERT INTO news VALUES
  (1, 'Market index gains 12 points', '2026-09-10T15:10:00+05:45'),
  (2, 'CITY announces bonus shares', '2026-09-09T16:00:00+05:45');
INSERT INTO forum_posts VALUES
  (10, 'Is VOLT a good buy now?', '2026-09-10T13:20:00+05:45'),
  (11, 'Hydro stocks after the monsoon', '2026-09-08T09:00:00+05:45');
SELECT 'news' AS kind, id, title, published_at AS at FROM news
UNION ALL
SELECT 'forum', id, title, created_at FROM forum_posts
ORDER BY at DESC
LIMIT 3;

Result:

kind (STRING)id (LONG)title (STRING)at (TIMESTAMP)
news1Market index gains 12 points2026-09-10T09:25:00.000Z
forum10Is VOLT a good buy now?2026-09-10T07:35:00.000Z
news2CITY announces bonus shares2026-09-09T10:15:00.000Z

(3 rows)

Example: Different values in the same column: KDB does not stop you

SELECT 1 AS n, 'a' AS t
UNION ALL
SELECT 'x', 2;

Result:

n (LONG)t (STRING)
1a
x2

(2 rows)

Example: LIMIT for one part: wrap it in FROM ( ) (sample data: sample)

SELECT * FROM (SELECT transaction_id, symbol, amount FROM trade_log WHERE date IN '2026-09-09' ORDER BY amount DESC LIMIT 1) a
UNION ALL
SELECT * FROM (SELECT transaction_id, symbol, amount FROM trade_log WHERE date IN '2026-09-10' ORDER BY amount DESC LIMIT 1) b;

Result:

transaction_id (LONG)symbol (SYMBOL)amount (DOUBLE)
100001TELCO881000
100017TELCO440500

(2 rows)

Example: Symbols that traded and also have a daily candle (no copies) (sample data: sample)

SELECT symbol FROM trade_log
INTERSECT
SELECT symbol FROM ochl_day
ORDER BY 1;

Result:

symbol (SYMBOL)
CITY
COAST
VOLT

(3 rows)

Example: Every part needs the same number of columns (sample data: sample; shows an error)

SELECT symbol FROM trade_log
UNION ALL
SELECT symbol, close FROM ochl_day;

Result:

Error: UNION ALL part 2 has 2 columns, the first part has 1

Example: ORDER BY after UNION ALL needs a result column (sample data: sample; shows an error)

SELECT symbol, quantity FROM trade_log
UNION ALL
SELECT symbol, volume FROM ochl_day
ORDER BY quantity * 2;

Result:

Error: line 4, column 19: ORDER BY after UNION ALL must name a result column or position

Example: A part cannot have its own LIMIT (sample data: sample; shows an error)

SELECT symbol FROM securities LIMIT 1
UNION ALL
SELECT 'X';

Result:

Error: line 2, column 1: expected OFFSET, ';' or the end of the input but found 'UNION'

(SELECT ...) as a value

Syntax:

(SELECT expression FROM ... [WHERE ...] [LIMIT 1])

A subquery in ( ) where a value is expected: in the SELECT list, in WHERE, in HAVING or in a join's ON. It must return 0 or 1 row.

Example: Each close next to the best close of the day (sample data: sample)

SELECT symbol, close,
       (SELECT max(close) FROM ochl_day WHERE time IN '2026-09-10') AS best_close,
       round(close / (SELECT max(close) FROM ochl_day WHERE time IN '2026-09-10') * 100, 1) AS pct_of_best
FROM ochl_day
WHERE time IN '2026-09-10';

Result:

symbol (SYMBOL)close (DOUBLE)best_close (DOUBLE)pct_of_best (DOUBLE)
CITY504.9504.9100
COAST376.1504.974.5
VOLT191.2504.937.9

(3 rows)

Example: The trade at the highest CITY rate (sample data: sample)

SELECT date, transaction_id, buyer, seller, quantity, rate
FROM trade_log
WHERE symbol = 'CITY'
  AND rate = (SELECT max(rate) FROM trade_log WHERE symbol = 'CITY');

Result:

date (TIMESTAMP)transaction_id (LONG)buyer (INT)seller (INT)quantity (LONG)rate (DOUBLE)
2026-09-10T00:00:00.000Z1000294945200518

(1 row)

Example: The last trading day in the table, without typing the date (sample data: sample)

SELECT time, symbol, close
FROM ochl_day
WHERE time = (SELECT max(time) FROM ochl_day)
ORDER BY close DESC;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)
2026-09-10T00:00:00.000ZCITY504.9
2026-09-10T00:00:00.000ZCOAST376.1
2026-09-10T00:00:00.000ZVOLT191.2

(3 rows)

Example: Symbols with more trades than the average symbol (sample data: sample)

SELECT symbol, count(*) AS trades
FROM trade_log
GROUP BY symbol
HAVING count(*) > (SELECT avg(n) FROM (SELECT symbol, count(*) AS n FROM trade_log GROUP BY symbol) t);

Result:

symbol (SYMBOL)trades (LONG)
CITY11
COAST11

(2 rows)

Example: No row gives NULL (sample data: sample)

SELECT (SELECT max(close) FROM ochl_day WHERE symbol = 'TELCO') AS telco_best_close;

Result:

telco_best_close (STRING)
NULL

(1 row)

Example: More than one row is an error (sample data: sample; shows an error)

SELECT symbol, (SELECT close FROM ochl_day WHERE time IN '2026-09-10') AS close
FROM securities;

Result:

Error: line 1, column 16: subquery returned 3 rows; a value needs exactly one (add LIMIT 1?)

IN (SELECT ...), NOT IN (SELECT ...)

Syntax:

expression IN (SELECT column FROM ... [WHERE ...])
expression NOT IN (SELECT column FROM ... [WHERE ...])

True when the value is (or is not) one of the values the subquery returns. It works like IN ('a', 'b', ...) with the list made by the subquery (see IN).

Example: Securities with a trade of 1000 shares or more (sample data: sample)

SELECT symbol, name
FROM securities
WHERE symbol IN (SELECT symbol FROM trade_log WHERE quantity >= 1000);

Result:

symbol (SYMBOL)name (STRING)
COASTCoastal Bank Limited
TELCONorthwind Telecom Limited

(2 rows)

Example: Securities with no daily candles yet (sample data: sample)

SELECT symbol, name, sector
FROM securities
WHERE symbol NOT IN (SELECT symbol FROM ochl_day);

Result:

symbol (SYMBOL)name (STRING)sector (STRING)
ACMEAcme Investment CompanyInvestment
TELCONorthwind Telecom LimitedTelecom

(2 rows)

Example: Candles of the 3 symbols with the biggest turnover (TELCO has no candles) (sample data: sample)

SELECT time, symbol, close, volume
FROM ochl_day
WHERE time IN '2026-09-10'
  AND symbol IN (SELECT symbol
                 FROM (SELECT symbol, sum(amount) AS turnover FROM trade_log
                       WHERE date IN '2026-09-10' GROUP BY symbol
                       ORDER BY turnover DESC LIMIT 3) top3);

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)volume (DOUBLE)
2026-09-10T00:00:00.000ZCITY504.9183040
2026-09-10T00:00:00.000ZCOAST376.1186200

(2 rows)

Example: Users who wrote a post, for an app

Setup:

CREATE TABLE app_users (id LONG PRIMARY KEY, name STRING, banned BOOL);
CREATE TABLE posts (id LONG PRIMARY KEY, user_id LONG, title STRING);
INSERT INTO app_users VALUES (1, 'sita', FALSE), (2, 'ram', FALSE), (3, 'hari', TRUE);
INSERT INTO posts VALUES (100, 1, 'COAST results'), (101, 3, 'Buy now!!!'), (102, 1, 'Hydro outlook');
SELECT id, name FROM app_users WHERE id IN (SELECT user_id FROM posts) ORDER BY id;
SELECT id, name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts);

Result:

SELECT id, name FROM app_users WHERE id IN (SELECT user_id FROM posts) ORDER BY id

id (LONG)name (STRING)
1sita
3hari

(2 rows)

SELECT id, name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts)

id (LONG)name (STRING)
2ram

(1 row)

Example: A NULL in the list: NOT IN keeps nothing

Setup:

CREATE TABLE app_users (id LONG PRIMARY KEY, name STRING);
CREATE TABLE posts (id LONG PRIMARY KEY, user_id LONG);
INSERT INTO app_users VALUES (1, 'sita'), (2, 'ram');
INSERT INTO posts VALUES (100, 1), (101, NULL);
SELECT name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts);
SELECT name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts WHERE user_id IS NOT NULL);

Result:

SELECT name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts)

name (STRING)

(0 rows)

SELECT name FROM app_users WHERE id NOT IN (SELECT user_id FROM posts WHERE user_id IS NOT NULL)

name (STRING)
ram

(1 row)

Example: Clean up posts of users who are gone

Setup:

CREATE TABLE app_users (id LONG PRIMARY KEY, name STRING);
CREATE TABLE posts (id LONG PRIMARY KEY, user_id LONG);
INSERT INTO app_users VALUES (1, 'sita');
INSERT INTO posts VALUES (100, 1), (101, 9);
DELETE FROM posts WHERE user_id NOT IN (SELECT id FROM app_users);
SELECT * FROM posts;

Result:

DELETE FROM posts WHERE user_id NOT IN (SELECT id FROM app_users)

1 row(s) deleted

SELECT * FROM posts

id (LONG)user_id (LONG)
1001

(1 row)

EXISTS, NOT EXISTS

Syntax:

EXISTS (SELECT ... FROM ... [WHERE ...])
NOT EXISTS (SELECT ... FROM ... [WHERE ...])

EXISTS is true when the subquery returns at least one row. What the subquery selects does not matter (SELECT 1 is usual).

Example: Show today's candles only if today's trade log is loaded (sample data: sample)

SELECT symbol, close
FROM ochl_day
WHERE time IN '2026-09-10'
  AND EXISTS (SELECT 1 FROM trade_log WHERE date IN '2026-09-10');

Result:

symbol (SYMBOL)close (DOUBLE)
CITY504.9
COAST376.1
VOLT191.2

(3 rows)

Example: As a column: is there any trade of 1000 shares or more? (sample data: sample)

SELECT EXISTS (SELECT 1 FROM trade_log WHERE quantity >= 1000) AS big_trade,
       NOT EXISTS (SELECT 1 FROM trade_log WHERE quantity > 5000) AS no_huge_trade;

Result:

big_trade (BOOL)no_huge_trade (BOOL)
truetrue

(1 row)

Example: Accounting check: warn when a journal entry does not balance

Setup:

CREATE TABLE journal (entry_id LONG, line INT, account STRING, debit DECIMAL(2), credit DECIMAL(2));
INSERT INTO journal VALUES
  (1, 1, 'Cash', 5000.00, 0), (1, 2, 'Sales', 0, 5000.00),
  (2, 1, 'Rent', 1200.00, 0), (2, 2, 'Cash', 0, 1000.00);
SELECT EXISTS (SELECT entry_id FROM journal GROUP BY entry_id HAVING sum(debit) != sum(credit)) AS has_unbalanced_entry;
SELECT entry_id, sum(debit) AS debit, sum(credit) AS credit
FROM journal GROUP BY entry_id HAVING sum(debit) != sum(credit);

Result:

SELECT EXISTS (SELECT entry_id FROM journal GROUP BY entry_id HAVING sum(debit) != sum(credit)) AS h

has_unbalanced_entry (BOOL)
true

(1 row)

SELECT entry_id, sum(debit) AS debit, sum(credit) AS credit FROM journal GROUP BY entry_id HAVING su

entry_id (LONG)debit (DECIMAL(2))credit (DECIMAL(2))
21200.001000.00

(1 row)

FROM (SELECT ...) alias

Syntax:

SELECT ... FROM (SELECT ...) [AS] alias [JOIN ...] [WHERE ...] ...
SELECT ... FROM table t JOIN (SELECT ...) [AS] alias ON condition

Uses the result of a query as a table. This is also called a derived table. The subquery needs a name (the alias).

PartMeaning
(SELECT ...)any query, also with GROUP BY, SAMPLE BY, window functions, UNION ALL, ORDER BY and LIMIT
aliasthe name of the result; write alias.column in the outer query

Example: Group inside, filter outside (sample data: sample)

SELECT t.symbol, t.trades, t.shares
FROM (SELECT symbol, count(*) AS trades, sum(quantity) AS shares FROM trade_log GROUP BY symbol) t
WHERE t.trades > 5
ORDER BY t.shares DESC;

Result:

symbol (SYMBOL)trades (LONG)shares (LONG)
COAST113070
TELCO62770
CITY112420

(3 rows)

Example: Weekly candles inside, 2-week moving average outside (sample data: sample)

SELECT time, close, sma(close, 2) AS sma2
FROM (
  SELECT time, last(close) AS close
  FROM ochl_day
  WHERE symbol = 'CITY' AND time IN '2026'
  SAMPLE BY 1w WEEK START SUNDAY
) w;

Result:

time (TIMESTAMP)close (DOUBLE)sma2 (DOUBLE)
2026-08-23T00:00:00.000Z505.9NULL
2026-08-30T00:00:00.000Z497.9501.9
2026-09-06T00:00:00.000Z504.9501.4

(3 rows)

Example: Join each trade to the symbol's total for the day (sample data: sample)

SELECT f.transaction_id, f.symbol, f.quantity, d.total,
       round(f.quantity * 100.0 / d.total, 1) AS pct_of_day
FROM trade_log f
JOIN (SELECT symbol, sum(quantity) AS total FROM trade_log WHERE date IN '2026-09-10' GROUP BY symbol) AS d
  ON d.symbol = f.symbol
WHERE f.date IN '2026-09-10' AND f.symbol = 'COAST'
ORDER BY pct_of_day DESC
LIMIT 4;

Result:

transaction_id (LONG)symbol (SYMBOL)quantity (LONG)total (LONG)pct_of_day (DOUBLE)
100015COAST1000285035.1
100031COAST1000285035.1
100023COAST500285017.5
100026COAST20028507

(4 rows)

Example: A cash book from two tables, with a running balance

Setup:

CREATE TABLE sales (id LONG PRIMARY KEY, day DATE, amount DECIMAL(2));
CREATE TABLE expenses (id LONG PRIMARY KEY, day DATE, amount DECIMAL(2));
INSERT INTO sales VALUES (1, '2026-09-01', 25000.00), (2, '2026-09-03', 18000.00);
INSERT INTO expenses VALUES (1, '2026-09-02', 12000.00), (2, '2026-09-03', 2500.50);
SELECT day, kind, amount, cumsum(amount) OVER (ORDER BY day, kind) AS balance
FROM (
  SELECT day, 'sale' AS kind, amount FROM sales
  UNION ALL
  SELECT day, 'expense', -amount FROM expenses
) book
ORDER BY day, kind;

Result:

day (DATE)kind (STRING)amount (DECIMAL(2))balance (DOUBLE)
2026-09-01sale25000.0025000
2026-09-02expense-12000.0013000
2026-09-03expense-2500.5010499.5
2026-09-03sale18000.0028499.5

(4 rows)

Example: The subquery needs no name (sample data: sample)

SELECT count(*) FROM (SELECT symbol FROM securities);

Result:

count(*) (LONG)
5

(1 row)

Correlated subqueries

Syntax:

SELECT ..., (SELECT ... FROM t2 WHERE t2.col = t1.col) FROM t1
SELECT ... FROM t1 WHERE [NOT] EXISTS (SELECT 1 FROM t2 WHERE t2.col = t1.col)
SELECT ... FROM t1 LEFT JOIN LATERAL (SELECT ... WHERE t2.col = t1.col) x ON TRUE

A correlated subquery uses a column of the outer query (t1.col above).

Example: The best close of each symbol, as a subquery per row (sample data: sample)

SELECT s.symbol,
       (SELECT max(close) FROM ochl_day d WHERE d.symbol = s.symbol) AS best_close
FROM securities s
ORDER BY s.symbol;

Result:

symbol (SYMBOL)best_close (DOUBLE)
ACMENULL
CITY542.4
COAST426.5
TELCONULL
VOLT196.4

(5 rows)

Example: The same question as a LEFT JOIN with GROUP BY (sample data: sample)

SELECT s.symbol, max(d.close) AS best_close
FROM securities s
LEFT JOIN ochl_day d ON d.symbol = s.symbol
GROUP BY s.symbol
ORDER BY s.symbol;

Result:

symbol (SYMBOL)best_close (DOUBLE)
ACMENULL
CITY542.4
COAST426.5
TELCONULL
VOLT196.4

(5 rows)

Example: Symbols with at least one trade: EXISTS with an outer column (sample data: sample)

SELECT symbol FROM securities s
WHERE EXISTS (SELECT 1 FROM trade_log f WHERE f.symbol = s.symbol)
ORDER BY symbol;

Result:

symbol (SYMBOL)
CITY
COAST
TELCO
VOLT

(4 rows)

Example: The same test with IN (SELECT ...) (sample data: sample)

SELECT symbol FROM securities
WHERE symbol IN (SELECT symbol FROM trade_log)
ORDER BY symbol;

Result:

symbol (SYMBOL)
CITY
COAST
TELCO
VOLT

(4 rows)

Example: Latest close per symbol compared with the day before: a window function instead (sample data: sample)

SELECT symbol, prev_close, close, round((close - prev_close) / prev_close * 100, 2) AS change_pct
FROM (
  SELECT time, symbol, close, lag(close) OVER (PARTITION BY symbol ORDER BY time) AS prev_close
  FROM ochl_day
) t
WHERE time IN '2026-09-10'
ORDER BY change_pct DESC;

Result:

symbol (SYMBOL)prev_close (DOUBLE)close (DOUBLE)change_pct (DOUBLE)
VOLT187.3191.22.08
CITY504.6504.90.06
COAST380.1376.1-1.05

(3 rows)