KDB knowledge

Performance tips

Make loading and queries fast - memory, threads, batch commits, bulk INSERTs, good table design, time filters, what the column engine takes, zone maps, saved stats, and how to measure.

KDB is fast for the work it was made for: chart queries on one symbol or one sensor, live commits, bulk loads. Some queries that read every row of a big table are still slow. This page tells you how to avoid them, and how to measure.

The first rule: measure first. Look at the time KDB prints ((3 rows, 0.4 ms)) and at EXPLAIN, then change one thing at a time.

Measure: timings and EXPLAIN

Syntax:

kdb sql <db> "SELECT ..."        -- table format ends with (N rows, X ms)
EXPLAIN SELECT ...                -- how KDB will read the data

Command line: The time is on the last line (sample data: sample)

kdb sql market "SELECT symbol, round(avg(close), 2) AS avg_close FROM ochl_minutes WHERE time IN '2026-09-10' GROUP BY symbol"

Output:

symbol | avg_close
-------+----------
CITY   |    504.24
COAST  |    378.36
(2 rows, 0.6 ms)

Example: EXPLAIN: a time filter and a symbol read only one key range (sample data: sample)

EXPLAIN SELECT time, close FROM ochl_minutes WHERE symbol = 'CITY' AND time IN '2026-09-10';

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
read only 1 key range(s) of each partition
build 3 of 7 stored column(s) per row: time, symbol, close
filter while scanning: 2 condition(s)
engine: column

(5 rows)

Example: EXPLAIN without filters: every row is read (sample data: sample)

EXPLAIN SELECT count(*) FROM ochl_minutes WHERE close > 500;

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
build 2 of 7 stored column(s) per row: time, close
filter while scanning: 1 condition(s)
group by 0 key(s), 1 aggregate(s)
engine: column

(5 rows)

EXPLAIN lines to look for

Line in EXPLAINWhat it tells you
scan table t (k of n partitions, ...)Only k files are read. A small k means the time filter works.
..., m skipped by column stats)Zone maps left out m more files by their min and max.
answer from saved stats of table t: ...count, sum, min, max come from saved stats. No row is read.
read only k key range(s) of each partitionsymbol = ... on the first sort column reads only those rows.
use index name (...)An index finds the rows.
build k of n stored column(s) per row: ...The row path builds only these columns (column mask).
aggregates pushed into the N UNION ALL partsA GROUP BY over a UNION ALL is grouped part by part (many groups).
join (nested loop) ...The join has no a = b condition: every row meets every row. Slow on big tables.

All lines are explained in EXPLAIN.

Filter on time and symbol first

Syntax:

WHERE symbol = 'CITY' AND time IN '2026-09'
WHERE symbol IN ('CITY', 'COAST') AND time >= '2026-09-01' AND time < '2026-10-01'

On a time-series table, a condition on the time column skips whole partitions (files), and symbol = ... or symbol IN (...) on the first key column jumps straight to those rows.

Example: One symbol, one day: fast even on 20 million rows (sample data: sample)

SELECT time, open, high, low, close FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
LIMIT 5;

Result:

time (TIMESTAMP)open (DOUBLE)high (DOUBLE)low (DOUBLE)close (DOUBLE)
2026-09-10T05:15:00.000Z504.6504.6504.4504.4
2026-09-10T05:16:00.000Z504.4504.7504.3504.6
2026-09-10T05:17:00.000Z504.6504.9504504.2
2026-09-10T05:18:00.000Z504.2504.6504.1504.4
2026-09-10T05:19:00.000Z504.4504.8504.1504.5

(5 rows)

Parameters, variables and computed dates

Syntax:

WHERE time >= d AND time < dateadd('d', 1, d)     -- d: a procedure parameter or variable
WHERE time >= dateadd('d', -30, '2026-09-11')

A procedure parameter, a variable, a $1 value and an expression without columns skip partitions just like a written date.

Example: A computed start date still reads only the days it needs (sample data: sample)

EXPLAIN SELECT count(*) FROM ochl_minutes
WHERE time >= dateadd('d', -1, '2026-09-11') AND time < '2026-09-11';

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
build 1 of 7 stored column(s) per row: time
filter while scanning: 2 condition(s)
group by 0 key(s), 1 aggregate(s)
engine: column

(5 rows)

Example: A date cast compared with a date is a range of the time column (sample data: sample)

EXPLAIN SELECT count(*) FROM ochl_minutes WHERE time::date = DATE '2026-09-10';

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
build 1 of 7 stored column(s) per row: time
filter while scanning: 1 condition(s)
group by 0 key(s), 1 aggregate(s)
engine: column

(5 rows)

Example: A procedure parameter reads one day, like a written date (sample data: sample)

CREATE PROCEDURE minutes_of(day TIMESTAMP) AS BEGIN
  SELECT count(*) AS minutes FROM ochl_minutes WHERE time >= day AND time < dateadd('d', 1, day);
END;
CALL minutes_of('2026-09-10') WITH DEBUG;

Result:

CREATE PROCEDURE minutes_of(day TIMESTAMP) AS BEGIN

procedure 'minutes_of' created

CALL minutes_of('2026-09-10') WITH DEBUG

procedure 'minutes_of' done: 0 row(s) changed

SELECT count(*) AS minutes FROM ochl_minutes WHERE time >= day AND time < dateadd('d', 1, day)

minutes (LONG)
476

(1 row)

debug

step (LONG)routine (STRING)statement (STRING)rows (LONG)time_us (LONG)changes (STRING)
1CALL minutes_of('2026-09-10') WITH DEBUG0612partitions read: 1
2minutes_ofSELECT count(*) AS minutes FROM ochl_minutes WHERE time >= day AND time < dateadd('d', 1, day)1570partitions read: 1

(2 rows)

LIMIT

Syntax:

SELECT ... LIMIT n
SELECT ... ORDER BY col DESC LIMIT n

Example: The 3 biggest minute volumes of one day (sample data: sample)

SELECT time, symbol, volume FROM ochl_minutes
WHERE time IN '2026-09-10'
ORDER BY volume DESC
LIMIT 3;

Result:

time (TIMESTAMP)symbol (SYMBOL)volume (DOUBLE)
2026-09-10T06:02:00.000ZCOAST1490
2026-09-10T07:22:00.000ZCOAST1490
2026-09-10T07:21:00.000ZCITY1480

(3 rows)

LATEST ON with a time filter

Syntax:

SELECT ... FROM live_data WHERE timestamp >= '<today>' LATEST ON timestamp PARTITION BY symbol

LATEST ON gives the newest row per symbol. It reads every row in the time range (column by column, on several threads), so it gets slower as the table grows.

Example: The newest price per symbol, reading only one day (sample data: sample)

SELECT symbol, timestamp, ltp FROM live_data
WHERE timestamp >= '2026-09-10'
LATEST ON timestamp PARTITION BY symbol;

Result:

symbol (SYMBOL)timestamp (TIMESTAMP)ltp (DOUBLE)
CITY2026-09-10T09:14:00.000Z527.1
COAST2026-09-10T09:14:00.000Z401.9
TELCO2026-09-10T09:14:00.000Z897.7
VOLT2026-09-10T09:14:00.000Z184

(4 rows)

Bulk INSERT: many rows per statement

Syntax:

INSERT [OR REPLACE | OR IGNORE] INTO table VALUES (...), (...), (...), ...

Every statement is one commit: one write to the log and one wait for the disk. So many rows in one statement are much faster than one statement per row.

WayRows per second on the test PC (about)
One INSERT per row, one HTTP request eachabout 1,300
INSERT with 100 rows per requestabout 35,000
INSERT with 2,000 to 20,000 rows per statementabout 110,000 to 160,000
Line protocol (POST /write), many lines per requestthe fastest way

Example: Three rows in one statement: one commit (sample data: sample)

INSERT OR REPLACE INTO ochl_day VALUES
  ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000),
  ('2026-09-13', 'COAST', 376.0, 380.0, 375.2, 379.5, 120500),
  ('2026-09-13', 'VOLT', 191.0, 193.4, 190.1, 192.8, 40200);

Result:

3 row(s) inserted

Many small writes: --sync-commits batch

Syntax:

kdb serve [<db>] --sync-commits always|batch

By default (always) KDB waits until each commit is on the disk before it answers. That is safe, but a disk can do only a few hundred to a few thousand such waits per second. Writers that commit at the same moment share one wait, so many clients together get many more commits per second than one client.

One wait is the disk's own flush: about 2-5 ms on a laptop SSD, on Docker Desktop and on many cloud disks. PostgreSQL waits for the same flush. When a few clients commit in a loop, KDB waits a moment (at most half a flush) for the ones that are about to commit, so one flush carries all of them instead of two flushes carrying half each. Measured, UPDATE ... SET n = n + 1 over HTTP: 2 to 8 clients get 1.2 to 1.7 times more commits a second than before; one client and 16 or more are unchanged.

With --sync-commits batch KDB answers at once and writes all waiting commits to the disk together, every 10 ms.

Command line: A server for a live feed (sample data: sample)

kdb serve ./market --sync-commits batch

Output:

KDB 0.2.0-dev
  console   http://127.0.0.1:9900
  data      ./market
  backups   ./_backups/market (from default)
  access    no token
  capacity  32 HTTP threads (default), 500 PostgreSQL connections (default), lock wait 5 s (default)
  lanes     reads using 100 ms of CPU (three times in a row) wait for one of 16 heavy slots, at most 16 HTTP in line (--lanes on)
  reads     32 reads run at once (the CPUs, at least 2; follows the CPU limit), the rest wait in line (--read-slots)
  commits   synced every 10 ms (--sync-commits batch)
  time zone Asia/Katmandu (the computer's); also timers and backup schedules
  readers   read while a long trigger or CALL writes (--reader-yield on); idle open transaction rolled back after 30 s (default)
  memory    26.95 GB for the whole process (85% of the machine's memory); table data 512.0 MB of it (--memory)
  cpu       32 CPUs (the machine's); 16 query threads (half of the CPUs, follows the limit), limits checked every 10 s
  limits    statement timeout 1 min, result memory 1.00 GB, 5000000 groups, 32 statements per user
  safety    writes allowed; DROP TABLE kept 7 days (UNDROP TABLE); audit log off
  logs      slow queries >= 1000 ms to ./market/_logs/slow_queries.jsonl; pg_stat_statements on; metrics at /metrics
  recovery  manifest copy: a, last shutdown: clean
  open      opened in 3 ms: manifest 1 ms, 1 files read 0 ms (8 table files left for their first use), log check 1 ms, replay 0 ms (0 transactions), clean-up 1 ms

Press Ctrl+C to stop.

One session only: SET synchronous_commit = off

Syntax:

SET synchronous_commit = off | on          one session (HTTP session or PostgreSQL connection)
SET LOCAL synchronous_commit = off         this transaction only (PostgreSQL connection)
X-KDB-Synchronous-Commit: off              one HTTP request

The same trade as batch, but only for the writes that can take it - a rate-limit counter, a cache row - while every other session keeps always. No restart is needed. It works as in PostgreSQL:

One table: WITH (synchronous_commit = off)

Syntax:

CREATE TABLE t (...) WITH (synchronous_commit = on | off)
ALTER TABLE t SET (synchronous_commit = on | off)
ALTER TABLE t RESET (synchronous_commit)

The same trade, chosen once for a table instead of per session. A commit that changes only such tables is confirmed without waiting for the disk; the disk follows within about 10 ms, with the same risk as above. A commit that also changes a table that waits - an account, a user, an order - waits for the disk as before, so money and users are never confirmed early.

TableWithout the option
a cache table (WITH (max_bytes = ...), evict, durability)answers before the flush
the key-value store's _kv and _kv_itemsanswers before the flush (Redis's appendfsync everysec); CONFIG SET appendfsync always sets on (key-value)
every other tablewaits for the flush

Example: Counters that need not wait for the disk, beside a ledger that must (sample data: sample)

Setup:

CREATE TABLE page_hits (page STRING PRIMARY KEY, n LONG) WITH (synchronous_commit = off);
CREATE TABLE ledger (id LONG PRIMARY KEY, amount DECIMAL(2));
INSERT INTO page_hits VALUES ('/home', 1);
SHOW CREATE TABLE page_hits;

Result:

create_sql (STRING)
CREATE TABLE page_hits ( page STRING PRIMARY KEY, n LONG ) WITH (synchronous_commit = off)

(1 row)

Many writes in one go: one disk wait for all

Each commit that waits for the disk costs one flush, so a client that sends one counter update per request is held to a few hundred a second. Send several writes together and they share one flush, with no loss of safety:

ClientSendOne disk wait for
HTTPseveral statements in one POST /exec (each commits on its own), or with atomic=1 (one transaction)the whole request
PostgreSQL porta pipeline: several Executes, then one Sync (pgx SendBatch, JDBC executeBatch, psycopg pipeline mode)the whole pipeline, before ReadyForQuery

Each statement of a pipeline still commits on its own, as before; what changed is that they wait for the disk together, at the Sync (since 2026-09-25). Measured on a busy laptop, counter updates (UPDATE ... SET n = n + 1 ... RETURNING n), one connection, 32 per batch (node bench/counter.mjs):

Pipeline of 32, PostgreSQL portbeforenow
1 connection211 a second4,277 a second
16 connections2,25310,443

A Sync after every Execute still waits for each one, as PostgreSQL does. For single writes that may lose the last 10 ms in a power cut, use synchronous_commit = off (above).

Memory: --memory

Syntax:

kdb serve [<db>] --memory <MB>
kdb sql <db> "..." --memory <MB>
kdb shell [<db>] --memory <MB>

--memory is the memory for table data that KDB keeps loaded, for all open databases together. The default is 512 MB. Data over the budget is unloaded and read from disk again when needed.

What the budget counts, and what it does not:

MemoryLimited byFreed
Saved rows of loaded partitions--memoryoldest use first, when over the budget
Lookup maps of DEDUP KEY / PRIMARY KEY for those partitions--memorywith the partition
Column copies of changed rows (for fast scans)--memoryat the checkpoint, or when over the budget
Changes not yet written to partition filesa checkpoint starts at a quarter of --memory per databaseat the checkpoint
Kept query results--cache-mb (all databases together)oldest first
CREATE INDEX indexesnot limited (see indexes)DROP INDEX
The rows of a statement while it runs (a big INSERT, a /write with triggers, a big result)the size of the requestwhen it ends

So the memory of the process is about --memory + --cache-mb + a quarter of --memory for each database being written + indexes + the requests running now + about 30 MB for the program itself. GET /api/stats shows every part (memory of each database) next to resident_bytes.

Command line: More memory for a big history (sample data: sample)

kdb serve ./market --memory 4096

Output:

KDB 0.2.0-dev
  console   http://127.0.0.1:9900
  data      ./market
  backups   ./_backups/market (from default)
  access    no token
  capacity  32 HTTP threads (default), 500 PostgreSQL connections (default), lock wait 5 s (default)
  lanes     reads using 100 ms of CPU (three times in a row) wait for one of 16 heavy slots, at most 16 HTTP in line (--lanes on)
  reads     32 reads run at once (the CPUs, at least 2; follows the CPU limit), the rest wait in line (--read-slots)
  commits   synced to disk before they are confirmed (--sync-commits always)
  time zone Asia/Katmandu (the computer's); also timers and backup schedules
  readers   read while a long trigger or CALL writes (--reader-yield on); idle open transaction rolled back after 30 s (default)
  memory    26.95 GB for the whole process (85% of the machine's memory); table data 4.00 GB of it (--memory)
  cpu       32 CPUs (the machine's); 16 query threads (half of the CPUs, follows the limit), limits checked every 10 s
  limits    statement timeout 1 min, result memory 1.00 GB, 5000000 groups, 32 statements per user
  safety    writes allowed; DROP TABLE kept 7 days (UNDROP TABLE); audit log off
  logs      slow queries >= 1000 ms to ./market/_logs/slow_queries.jsonl; pg_stat_statements on; metrics at /metrics
  recovery  manifest copy: a, last shutdown: clean
  open      opened in 2 ms: manifest 0 ms, 1 files read 1 ms (8 table files left for their first use), log check 0 ms, replay 0 ms (0 transactions), clean-up 1 ms

Press Ctrl+C to stop.

Memory per database and resident tables

When several apps share one server, a big scan in one database can unload the tables another database reads all the time. Three settings stop that:

SettingWhat it does
ALTER DATABASE d SET MEMORY_LIMIT = '512MB'd holds at most this much loaded data. Over it, d unloads its own partitions first, even when --memory has room.
ALTER DATABASE d SET MEMORY_RESERVED = '128MB'this much of d's loaded data is never unloaded for another database or for the process memory limit.
ALTER TABLE t SET (resident = on)the table's partitions stay loaded once read, whoever needs memory. They still count against the database's MEMORY_LIMIT.

Big GROUP BY over a UNION

INSERT INTO daily SELECT ... FROM (SELECT ... FROM a UNION ALL SELECT ... FROM b) u GROUP BY ... does not keep the rows of the UNION ALL in memory: when every part is a plain read of one table (no GROUP BY, DISTINCT, ORDER BY or LIMIT inside), the rows go straight into the grouping, part after part. A view of that shape works the same way.

Threads: --threads

Syntax:

kdb serve [<db>] --threads N

Big queries and big writes use several threads. The default, and the most, is half of the CPU threads, so KDB never takes the whole computer.

Command line: Leave CPU for other programs (sample data: sample)

kdb serve ./market --threads 1

Output:

KDB 0.2.0-dev
  console   http://127.0.0.1:9900
  data      ./market
  backups   ./_backups/market (from default)
  access    no token
  capacity  32 HTTP threads (default), 500 PostgreSQL connections (default), lock wait 5 s (default)
  lanes     reads using 100 ms of CPU (three times in a row) wait for one of 16 heavy slots, at most 16 HTTP in line (--lanes on)
  reads     32 reads run at once (the CPUs, at least 2; follows the CPU limit), the rest wait in line (--read-slots)
  commits   synced to disk before they are confirmed (--sync-commits always)
  time zone Asia/Katmandu (the computer's); also timers and backup schedules
  readers   read while a long trigger or CALL writes (--reader-yield on); idle open transaction rolled back after 30 s (default)
  memory    26.95 GB for the whole process (85% of the machine's memory); table data 512.0 MB of it (--memory)
  cpu       32 CPUs (the machine's); 1 query thread (--threads), limits checked every 10 s
  limits    statement timeout 1 min, result memory 1.00 GB, 5000000 groups, 32 statements per user
  safety    writes allowed; DROP TABLE kept 7 days (UNDROP TABLE); audit log off
  logs      slow queries >= 1000 ms to ./market/_logs/slow_queries.jsonl; pg_stat_statements on; metrics at /metrics
  recovery  manifest copy: a, last shutdown: clean
  open      opened in 2 ms: manifest 1 ms, 1 files read 0 ms (8 table files left for their first use), log check 0 ms, replay 0 ms (0 transactions), clean-up 1 ms

Press Ctrl+C to stop.

Result cache: --cache-mb

Syntax:

kdb serve [<db>] --cache-mb <MB>

kdb serve keeps the results of queries in memory (default 256 MB for all open databases together). The same query again gets the kept result at once, as long as no table it reads has changed.

SET result_cache = off;
SELECT symbol, sum(volume) FROM ochl_day GROUP BY symbol;  -- computed every time
SET result_cache_now_ms = 1000;
SELECT count(*) AS n, max(now()) AS asked_at FROM ochl_day;  -- an answer up to 1 s old may come from the cache

Command line: A bigger result cache for a dashboard (sample data: sample)

kdb serve ./market --cache-mb 256

Output:

KDB 0.2.0-dev
  console   http://127.0.0.1:9900
  data      ./market
  backups   ./_backups/market (from default)
  access    no token
  capacity  32 HTTP threads (default), 500 PostgreSQL connections (default), lock wait 5 s (default)
  lanes     reads using 100 ms of CPU (three times in a row) wait for one of 16 heavy slots, at most 16 HTTP in line (--lanes on)
  reads     32 reads run at once (the CPUs, at least 2; follows the CPU limit), the rest wait in line (--read-slots)
  commits   synced to disk before they are confirmed (--sync-commits always)
  time zone Asia/Katmandu (the computer's); also timers and backup schedules
  readers   read while a long trigger or CALL writes (--reader-yield on); idle open transaction rolled back after 30 s (default)
  memory    26.95 GB for the whole process (85% of the machine's memory); table data 512.0 MB of it (--memory)
  cpu       32 CPUs (the machine's); 16 query threads (half of the CPUs, follows the limit), limits checked every 10 s
  limits    statement timeout 1 min, result memory 1.00 GB, 5000000 groups, 32 statements per user
  safety    writes allowed; DROP TABLE kept 7 days (UNDROP TABLE); audit log off
  logs      slow queries >= 1000 ms to ./market/_logs/slow_queries.jsonl; pg_stat_statements on; metrics at /metrics
  recovery  manifest copy: a, last shutdown: clean
  open      opened in 2 ms: manifest 1 ms, 1 files read 0 ms (8 table files left for their first use), log check 0 ms, replay 0 ms (0 transactions), clean-up 1 ms

Press Ctrl+C to stop.

What the column engine takes

Syntax:

WHERE part AND part AND ...     -- every part must be one of the shapes below

KDB has two ways to run a query on one table: the column engine (whole columns at a time, on all threads) and the row path (row by row, one thread). The column engine takes a WHERE only when every part joined by AND is one of these:

WHERE partExample
a column against a value: =, !=, <, <=, >, >=volume > 1000, symbol = 'CITY'
BETWEEN and NOT BETWEEN two valuesclose BETWEEN 500 AND 510
IN and NOT IN a list of valuessymbol IN ('CITY', 'COAST')
OR of = (or IN) on the same columnsymbol = 'CITY' OR symbol = 'COAST'
LIKE, ILIKE, NOT LIKE on a SYMBOL or STRING columnsymbol LIKE 'C%'
time IN '...' on a TIMESTAMP or DATE columntime IN '2026-09-10'
IS NULL, IS NOT NULLclose IS NOT NULL
a column against a column: two whole-number columns, two DATE, two TIMESTAMP, two FLOAT/DOUBLE; two SYMBOL columns with = or !=close > open, buyer = seller
NOT, OR and AND of the shapes above, nested any wayNOT (volume <= 0 AND high = low AND open = close), symbol = 'CITY' OR volume > 1000
one member of a JSON column against text: =, !=, <, <=, >, >=extra_data->>'category' = 'auth'
a TIMESTAMP column against date_trunc of a TIMESTAMP column: = and !=, also with a time zonetime = date_trunc('day', time) (daily bars only: 2.7 ms became 0.8 ms, 2026-09-25)

These send the query to the row path:

What the newer shapes gave, 1,000,000 rows, one thread, before and after they came into the column engine (TASKS.md, 2026-09-23):

QueryBeforeAfter
sym LIKE 'S6%'242 ms1.2 ms
sym ILIKE ...230 ms2.2 ms
sum(qty) WHERE sym = 'S1' OR sym = 'S2'151 ms0.30 ms
random whole numbers, x > L4.68 ms0.67 ms
b BETWEEN ...8.77 ms0.84 ms

NOT, OR and AND trees (2026-09-24, 8.4 million minute bars of 20 symbols stored by symbol, all threads; reports/speed-trees-or-index-agg-expr-2026-09-24.md):

QueryBeforeAfter
a chart read: symbol = 'NABIL-1' AND NOT (volume <= 0 AND high = low AND open = close) ORDER BY time DESC LIMIT 300189 ms5.3 ms
the same, LIMIT 5000256 ms35 ms
count(*) WHERE symbol = ... AND (volume IS NULL OR volume > 550 OR (open = close AND high = low))129 ms1.7 ms

Example: LIKE, BETWEEN and an OR on one column: all in the column engine (sample data: sample)

SELECT count(*) AS minutes, sum(volume) AS volume FROM ochl_minutes
WHERE (symbol = 'CITY' OR symbol = 'COAST') AND symbol LIKE 'C%' AND close BETWEEN 400 AND 600;

Result:

minutes (LONG)volume (DOUBLE)
480365430

(1 row)

Select only the columns you need

Syntax:

SELECT time, close FROM ...      -- builds 2 columns per row
SELECT * FROM ...                -- builds every column

When a query runs on the row path, KDB builds only the columns the query reads. EXPLAIN shows them: build 2 of 7 stored column(s) per row: time, close.

Example: The row path builds only the columns the query reads (sample data: sample)

EXPLAIN SELECT time, close FROM ochl_minutes WHERE close * 1 > 500;

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
build 2 of 7 stored column(s) per row: time, close
filter while scanning: 1 condition(s)
engine: row (a condition the column engine does not do)

(4 rows)

count, sum, min, max of a whole table

Syntax:

SELECT count(*), sum(x), min(x), max(x), avg(x) FROM t [WHERE <time range>]

Every partition file keeps its row count and, per column, its count, sum, min and max. When a query asks only for these, with no WHERE or only a time range, KDB answers from those stats. It reads no rows, and loads no partition that is not loaded yet.

Example: A whole-table aggregate answered from the files' stats (sample data: sample)

EXPLAIN SELECT count(*), sum(volume), min(low), max(high) FROM ochl_minutes;

Result:

plan (STRING)
scan table ochl_minutes (1 of 1 partitions, about 956 rows)
answer from saved stats of table ochl_minutes: 1 of 1 partitions from the partition list and their files' stats (0 in memory, 0 partitions loaded, 1 file headers read); 0 partitions scanned
build 4 of 7 stored column(s) per row: time, high, low, volume
group by 0 key(s), 4 aggregate(s)
engine: column

(5 rows)

Zone maps: files skipped by min and max

Syntax:

WHERE col > value | col BETWEEN a AND b | col IN (...) | time IN '...'

For a number, DATE, TIMESTAMP or DECIMAL column, KDB knows the smallest and the biggest value in each partition file. A partition whose range cannot hold a match is skipped before any row is read.

Example: Trade ids grow with time: the first day cannot hold ids above 100008 (sample data: sample)

SELECT count(*) AS trades FROM trade_log;
EXPLAIN SELECT count(*) FROM trade_log WHERE transaction_id > 100008;

Result:

SELECT count(*) AS trades FROM trade_log

trades (LONG)
32

(1 row)

EXPLAIN SELECT count(*) FROM trade_log WHERE transaction_id > 100008

plan (STRING)
scan table trade_log (1 of 2 partitions, about 32 rows, 1 skipped by column stats)
build 2 of 7 stored column(s) per row: date, transaction_id
filter while scanning: 1 condition(s)
group by 0 key(s), 1 aggregate(s)
engine: row (a condition, group key or aggregate the column engine does not do)

(5 rows)

One symbol of a partition not in memory

Syntax:

WHERE symbol = 'X'   -- on a table with SORT BY (symbol, time) (or symbol first in its key)

A month of minute candles for 500 symbols is one partition file of about 20 MB, and one symbol's rows are a few KB of it. When --memory is full and such a partition is not loaded, a query for one symbol now reads only that symbol's rows from the file. It does not load the whole file, and it pushes nothing else out of memory.

A scan now reads a single partition file outside the lock all readers share, as it already did for several at once, so readers of other partitions and tables do not wait for one reader's disk. Two readers that need the same file read it once.

Math inside sum(), avg(), min(), max()

Syntax:

sum(a + b)   sum(a - b)   sum(a * 2)   avg(close - open)   max(volume / 1000)

One +, -, * or / between two number columns, or a column and a number, runs as fast as a plain column.

Example: Math inside an aggregate (sample data: sample)

SELECT sum(volume * 2), avg(close - open), max(high - low) FROM ochl_minutes WHERE symbol = 'CITY';

Result:

sum(volume * 2) (DOUBLE)avg(close - open) (DOUBLE)max(high - low) (DOUBLE)
730860-0.01479166666666671.20000000000005

(1 row)

GROUP BY with many groups

Syntax:

SELECT key, key2, sum(a), sum(a) - sum(b) FROM t WHERE time_range GROUP BY key, key2

KDB has two ways to run a query: the column engine (whole columns at a time, on all threads) and the row engine (row by row). The column engine is 5 to 50 times faster. A GROUP BY stays in it, even with hundreds of thousands of groups, when it is written like this:

Part of the queryThe column engine takes
Group keysSYMBOL, STRING (VARCHAR, TEXT), whole-number, DATE and TIMESTAMP columns, up to 4 of them; date_trunc('day', time) and to_char(time, 'YYYY-MM-DD') of a time column, also with a time zone
Aggregatescount, sum, avg, min, max, first, last; count(DISTINCT col); sum(CASE WHEN a > b THEN x ELSE y END); math inside an aggregate
Result columnsthe keys, the aggregates, and expressions of aggregates only: sum(a) - sum(b), round(sum(a) / sum(b), 2), coalesce(sum(a), 0), CASE WHEN sum(q) > 0 THEN sum(a) / sum(q) ELSE 0 END
ORDER BYresult columns, by name, alias or position, and an aggregate that is also a result column (ORDER BY sum(q) DESC)
WHEREthe column engine shapes: a column against a value, IN, BETWEEN, LIKE, IS NULL, a column against a column of the same kind (buyer = seller, close > open), a JSON member against text (extra->>'category' = 'auth'), and NOT / OR / AND of them

These send the query, or its last step, back to the row engine:

A GROUP BY over a UNION ALL in FROM is grouped part by part: each part groups its own table in the column engine, and the outer query only adds up one row per group and part. This is the buy-and-sell shape of a floor sheet, and it costs the two legs instead of a row-by-row pass over every trade twice:

Syntax:

SELECT symbol, broker, sum(bq) AS buy_qty, sum(sq) AS sell_qty
FROM (SELECT symbol, buyer AS broker, quantity AS bq, 0 AS sq FROM trades WHERE time_range
      UNION ALL
      SELECT symbol, seller AS broker, 0 AS bq, quantity AS sq FROM trades WHERE time_range)
GROUP BY symbol, broker

EXPLAIN starts with aggregates pushed into the 2 UNION ALL parts when it applies. It applies to sum, count, min and max (with sum(a) - sum(b), HAVING, ORDER BY and LIMIT on the outer query, and an outer WHERE, which moves into every part) over parts that are plain SELECT ... FROM one_table [WHERE ...]. With avg, first, last, DISTINCT, a window function, or a part that joins, groups or limits by itself, the query runs as written.

Both engines give the same rows. Two small differences exist, and neither changes a stored value:

Example: Two keys, a CASE sum and arithmetic on aggregates: all in the column engine (sample data: sample)

SELECT symbol, date_trunc('day', time) AS day, sum(volume) AS volume,
       sum(CASE WHEN close > open THEN volume ELSE 0 END) AS up_volume,
       round(sum(volume * close) / sum(volume), 2) AS vwap
FROM ochl_minutes WHERE close > low GROUP BY symbol, day LIMIT 5;

Result:

symbol (SYMBOL)day (TIMESTAMP)volume (DOUBLE)up_volume (DOUBLE)vwap (DOUBLE)
CITY2026-09-09T00:00:00.000Z15203080580507.77
CITY2026-09-10T00:00:00.000Z15604090920504.28
COAST2026-09-09T00:00:00.000Z15510070190387.25
COAST2026-09-10T00:00:00.000Z16856089090378.39

(4 rows)

Table design: partitions, DEDUP KEY, SORT BY

Syntax:

CREATE TABLE ... TIMESERIES(time) PARTITION BY DAY|MONTH|YEAR SORT BY (symbol, time) DEDUP KEY (symbol, time)

How a table is made decides how fast it is. You cannot change these later without making a new table.

ChoiceAdvice
PARTITION BYOne partition should hold from about 100,000 to a few million rows. Minute candles of 500 symbols: MONTH. Daily candles: YEAR. Trades: DAY. Too small partitions (millions of tiny files) and too big ones (one file for 10 years) are both slow.
SORT BY / DEDUP KEYPut the column you filter on most first: (symbol, time). Then WHERE symbol = 'CITY' reads only CITY's rows in each partition.
DEDUP KEYMakes INSERT OR REPLACE and duplicate checks cheap. It must include the time column, so a duplicate is looked for only in the row's own partition. A write into a new day looks up nothing.
Deletes of old dataALTER TABLE ... DROP PARTITION removes a whole partition at once. DELETE of many rows is slower: the next checkpoint rewrites every partition it touched.

See partitions and dedup and CREATE TABLE.

SYMBOL columns

Syntax:

symbol SYMBOL

Use SYMBOL for short text that repeats a lot: stock symbols, sectors, broker codes, statuses.

Indexes

Syntax:

CREATE INDEX name ON table (column, ...)

A condition on a column that is not the first key column (WHERE thread_id = 7, WHERE email = '...') reads every row. An index makes it fast.

The line protocol for fast writes

Syntax:

POST /write?db=<name>&precision=s
<table>,<tag>=<value> <field>=<value>,... <time>

For live feeds and big loads, send rows in the InfluxDB line protocol to kdb serve instead of SQL. There is no SQL to parse, and big bodies are read on several threads.

Loads: what keeps the fast path

Syntax:

CREATE TABLE trades (...) TIMESERIES(time) PARTITION BY DAY SORT BY (symbol, time)     -- no DEDUP KEY
CREATE TRIGGER t AFTER INSERT ON trades DEFERRED EXECUTE BEGIN ... END

A big POST /write or a many-row INSERT is fastest when KDB can write the rows as sorted column runs, built before the table is locked. Some table features need every row on its own.

The table hasLoad path
a DEDUP KEY (or PRIMARY KEY)runs
no unique key at all (keyless)runs, appended: nothing is looked up or replaced
only DEFERRED triggersruns; the trigger runs after the commit
an INSERT trigger that is not deferredrow by row, with the trigger inside the write
REFERENCES, UNIQUE (other than the key), AUTO columns, a UNIQUE index, or other tables that REFERENCE itrow by row: each row is checked against other rows

Measured on 2026-09-23:

ChangeBeforeAfter
floor sheet without a DEDUP KEY written as runs (814,535 real trades)438,373 rows/s918,067 rows/s (QuestDB 677,540)
20,000-row load into a table without a trigger, while another table has one34 ms8 ms
20,000-row load into a table whose trigger is made DEFERRED103 ms9 ms

Slow parts KDB still has

Syntax:

reads of every row  |  one writer per table  |  big DELETEs  |  CHECK and BACKUP of everything

Being honest helps you plan. These are known (from bench/README.md, September 2026):

Slow partWhat to do
Queries that read every row of a big table (GROUP BY symbol over years, ORDER BY volume LIMIT 10 over months, count(*) with a filter on another column) take from tenths of a second to seconds. A plain count(*) or sum of the whole table is answered from saved stats.Add a time range. Keep summary tables (for example daily candles) and query those.
LATEST ON reads every row in the time range.Add a time filter, or keep a one-row-per-symbol table.
One writer per table. A long UPDATE or DELETE makes other writers of the same table wait; writers of other tables go on. While a BEGIN ... COMMIT is open, writers of its tables wait up to 5 seconds, then get table 'x' is busy.Keep transactions short. Split huge changes into parts. Put a live feed and a big load in different tables.
DELETE of many rows: the next checkpoint rewrites every partition that had deleted rows.Use DROP PARTITION for old data.
CHECK TABLE and backups read or copy the whole database.Run them after market hours.
With less --memory than the data, big queries read files again.Give --memory about the size of the data.
The first query after a start loads files (and builds an index the warm-up thread has not reached yet).Expect a slower first query; later ones are warm.
Opening a data folder with many thousands of partition files: a 9.5 GB folder with 11,104 partitions took 124 to 233 s, one file open per partition (TASKS.md).Keep partitions big enough (see table design); drop old ones with DROP PARTITION.
Every HTTP request prints a line in the server's window.With thousands of requests per second, send the server's output to a file.

What the test PC measured (medium = 2 million minute rows, large = 20 million):

BenchmarkMediumLarge, --memory 4096
bulk load, one INSERT of 2,000 rows12.6 ms12.7 ms
live commit of 527 rows (one row per symbol)6.4 ms7.7 ms
one symbol, one year of minutes as 1-hour candles1.9 ms3.8 ms
LATEST ON for 527 symbols13 ms25 ms
count + sum over all minute rows186 ms1.4 s
open the database after a clean close95 ms74 ms
backup (copy and check)0.86 s4.3 s