Joins
Put rows of two or more tables side by side: JOIN, LEFT / RIGHT / FULL JOIN, USING, CROSS JOIN, and ASOF / LT / SPLICE JOIN for time series.
- Category: Querying
- Keywords: join, JOIN, hash join, join speed, key lookup, index lookup, index nested loop join, KDB_NO_COLUMN_JOIN, engine: column (hash join), GROUP BY over a join, join another database, INNER JOIN, INNER, ON, LEFT JOIN, LEFT OUTER JOIN, LEFT, OUTER, CROSS JOIN, CROSS, ASOF JOIN, ASOF, as of, time join, table alias, self join, join subquery, RIGHT JOIN, RIGHT OUTER JOIN, FULL JOIN, FULL OUTER JOIN, USING, JOIN USING, LT JOIN, SPLICE JOIN, RIGHT, FULL, LT, SPLICE, comma join, ambiguous column, join, left join, asof, cross join
- See also: SELECT (from), WHERE, GROUP BY and HAVING, UNION and subqueries (correlated), LATEST ON, EXPLAIN
A join puts rows of two tables side by side. For example, a trade in trade_log has only the broker number (buyer = 58); a join with brokers adds the broker's name.
ON says which rows belong together. Give each table a short name (an alias) and write alias.column for the columns.
| Join | Rows you get |
|---|---|
JOIN (or INNER JOIN) | only the rows that have a match on both sides |
LEFT JOIN (or LEFT OUTER JOIN) | every row of the left table; NULL columns when the right side has no match |
RIGHT JOIN (or RIGHT OUTER JOIN) | every row of the right table; NULL columns when the left side has no match |
FULL JOIN (or FULL OUTER JOIN) | every row of both tables, side by side where they match |
CROSS JOIN | every left row with every right row |
ASOF JOIN | for each left row, the last right row at or before its time |
LT JOIN | for each left row, the last right row strictly before its time |
SPLICE JOIN | every row of both time-series tables in time order, each with the other table's last row so far |
Example: The 5 biggest trades of the day, with the buyer's and the seller's names (sample data: sample)
SELECT f.transaction_id, f.symbol, b.name AS buyer, s.name AS seller, f.quantity, f.rate, f.amount
FROM trade_log f
JOIN brokers b ON f.buyer = b.id
JOIN brokers s ON f.seller = s.id
WHERE f.date IN '2026-09-10'
ORDER BY f.amount DESC
LIMIT 5;
Result:
| transaction_id (LONG) | symbol (SYMBOL) | buyer (STRING) | seller (STRING) | quantity (LONG) | rate (DOUBLE) | amount (DOUBLE) |
|---|---|---|---|---|---|---|
| 100017 | TELCO | Online Securities | Vision Securities | 500 | 881 | 440500 |
| 100031 | COAST | Imperial Securities | Northgate Securities | 1000 | 402 | 402000 |
| 100015 | COAST | Online Securities | Imperial Securities | 1000 | 398 | 398000 |
| 100018 | CITY | Imperial Securities | Keystone Securities | 500 | 516 | 258000 |
| 100023 | COAST | Northgate Securities | Keystone Securities | 500 | 399 | 199500 |
(5 rows)
JOIN, INNER JOIN
Syntax:
FROM left_table [[AS] alias]
[INNER] JOIN right_table [[AS] alias] { ON condition | USING (column, ...) }
[[INNER] JOIN ... ON ...] ...
For each row of the left side, KDB finds the rows of the right table where the ON condition is true, and gives one output row for each match. A row with no match is left out.
| Part | Meaning |
|---|---|
right_table | a table, a view, (SELECT ...) alias, or a table of another database: otherdb.table (see queries across databases) |
ON condition | which rows match; usually a.key = b.key, and more conditions with AND |
- Returns: one row per matching pair. The columns of all tables can be used in the select list,
WHERE,GROUP BYandORDER BY. - NULL: a
NULLkey never matches anything (not even anotherNULL). - Notes:
ON(orUSING) is required. Thea = bparts ofONare used to find matches fast (a hash join); a join without any=between the two tables checks every pair (slow for big tables). - A join on one
=between number columns (ids,DATEorTIMESTAMPvalues) is the fastest kind: the numbers themselves are the keys of the hash join. Joining on a text column, or on two=, works the same way but makes a text key for every row. - The first table in
FROMis read with its time and symbol filters. A joined table is either looked up by key for each left row, or read completely into memory (see join speed). Put the big time-series table first and join small tables to it. - The comma form
FROM a, bis aCROSS JOIN; ana.x = b.xinWHEREmakes it a hash join.JOIN ... ONsays the same more clearly. - A
RIGHT,FULLorSPLICE JOINafter a comma is refused: PostgreSQL readsFROM a, b RIGHT JOIN casa, (b RIGHT JOIN c), and joining left to right would give other rows. WriteCROSS JOINfor the comma (left to right), or put the joins after the comma in a subquery. - At most 63 tables in one query.
Example: Security names with the close price of the day (sample data: sample)
SELECT s.symbol, s.name, s.sector, d.close
FROM ochl_day d
JOIN securities s ON s.symbol = d.symbol
WHERE d.time IN '2026-09-10'
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) | name (STRING) | sector (STRING) | close (DOUBLE) |
|---|---|---|---|
| CITY | City Bank Limited | Banking | 504.9 |
| COAST | Coastal Bank Limited | Banking | 376.1 |
| VOLT | Volt River Hydropower Limited | Energy | 191.2 |
(3 rows)
Example: Turnover by sector (sample data: sample)
SELECT s.sector, count(*) AS trades, sum(f.quantity) AS shares, sum(f.amount) AS turnover
FROM trade_log f
JOIN securities s ON f.symbol = s.symbol
WHERE f.date IN '2026-09-10'
GROUP BY s.sector
ORDER BY turnover DESC;
Result:
| sector (STRING) | trades (LONG) | shares (LONG) | turnover (DOUBLE) |
|---|---|---|---|
| Banking | 16 | 3760 | 1609170 |
| Telecom | 4 | 770 | 678260 |
| Energy | 4 | 400 | 73950 |
(3 rows)
Example: Accounting: totals per account type (a small trial balance) (sample data: query-app)
SELECT a.type, sum(l.debit) AS debit, sum(l.credit) AS credit
FROM ledger l
JOIN accounts a ON a.code = l.account
GROUP BY a.type
ORDER BY a.type;
Result:
| type (STRING) | debit (DECIMAL(2)) | credit (DECIMAL(2)) |
|---|---|---|
| asset | 806350.00 | 323830.00 |
| equity | NULL | 500000.00 |
| expense | 26030.00 | NULL |
| income | NULL | 8550.00 |
| liability | 257500.00 | 257500.00 |
(5 rows)
Example: Forum posts with the author's app plan (sample data: query-app, content-forum)
SELECT p.id, p.author, u.plan, u.city, p.body
FROM posts p
INNER JOIN app_users u ON u.username = p.author
WHERE p.thread_id = 1
ORDER BY p.id;
Result:
| id (LONG) | author (STRING) | plan (STRING) | city (STRING) | body (STRING) |
|---|---|---|---|---|
| 1 | ram | pro | Kathmandu | CITY dividend looks good this year. |
| 2 | sita | free | Pokhara | I agree, the profit grew 18%. |
| 4 | gita | pro | Dharan | What about the bonus shares? |
| 5 | ram | pro | Kathmandu | Bonus is 10% this time. |
| 6 | sita | free | Pokhara | सिटी बैंकको नाफा राम्रो छ। |
(5 rows)
Example: ON is required (sample data: sample; shows an error)
SELECT * FROM trade_log f JOIN brokers b;
Result:
Error: line 1, column 41: expected ON or USING (columns) and a join condition after the joined table but found ';'
Join speed: key lookup or hash
KDB picks one of two ways to find the rows of a joined table (JOIN and LEFT JOIN):
| Way | When | Cost |
|---|---|---|
| key lookup | The a = b parts of ON give the first columns of the joined table's key: its PRIMARY KEY, or for a time-series table its SORT BY / DEDUP KEY columns including the time column. Or they give the first column of an index of the joined table. | Each left row reads only its own rows. A login row joined to its user among 100,000 users reads one user. |
| hash | Any other ON, or a left side with many rows (more than about a quarter of the joined table) | The joined table is read once (only the columns the query uses), then every left row is matched in it. Its rows are not copied: the hash points at them where they already are in memory, and a row is built only when a left row matches it. |
EXPLAINshows the choice:join (key lookup on id) table users,join (index lookup on email) table usersorjoin (hash on 1 key(s)) table users.- When KDB cannot tell in advance how many left rows there are (a
WHEREon a column without an index), it starts with lookups and switches to the hash once they cost more. - A key of type
DOUBLEorDECIMALis always joined with the hash. - A
GROUP BYover joined rows keeps no copy of the joined tables' rows either, andORDER BY ... LIMIT nkeeps only the best n rows while it groups. A report that joins 20,000 users to their accounts and transfers and shows the top 20 runs about twice as fast as before (2026-10-02).
Example: Each trade looks up its buyer by the brokers' PRIMARY KEY (sample data: sample)
EXPLAIN SELECT f.transaction_id, b.name
FROM trade_log f
JOIN brokers b ON b.id = f.buyer
WHERE f.date IN '2026-09-10';
Result:
| plan (STRING) |
|---|
| scan table trade_log (1 of 2 partitions, about 32 rows) |
| join (key lookup on id) table brokers (1 of 1 partitions, about 5 rows) |
| build 3 of 7 stored column(s) per row: date, transaction_id, buyer |
| filter while scanning: 1 condition(s) |
| engine: row (a join) |
(5 rows)
GROUP BY over a join: the column engine
Since 2026-10-02 a GROUP BY (or a plain count(*), sum, ...) over two tables joined on one pair of columns runs on the column engine: the joined table is read once into a hash, the first table is read column by column on all threads, and no joined row is ever made. On 400,000 rows joined to 40,000 that is about 10 times faster than the row engine.
It is used when all of these hold:
| What | Allowed |
|---|---|
| Tables | two stored tables (not a subquery or view), JOIN or LEFT JOIN |
ON | one a.x = b.y of whole-number columns (BYTE .. LONG) or of text columns (SYMBOL, STRING), plus conditions on the joined table alone |
WHERE | conditions the column engine does (see WHERE: fast conditions) on either table; with LEFT JOIN, on the first table only |
GROUP BY | up to 4 plain columns of either table; no SAMPLE BY |
| Aggregates | count(*), count(col), sum, avg, min, max of plain columns (sum/avg of numbers, min/max also of times and dates) |
| Elsewhere | the SELECT list, HAVING and ORDER BY read only the group columns and the aggregates |
EXPLAINthen ends withengine: column (hash join); otherwiseengine: row (...)says why not.- A first table with a filter, joined to a big table (over 65,536 rows) that it can look up by key or index, stays on the row engine: a few lookups are cheaper than reading the whole joined table.
- The answers are the same on both engines, in the same order.
KDB_NO_COLUMN_JOIN=1sends every join back to the row engine, for comparing.
LEFT JOIN, LEFT OUTER JOIN
Syntax:
FROM left_table [[AS] alias]
LEFT [OUTER] JOIN right_table [[AS] alias] ON condition
Like JOIN, but every row of the left table stays. When the right table has no matching row, its columns are NULL. OUTER is an optional word.
- Returns: at least one row for each left row.
- NULL: the right side's columns are
NULLfor left rows without a match.count(right.column)counts them as 0. - Notes: a condition on the right table belongs in
ONwhen you want to keep the left rows. InWHEREit removes the rows where the right side isNULL, so theLEFT JOINacts like a plainJOIN(see the two examples below). - To keep the rows of the right table instead, use RIGHT JOIN; for both, FULL JOIN.
Example: Every security, also the ones without trades (sample data: sample)
SELECT s.symbol, count(f.transaction_id) AS trades
FROM securities s
LEFT JOIN trade_log f ON f.symbol = s.symbol
GROUP BY s.symbol
ORDER BY trades DESC, s.symbol;
Result:
| symbol (SYMBOL) | trades (LONG) |
|---|---|
| CITY | 11 |
| COAST | 11 |
| TELCO | 6 |
| VOLT | 4 |
| ACME | 0 |
(5 rows)
Example: The day's close for every security: the condition on the day is in ON (sample data: sample)
SELECT s.symbol, d.close
FROM securities s
LEFT JOIN ochl_day d ON d.symbol = s.symbol AND d.time IN '2026-09-10'
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) | close (DOUBLE) |
|---|---|
| ACME | NULL |
| CITY | 504.9 |
| COAST | 376.1 |
| TELCO | NULL |
| VOLT | 191.2 |
(5 rows)
Example: The same condition in WHERE removes ACME and TELCO (sample data: sample)
SELECT s.symbol, d.close
FROM securities s
LEFT JOIN ochl_day d ON d.symbol = s.symbol
WHERE d.time IN '2026-09-10'
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) | close (DOUBLE) |
|---|---|
| CITY | 504.9 |
| COAST | 376.1 |
| VOLT | 191.2 |
(3 rows)
Example: App users and how many forum posts they wrote (sample data: query-app, content-forum)
SELECT u.username, count(p.id) AS posts, max(p.created_at) AS last_post
FROM app_users u
LEFT OUTER JOIN posts p ON p.author = u.username
GROUP BY u.username
ORDER BY posts DESC, u.username;
Result:
| username (STRING) | posts (LONG) | last_post (TIMESTAMP) |
|---|---|---|
| gita | 2 | 2026-09-02T03:25:00.000Z |
| ram | 2 | 2026-09-01T05:45:00.000Z |
| sita | 2 | 2026-09-01T06:15:00.000Z |
| anita | 0 | NULL |
| bikash | 0 | NULL |
| hari_99 | 0 | NULL |
| suman | 0 | NULL |
| test_user | 0 | NULL |
(8 rows)
Example: Find what is missing: brokers who sold nothing on 2026-09-09 (sample data: sample)
SELECT b.id, b.name
FROM brokers b
LEFT JOIN trade_log f ON f.seller = b.id AND f.date IN '2026-09-09'
WHERE f.transaction_id IS NULL;
Result:
| id (INT) | name (STRING) |
|---|---|
| 58 | Northgate Securities |
(1 row)
RIGHT JOIN, RIGHT OUTER JOIN
Syntax:
FROM left_table [[AS] alias]
RIGHT [OUTER] JOIN right_table [[AS] alias] { ON condition | USING (column, ...) }
The mirror of LEFT JOIN: every row of the right table stays. When no left row matches it, the left side's columns are NULL. It is the same as swapping the two tables in a LEFT JOIN, but keeps the column order you wrote.
- Returns: at least one row for each right row.
- NULL: a right row whose key is
NULLmatches nothing and still comes out, withNULLon the left. - Notes:
WHEREis checked after the join, on the added rows too:WHERE left.col IS NULLfinds the right rows without a match. - The right table is read into memory (a hash join), as for
LEFT JOINwith the hash. The rows that no left row matched come after the others, unless youORDER BY.
Example: Every security, also the ones nobody traded on 2026-09-10 (sample data: sample)
SELECT s.symbol, count(f.transaction_id) AS trades
FROM (SELECT * FROM trade_log WHERE date IN '2026-09-10') f
RIGHT JOIN securities s ON f.symbol = s.symbol
GROUP BY s.symbol
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) | trades (LONG) |
|---|---|
| ACME | 0 |
| CITY | 7 |
| COAST | 9 |
| TELCO | 4 |
| VOLT | 4 |
(5 rows)
Example: Only the right rows without a match (sample data: sample)
SELECT s.symbol
FROM ochl_minutes m
RIGHT JOIN securities s ON m.symbol = s.symbol
WHERE m.symbol IS NULL
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) |
|---|
| ACME |
| TELCO |
| VOLT |
(3 rows)
FULL JOIN, FULL OUTER JOIN
Syntax:
FROM left_table [[AS] alias]
FULL [OUTER] JOIN right_table [[AS] alias] { ON condition | USING (column, ...) }
Every row of both tables: the pairs that match side by side, then the left rows without a match (right side NULL) and the right rows without a match (left side NULL). Use it to compare two lists.
- Returns: at least max(left rows, right rows) rows.
- Notes: with
USING (id), the oneidcolumn holds whichever side's value is there (see USING).
Example: Symbols with live prices, symbols that are not banks, or both (sample data: sample)
SELECT l.symbol AS live, s.symbol AS not_a_bank
FROM (SELECT DISTINCT symbol FROM live_data) l
FULL JOIN (SELECT symbol FROM securities WHERE sector <> 'Banking') s ON l.symbol = s.symbol
ORDER BY coalesce(l.symbol, s.symbol);
Result:
| live (SYMBOL) | not_a_bank (SYMBOL) |
|---|---|
| NULL | ACME |
| CITY | NULL |
| COAST | NULL |
| TELCO | TELCO |
| VOLT | VOLT |
(5 rows)
Example: FULL JOIN after a comma is refused (sample data: sample; shows an error)
SELECT count(*) FROM brokers b, securities s FULL JOIN live_data l ON l.symbol = s.symbol;
Result:
Error: line 1, column 46: FULL JOIN after a comma join is ambiguous: PostgreSQL joins it before the comma, KDB after. Write CROSS JOIN instead of the comma to join left to right, or put the joins after the comma in a subquery: FROM a, (SELECT ... FROM b RIGHT JOIN c ON ...) s
USING
Syntax:
JOIN right_table [[AS] alias] USING (column, ...)
USING (id) is ON left.id = right.id for columns that have the same name in both tables (PostgreSQL). It works with JOIN, LEFT JOIN, RIGHT JOIN and FULL JOIN.
| What you write | Means |
|---|---|
SELECT * | each USING column once, first, then the other columns of each table |
id (no table name) | the joined id: the left one for JOIN and LEFT JOIN, the right one for RIGHT JOIN, coalesce(left.id, right.id) for FULL JOIN |
a.id, b.id | each table's own column, as always; a.* still shows all of a |
- Notes: the column must be in the right table and in exactly one table on the left; otherwise write
ON a.col = b.col.
Example: The day's candle for each security, joined on the column both tables have (sample data: sample)
SELECT * FROM securities JOIN (SELECT symbol, close FROM ochl_day WHERE time IN '2026-09-10') d USING (symbol)
ORDER BY symbol;
Result:
| symbol (SYMBOL) | name (STRING) | sector (STRING) | close (DOUBLE) |
|---|---|---|---|
| CITY | City Bank Limited | Banking | 504.9 |
| COAST | Coastal Bank Limited | Banking | 376.1 |
| VOLT | Volt River Hydropower Limited | Energy | 191.2 |
(3 rows)
Example: A column that is not in both tables (sample data: sample; shows an error)
SELECT * FROM securities s JOIN brokers b USING (symbol);
Result:
Error: line 1, column 28: column 'symbol' in USING is not in the right table 'b'
CROSS JOIN
Syntax:
FROM left_table [[AS] alias]
CROSS JOIN right_table [[AS] alias]
Every row of the left side with every row of the right table. There is no ON.
- Returns: (left rows) × (right rows) rows. 5 brokers × 5 securities = 25 rows.
- Notes: the result grows very fast. Filter with
WHERE, and use it only with small tables. - Useful to make a full grid (every broker with every symbol), or to compare every row with a few other rows.
Example: How many pairs (sample data: sample)
SELECT count(*) AS pairs FROM brokers b CROSS JOIN securities s;
Result:
| pairs (LONG) |
|---|
| 25 |
(1 row)
Example: Broker buy and sell summary of the day (sample data: sample)
SELECT b.id, b.name,
sum(CASE WHEN f.buyer = b.id THEN f.quantity ELSE 0 END) AS bought,
sum(CASE WHEN f.seller = b.id THEN f.quantity ELSE 0 END) AS sold
FROM brokers b
CROSS JOIN trade_log f
WHERE f.date IN '2026-09-10'
GROUP BY b.id, b.name
ORDER BY bought DESC;
Result:
| id (INT) | name (STRING) | bought (LONG) | sold (LONG) |
|---|---|---|---|
| 49 | Online Securities | 1740 | 340 |
| 45 | Imperial Securities | 1560 | 1520 |
| 58 | Northgate Securities | 760 | 1270 |
| 1 | Keystone Securities | 570 | 1100 |
| 34 | Vision Securities | 300 | 700 |
(5 rows)
Example: CROSS JOIN has no ON (sample data: sample; shows an error)
SELECT b.name FROM brokers b CROSS JOIN securities s ON s.symbol = 'TELCO';
Result:
Error: line 1, column 54: expected JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, ASOF JOIN, LT JOIN, SPLICE JOIN, CROSS JOIN, WHERE, LATEST ON, SAMPLE BY, GROUP BY, UNION ALL, ORDER BY, LIMIT, ';' or the end of the input but found 'ON'
ASOF JOIN
Syntax:
FROM left_table [[AS] alias]
ASOF JOIN right_table [[AS] alias] [ON condition]
A join by time. For each row of the left table, KDB takes the last row of the right table whose time is at or before the left row's time. With ON, the right row must also match the ON condition (usually the same symbol).
This answers questions like "what was the last candle when this live price came in?" or "what was the price when this trade happened?".
| Part | Meaning |
|---|---|
left_table | a time-series table (its TIMESERIES column is the time) |
right_table | a time-series table, or a subquery whose result has a time column |
ON a.symbol = b.symbol | match only rows of the same symbol; other AND conditions are checked too |
ON (symbol) | QuestDB's short form of the same: the listed columns are equal in both tables |
- Returns: one row for each left row. When no right row is early enough (or none matches
ON), the right columns areNULL, like aLEFT JOIN. - Notes: both sides must have a time column: a relational table gives the error
ASOF JOIN needs a time column in both tables. - "At or before" includes the same time. For strictly before, use LT JOIN.
- Without
ON, any row of the right table can match, whatever its symbol. - QuestDB's
TOLERANCEis not there yet.
Example: Live prices with the last minute candle at or before each snapshot (sample data: sample)
SELECT l.timestamp, l.symbol, l.ltp, m.time AS candle_time, m.close AS candle_close
FROM live_data l
ASOF JOIN ochl_minutes m ON l.symbol = m.symbol
WHERE l.symbol IN ('CITY', 'COAST')
ORDER BY l.symbol, l.timestamp;
Result:
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) | candle_time (TIMESTAMP) | candle_close (DOUBLE) |
|---|---|---|---|---|
| 2026-09-10T05:20:00.000Z | CITY | 513 | 2026-09-10T05:20:00.000Z | 504.6 |
| 2026-09-10T07:15:00.000Z | CITY | 515.6 | 2026-09-10T07:15:00.000Z | 502.3 |
| 2026-09-10T09:14:00.000Z | CITY | 527.1 | 2026-09-10T09:14:00.000Z | 504.9 |
| 2026-09-10T05:20:00.000Z | COAST | 390.6 | 2026-09-10T05:20:00.000Z | 380.9 |
| 2026-09-10T07:15:00.000Z | COAST | 391.4 | 2026-09-10T07:15:00.000Z | 377.8 |
| 2026-09-10T09:14:00.000Z | COAST | 401.9 | 2026-09-10T09:14:00.000Z | 376.1 |
(6 rows)
Example: No match: there are no minute candles for TELCO, so the right side is NULL (sample data: sample)
SELECT l.timestamp, l.symbol, l.ltp, m.time AS candle_time, m.close
FROM live_data l
ASOF JOIN ochl_minutes m ON l.symbol = m.symbol
WHERE l.symbol = 'TELCO';
Result:
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) | candle_time (TIMESTAMP) | close (DOUBLE) |
|---|---|---|---|---|
| 2026-09-10T05:20:00.000Z | TELCO | 860.5 | NULL | NULL |
| 2026-09-10T07:15:00.000Z | TELCO | 869 | NULL | NULL |
| 2026-09-10T09:14:00.000Z | TELCO | 897.7 | NULL | NULL |
(3 rows)
Example: Trade rate compared with the day's close (sample data: sample)
SELECT f.transaction_id, f.symbol, f.rate, d.close AS day_close, round(f.rate - d.close, 2) AS above_close
FROM trade_log f
ASOF JOIN ochl_day d ON f.symbol = d.symbol
WHERE f.date IN '2026-09-10' AND f.symbol = 'COAST'
ORDER BY f.transaction_id
LIMIT 4;
Result:
| transaction_id (LONG) | symbol (SYMBOL) | rate (DOUBLE) | day_close (DOUBLE) | above_close (DOUBLE) |
|---|---|---|---|---|
| 100009 | COAST | 400 | 376.1 | 23.9 |
| 100013 | COAST | 398 | 376.1 | 21.9 |
| 100014 | COAST | 399.5 | 376.1 | 23.4 |
| 100015 | COAST | 398 | 376.1 | 21.9 |
(4 rows)
Example: An extra ON condition: the last minute with more than 1400 shares (sample data: sample)
SELECT l.timestamp, l.ltp, m.time AS big_minute, m.volume
FROM live_data l
ASOF JOIN ochl_minutes m ON l.symbol = m.symbol AND m.volume > 1400
WHERE l.symbol = 'CITY';
Result:
| timestamp (TIMESTAMP) | ltp (DOUBLE) | big_minute (TIMESTAMP) | volume (DOUBLE) |
|---|---|---|---|
| 2026-09-10T05:20:00.000Z | 513 | 2026-09-10T05:20:00.000Z | 1420 |
| 2026-09-10T07:15:00.000Z | 515.6 | 2026-09-10T07:10:00.000Z | 1440 |
| 2026-09-10T09:14:00.000Z | 527.1 | 2026-09-10T08:53:00.000Z | 1480 |
(3 rows)
Example: Both tables need a time column (sample data: sample; shows an error)
SELECT l.symbol, s.name FROM live_data l ASOF JOIN securities s ON l.symbol = s.symbol;
Result:
Error: line 1, column 42: ASOF JOIN needs a time column in both tables (TIMESERIES tables)
LT JOIN
Syntax:
FROM left_table [[AS] alias]
LT JOIN right_table [[AS] alias] [ON condition | ON (column, ...)]
Like ASOF JOIN, but the right row must be strictly earlier than the left row (QuestDB). A right row at the same time is skipped. Use it to pair a row with the one before it, for example each day's candle with the previous day's.
- Returns: one row for each left row;
NULLright columns when no right row is earlier. - Notes: the same rules as
ASOF JOIN: both tables need a time column, andONmay add conditions besides the keys.
Example: Each day's close with the close of the day before (sample data: sample)
SELECT d.time, d.close, p.time AS prev_day, p.close AS prev_close
FROM ochl_day d
LT JOIN ochl_day p ON (symbol)
WHERE d.symbol = 'CITY' AND d.time >= '2026-09-06'
ORDER BY d.time;
Result:
| time (TIMESTAMP) | close (DOUBLE) | prev_day (TIMESTAMP) | prev_close (DOUBLE) |
|---|---|---|---|
| 2026-09-06T00:00:00.000Z | 499 | 2026-09-03T00:00:00.000Z | 497.9 |
| 2026-09-07T00:00:00.000Z | 498.7 | 2026-09-06T00:00:00.000Z | 499 |
| 2026-09-08T00:00:00.000Z | 497.5 | 2026-09-07T00:00:00.000Z | 498.7 |
| 2026-09-09T00:00:00.000Z | 504.6 | 2026-09-08T00:00:00.000Z | 497.5 |
| 2026-09-10T00:00:00.000Z | 504.9 | 2026-09-09T00:00:00.000Z | 504.6 |
(5 rows)
SPLICE JOIN
Syntax:
FROM left_table [[AS] alias]
SPLICE JOIN right_table [[AS] alias] [ON a.key = b.key | ON (column, ...)]
A full time join (QuestDB): the rows of both tables are merged in time order. Each row comes out once, with the latest row of the other table so far (of the same key with ON). Rows of the two tables with the same time (and key) come out as one pair.
- Returns: at least as many rows as both tables together, minus the pairs of the same time. A row before any row of the other table has
NULLon that side. - Notes:
ONtakes only equal columns.SPLICE JOINmust be the first join of the query (joins after it are fine); both tables need a time column. - The rows come out in time order. Rows of one table with the same time come in that table's order (for a table with
SORT BY (symbol, time), by symbol). - With keys, every row of both tables is kept. QuestDB 9.4.3 pairs rows the same way for one key, but with several keys it drops some right rows and pairs some rows with an older row of their key; KDB keeps them all.
Example: Live prices and a few minute candles on one time line (05:20 has both: one pair) (sample data: sample)
SELECT l.timestamp, l.ltp, m.time AS candle, m.close
FROM (SELECT * FROM live_data WHERE symbol = 'CITY') l
SPLICE JOIN (SELECT * FROM ochl_minutes
WHERE symbol = 'CITY' AND time >= '2026-09-10T05:17:00Z' AND time < '2026-09-10T05:23:00Z') m;
Result:
| timestamp (TIMESTAMP) | ltp (DOUBLE) | candle (TIMESTAMP) | close (DOUBLE) |
|---|---|---|---|
| NULL | NULL | 2026-09-10T05:17:00.000Z | 504.2 |
| NULL | NULL | 2026-09-10T05:18:00.000Z | 504.4 |
| NULL | NULL | 2026-09-10T05:19:00.000Z | 504.5 |
| 2026-09-10T05:20:00.000Z | 513 | 2026-09-10T05:20:00.000Z | 504.6 |
| 2026-09-10T05:20:00.000Z | 513 | 2026-09-10T05:21:00.000Z | 505.1 |
| 2026-09-10T05:20:00.000Z | 513 | 2026-09-10T05:22:00.000Z | 504.9 |
| 2026-09-10T07:15:00.000Z | 515.6 | 2026-09-10T05:22:00.000Z | 504.9 |
| 2026-09-10T09:14:00.000Z | 527.1 | 2026-09-10T05:22:00.000Z | 504.9 |
(8 rows)
Table aliases
Syntax:
FROM table [AS] alias
JOIN table [AS] alias ON ...
alias.column
alias.*
An alias is a short name for a table in one query. After you give one, use it for the columns: f.symbol. AS is optional.
- Notes: when a column name is in more than one table (like
symbolorname), you must writealias.column. Otherwise KDB sayscolumn 'symbol' is in more than one table. - The same table can be joined twice with two aliases (
brokers bandbrokers sin the first example). - The words
ASOF,CROSS,FULL,INNER,LATEST,LEFT,LT,RIGHT,SAMPLE,SPLICE,UNION,USINGand a few more cannot be an alias withoutAS, because they start the next part of the query.
Example: One table joined twice, with AS and long aliases: who sold to whom (sample data: sample)
SELECT f.transaction_id, buyer_broker.name AS buyer, seller_broker.name AS seller
FROM trade_log AS f
JOIN brokers AS buyer_broker ON buyer_broker.id = f.buyer
JOIN brokers AS seller_broker ON seller_broker.id = f.seller
WHERE seller_broker.name = 'Keystone Securities'
ORDER BY f.transaction_id
LIMIT 3;
Result:
| transaction_id (LONG) | buyer (STRING) | seller (STRING) |
|---|---|---|
| 100002 | Imperial Securities | Keystone Securities |
| 100013 | Northgate Securities | Keystone Securities |
| 100016 | Vision Securities | Keystone Securities |
(3 rows)
Example: A column in two tables needs the alias (sample data: sample; shows an error)
SELECT symbol, name FROM trade_log f JOIN securities s ON s.symbol = f.symbol;
Result:
Error: line 1, column 8: column 'symbol' is in more than one table; write it as table.column
Joining a subquery
Syntax:
JOIN (SELECT ...) alias ON condition
LEFT JOIN (SELECT ...) alias ON condition
ASOF JOIN (SELECT ...) alias ON condition
The right side of a join can be the result of another query in ( ). Give it an alias, so you can write alias.column. This is how you join a table with totals or with the latest row of something.
- Notes: the subquery runs once, before the join. To use columns of the tables before it, write
LATERAL (SELECT ...)(see correlated subqueries). - A subquery in
FROM(the left side) also works: see FROM.
Example: Each security with its latest live price (sample data: sample)
SELECT s.symbol, s.name, l.ltp, l.pct_change
FROM securities s
LEFT JOIN (SELECT symbol, ltp, pct_change FROM live_data LATEST ON timestamp PARTITION BY symbol) l
ON l.symbol = s.symbol
ORDER BY s.symbol;
Result:
| symbol (SYMBOL) | name (STRING) | ltp (DOUBLE) | pct_change (DOUBLE) |
|---|---|---|---|
| ACME | Acme Investment Company | NULL | NULL |
| CITY | City Bank Limited | 527.1 | 2.71 |
| COAST | Coastal Bank Limited | 401.9 | 0.7 |
| TELCO | Northwind Telecom Limited | 897.7 | 1.9 |
| VOLT | Volt River Hydropower Limited | 184 | -0.86 |
(5 rows)
Example: Each trade as a share of the symbol's total quantity of the day (sample data: sample)
SELECT f.transaction_id, f.symbol, f.quantity, t.total,
round(f.quantity * 100.0 / t.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) t
ON t.symbol = f.symbol
WHERE f.date IN '2026-09-10' AND f.symbol = 'CITY'
ORDER BY pct_of_day DESC;
Result:
| transaction_id (LONG) | symbol (SYMBOL) | quantity (LONG) | total (LONG) | pct_of_day (DOUBLE) |
|---|---|---|---|---|
| 100018 | CITY | 500 | 910 | 54.9 |
| 100029 | CITY | 200 | 910 | 22 |
| 100024 | CITY | 100 | 910 | 11 |
| 100010 | CITY | 50 | 910 | 5.5 |
| 100022 | CITY | 20 | 910 | 2.2 |
| 100027 | CITY | 20 | 910 | 2.2 |
| 100030 | CITY | 20 | 910 | 2.2 |
(7 rows)
Example: Account balances with the account names (sample data: query-app)
SELECT a.code, a.name, b.balance
FROM accounts a
JOIN (SELECT account, coalesce(sum(debit), 0) - coalesce(sum(credit), 0) AS balance
FROM ledger GROUP BY account) b
ON b.account = a.code
WHERE a.type = 'asset'
ORDER BY a.code;
Result:
| code (INT) | name (STRING) | balance (DECIMAL(2)) |
|---|---|---|
| 1000 | Cash | 5000.00 |
| 1100 | Bank - City | 230320.00 |
| 1200 | Shares at cost | 247200.00 |
(3 rows)
Example: Without an alias it still works, but the columns have no name to use (sample data: sample)
SELECT b.id, b.name, count(*) AS trades
FROM brokers b JOIN (SELECT buyer FROM trade_log) ON buyer = b.id
GROUP BY b.id, b.name
ORDER BY b.id;
Result:
| id (INT) | name (STRING) | trades (LONG) |
|---|---|---|
| 1 | Keystone Securities | 9 |
| 34 | Vision Securities | 5 |
| 45 | Imperial Securities | 6 |
| 49 | Online Securities | 6 |
| 58 | Northgate Securities | 6 |
(5 rows)