KDB knowledge

17. Indexes and search

What you will learn

All examples are in examples/17-indexes-and-search/. The output under each example is real.

Why indexes

Every table has a storage key: the PRIMARY KEY, or SORT BY / DEDUP KEY of a time-series table (chapter 3). Rows are kept in the order of this key. So WHERE id = 5 finds the row at once.

A condition on another column is different. WHERE thread_id = 7 must read every row of the table and check each one. With 10 rows this is fast. With 1 million forum posts it is slow.

An index is a second, sorted list of one or more columns. Each entry points to its row. With an index on thread_id, KDB jumps to the posts of thread 7 and reads only those.

Without indexWith index
Reads every rowReads only the rows it needs
1 million posts: tens of milliseconds, more with long text1 million posts: about 0.05 ms
Writes are a little fasterEvery write also changes the index

Create an index for the columns your app uses in WHERE and ORDER BY again and again. Do not create indexes you do not use.

Where the index lives

An index is kept in memory. KDB does not write it to disk:

Step 1: the forum tables

01-forum-schema.sql:

CREATE TABLE threads (
  id LONG PRIMARY KEY,
  title STRING NOT NULL,
  created_at TIMESTAMP NOT NULL
);

CREATE TABLE posts (
  id LONG PRIMARY KEY,
  thread_id LONG NOT NULL REFERENCES threads(id),
  parent_id LONG,
  author STRING NOT NULL,
  body STRING NOT NULL,
  created_at TIMESTAMP NOT NULL
);

-- Pages of a thread, newest first.
CREATE INDEX posts_thread_time ON posts (thread_id, created_at, id);
-- The replies of a post.
CREATE INDEX posts_parent ON posts (parent_id);
-- Posts of one author.
CREATE INDEX posts_author ON posts (author);
table 'threads' created

table 'posts' created

index 'posts_thread_time' created on table 'posts' (0 rows)

index 'posts_parent' created on table 'posts' (0 rows)

index 'posts_author' created on table 'posts' (0 rows)

The syntax:

CREATE [UNIQUE | FULLTEXT] INDEX [IF NOT EXISTS] name ON table (column, ...);
DROP INDEX [IF EXISTS] name;
SHOW INDEXES [FROM table];

Index names are unique in the whole database. A good habit: table_columns, for example posts_thread_time.

Then some posts: 02-forum-data.sql.

Step 2: pages of a thread

A forum shows a thread in pages of 20 posts, newest first. Here the pages have 3 posts, so the example stays short.

03-thread-pages.sql:

-- Page 1 of thread 1: the 3 newest posts.
SELECT id, author, created_at FROM posts
WHERE thread_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 3;

-- Page 2: the 3 posts older than the last row of page 1 (created_at and id of post 4).
SELECT id, author, created_at FROM posts
WHERE thread_id = 1 AND (created_at, id) < ('2026-09-01T11:00:00+05:45', 4)
ORDER BY created_at DESC, id DESC
LIMIT 3;
id | author | created_at
---+--------+-------------------------
 6 | sita   | 2026-09-01T06:15:00.000Z
 5 | ram    | 2026-09-01T05:45:00.000Z
 4 | gita   | 2026-09-01T05:15:00.000Z
(3 rows, 0.4 ms)

id | author | created_at
---+--------+-------------------------
 3 | hari   | 2026-09-01T04:35:00.000Z
 2 | sita   | 2026-09-01T04:20:00.000Z
 1 | ram    | 2026-09-01T04:15:00.000Z
(3 rows, 0.1 ms)

How it works:

  1. The index (thread_id, created_at, id) already has the posts of thread 1 sorted by time. KDB reads them from the newest end and stops after 3 rows. It does not read the other posts, not even the other posts of the thread.
  2. For the next page, the app remembers created_at and id of the last row it showed (post 4). The row comparison (created_at, id) < ('...', 4) means: older than post 4, or the same time and a smaller id. KDB starts reading the index right there.

This is called keyset pagination. Page 1000 is as fast as page 1.

Why not LIMIT 20 OFFSET 20000? OFFSET must still count the 20,000 rows before the page. It gets slower on every page. Use OFFSET only for small tables.

Why id in the index and in ORDER BY? Two posts can have the same created_at. id makes the order exact, so no post is shown twice or skipped between pages.

Step 3: replies and authors

04-replies.sql:

-- Posts that start a discussion (not replies) in thread 1.
SELECT id, author, body FROM posts WHERE thread_id = 1 AND parent_id IS NULL ORDER BY created_at;

-- The replies to post 1.
SELECT id, author, body FROM posts WHERE parent_id = 1 ORDER BY created_at;

-- How many posts each author wrote in thread 1.
SELECT author, count(*) AS posts FROM posts WHERE thread_id = 1 GROUP BY author ORDER BY author;
id | author | body
---+--------+------------------------------------
 1 | ram    | CITY dividend looks good this year.
 4 | gita   | What about the bonus shares?
 6 | sita   | सिटी बैंकको नाफा राम्रो छ।
(3 rows, 0.4 ms)

id | author | body
---+--------+-----------------------------------
 2 | sita   | I agree, the profit grew 18%.
 3 | hari   | Price is high now. Wait for a dip.
(2 rows, 0.1 ms)

author | posts
-------+------
gita   |     1
hari   |     1
ram    |     2
sita   |     2
(4 rows, 0.1 ms)

Step 4: does my query use an index?

Put EXPLAIN before the query. 05-explain.sql (CSV output, so the lines are not cut):

EXPLAIN SELECT id FROM posts WHERE thread_id = 1 ORDER BY created_at DESC, id DESC LIMIT 20;
EXPLAIN SELECT id FROM posts WHERE parent_id = 1;
-- No index on body: the table is read.
EXPLAIN SELECT id FROM posts WHERE body = 'Which ones?';
plan
"scan table posts (1 of 1 partitions, about 8 rows)"
"use index posts_thread_time (thread_id = value, rows in ORDER BY order, stops after 20 row(s))"
"build 3 of 6 stored column(s) per row: id, thread_id, created_at"
filter while scanning: 1 condition(s)
sort by 2 key(s)
limit 20
plan
"scan table posts (1 of 1 partitions, about 8 rows)"
use index posts_parent (parent_id = value)
"build 2 of 6 stored column(s) per row: id, parent_id"
filter while scanning: 1 condition(s)
plan
"scan table posts (1 of 1 partitions, about 8 rows)"
"build 2 of 6 stored column(s) per row: id, body"
filter while scanning: 1 condition(s)

The line use index <name> says which index is used. No such line: every row is read. The line build 3 of 6 stored column(s) says that KDB reads only the columns the query needs, not the long body text of every post.

When KDB uses an index (plain index, columns (a, b, c)):

Your queryUses the index
a = 5, a IN (1, 2, 3), a IS NULLyes
a = 5 AND b = 7yes, both columns
a = 5 AND b > 100, b BETWEEN ..., b LIKE 'abc%'yes: a exactly, then a range of b
a = 5 ORDER BY b DESC LIMIT 20yes, and it stops after 20 rows
a = 5 AND (b, c) < (x, y) ORDER BY b DESC, c DESC LIMIT 20yes: keyset pagination
b = 7 aloneno: b is not the first column
a = 5 OR b = 7no: OR
lower(a) = 'x', a + 1 = 6only with an expression index on exactly that expression: CREATE INDEX ix ON t (lower(a))
a != 5, a NOT IN (...), a LIKE '%abc'no

Other rules:

Step 5: UNIQUE indexes

A UNIQUE index makes sure no two rows have the same values. Use it for email addresses, user names, or "one row per company per quarter".

06-unique.sql:

-- One account per email address.
CREATE TABLE users (id LONG PRIMARY KEY, email STRING NOT NULL, name STRING);
CREATE UNIQUE INDEX users_email ON users (email);
INSERT INTO users VALUES (1, 'ram@example.com', 'Ram'), (2, 'sita@example.com', 'Sita');
INSERT INTO users VALUES (3, 'ram@example.com', 'Another Ram');
table 'users' created

index 'users_email' created on table 'users' (0 rows)

2 row(s) inserted
error: duplicate value violates unique index 'users_email' on table 'users': a row with email='ram@example.com' already exists
[exit code 1]

LIKE '%bank%' reads every row and checks every letter. For thousands of news articles that is slow. A FULLTEXT index keeps a list of words: for each word, the articles that have it.

07-news-schema.sql:

CREATE TABLE news (
  id LONG PRIMARY KEY,
  symbol STRING,
  published_at TIMESTAMP NOT NULL,
  title STRING NOT NULL,
  body STRING
);

-- Search in the title and the body together.
CREATE FULLTEXT INDEX news_text ON news (title, body);
-- The newest news of a company.
CREATE INDEX news_symbol_time ON news (symbol, published_at);

INSERT INTO news VALUES
  (1, 'CITY', '2026-08-01T11:00:00+05:45', 'City Bank profit rises 18%',
      'City Bank reported a net profit of 3.27 billion dollars. The bank will pay a 12.5% dividend.'),
  (2, 'CITY', '2026-08-01T11:30:00+05:45', 'सिटी बैंकको नाफा बढ्यो',
      'सिटी बैंकले चौथो त्रैमासिकमा ३ अर्ब २७ करोड नाफा कमाएको छ। लाभांश १२.५ प्रतिशत।'),
  (3, 'VOLT', '2026-08-15T10:00:00+05:45', 'Hydropower company plans IPO',
      'Volt River Hydropower will sell new shares. The IPO opens next month.'),
  (4, NULL,    '2026-09-01T08:00:00+05:45', 'Market index closes at 2,840',
      'Banks and hydropower shares moved up. City Bank led the gainers.'),
  (5, 'COAST',  '2026-09-02T09:00:00+05:45', 'Coastal bank merger talks',
      'Coastal Bank is in talks about a merger. No loan changes are planned.');
table 'news' created

index 'news_text' created on table 'news' (0 rows)

index 'news_symbol_time' created on table 'news' (0 rows)

5 row(s) inserted

A FULLTEXT index works on STRING (and SYMBOL, JSON) columns. With several columns, they are searched together as one text.

How text is cut into words

RuleExample
Letters and digits of any language make wordsसिटी बैंकको नाफा is 3 words
English letters: case does not matterBank = bank = BANK
Punctuation separates words, also । and ॥छ। is the word छ
A number keeps its dot; a comma inside a number is dropped12.5 is one word; 1,234.5 is 1234.5
Devanagari digits are the same as 0-9१२.५ = 12.5
Words must be the same; there is no stemmingbank does not find banks; बैंक does not find बैंकले (use बैंक*)

Searching

08-search.sql:

-- Every word must be there (English letters: any case).
SELECT id, title FROM news WHERE MATCH(title, body, 'city bank') ORDER BY id;

-- The best matches first.
SELECT id, title, round(search_rank(title, body, 'bank'), 3) AS rank
FROM news
WHERE MATCH(title, body, 'bank')
ORDER BY rank DESC
LIMIT 3;

-- Nepali words; a number with a dot is one word (१२.५ is the same as 12.5).
SELECT id, title FROM news WHERE MATCH(title, body, 'नाफा') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, '12.5') ORDER BY id;

-- An exact phrase, a prefix, and a word that must not be there.
SELECT id FROM news WHERE MATCH(title, body, '"net profit"');
SELECT id FROM news WHERE MATCH(title, body, 'hydro*') ORDER BY id;
SELECT id FROM news WHERE MATCH(title, body, 'bank -merger') ORDER BY id;

-- Full-text search together with other conditions.
SELECT id, title FROM news WHERE MATCH(title, body, 'bank') AND published_at >= '2026-09-01' ORDER BY id;
id | title
---+-----------------------------
 1 | City Bank profit rises 18%
 4 | Market index closes at 2,840
(2 rows, 0.4 ms)

id | title                        | rank
---+------------------------------+------
 1 | City Bank profit rises 18%   | 0.806
 5 | Coastal bank merger talks    | 0.751
 4 | Market index closes at 2,840 | 0.562
(3 rows, 0.1 ms)

id | title
---+-----------------------
 2 | सिटी बैंकको नाफा बढ्यो
(1 row, 0.0 ms)

id
--
 1
 2
(2 rows, 0.0 ms)

id
--
 1
(1 row, 0.0 ms)

id
--
 3
 4
(2 rows, 0.0 ms)

id
--
 1
 4
(2 rows, 0.0 ms)

id | title
---+-----------------------------
 4 | Market index closes at 2,840
 5 | Coastal bank merger talks
(2 rows, 0.0 ms)

The search text:

You writeIt finds rows that
city bankhave both words, anywhere
"net profit"have these words next to each other, in this order (not across title and body). The index keeps the place of every word, so a phrase is found in the index too.
hydro*have a word that starts with hydro (hydropower)
bank -mergerhave bank but not merger

A search needs at least one word without -.

search_rank(columns..., 'words') says how well a row matches: higher is better, 0 when the row does not match. It uses the BM25 formula: a word that is rare in the table counts more than a common word, a word that is there many times counts more, and a long text counts a little less. Use the same columns and words as in MATCH.

ORDER BY search_rank(...) DESC LIMIT 20 together with MATCH(...) is the fastest form: KDB takes the 20 best rows straight from the index and reads only those rows.

MATCH and search_rank also work without a FULLTEXT index. Then KDB reads every row.

Highlight the words

highlight(columns..., 'words') returns the text with the found words inside <b> and </b>, for a search result page.

09-highlight.sql:

SELECT id, highlight(title, 'bank') AS title FROM news WHERE MATCH(title, body, 'bank') ORDER BY id;
id,title
1,City <b>Bank</b> profit rises 18%
4,"Market index closes at 2,840"
5,Coastal <b>bank</b> merger talks

Row 4 matched because its body has Bank; the highlighted column here is only the title.

highlight does not change any other character. If users can type < or & (in a forum they can), do not put the result into a web page as it is: escape the text in your app first.

Changes

10-news-changes.sql:

UPDATE news SET body = 'Coastal Bank and Harbor Bank signed the merger.' WHERE id = 5;
SELECT id FROM news WHERE MATCH(title, body, 'harbor');
DELETE FROM news WHERE id = 4;
SELECT id FROM news WHERE MATCH(title, body, 'gainers');
1 row(s) updated

id
--
 5
(1 row, 0.2 ms)

1 row(s) deleted

id
--
(0 rows, 0.0 ms)

11-news-explain.sql:

EXPLAIN SELECT id FROM news WHERE MATCH(title, body, 'bank') ORDER BY search_rank(title, body, 'bank') DESC LIMIT 20;
EXPLAIN SELECT id, title FROM news WHERE symbol = 'CITY' ORDER BY published_at DESC LIMIT 10;
plan
"scan table news (1 of 1 partitions, about 4 rows)"
"use index news_text (full-text search, best 20 row(s) by search_rank)"
"build 3 of 5 stored column(s) per row: id, title, body"
filter while scanning: 1 condition(s)
sort by 1 key(s)
limit 20
plan
"scan table news (1 of 1 partitions, about 4 rows)"
"use index news_symbol_time (symbol = value, rows in ORDER BY order, stops after 10 row(s))"
"build 4 of 5 stored column(s) per row: id, symbol, published_at, title"
filter while scanning: 1 condition(s)
sort by 1 key(s)
limit 10

Step 7: see and remove indexes

12-show-indexes.sql:

-- kdb sql opens the database again, so no index is in memory yet. The first query builds one.
SHOW INDEXES FROM posts;
SELECT count(*) AS posts FROM posts WHERE thread_id = 1;
SHOW INDEXES FROM posts;
SHOW CREATE TABLE users;
name,table,kind,columns,built,rows,memory_bytes,create_sql,building
posts_thread_time,posts,INDEX,"thread_id, created_at, id",false,,,"CREATE INDEX posts_thread_time ON posts (thread_id, created_at, id)",false
posts_parent,posts,INDEX,parent_id,false,,,CREATE INDEX posts_parent ON posts (parent_id),false
posts_author,posts,INDEX,author,false,,,CREATE INDEX posts_author ON posts (author),false
posts
6
name,table,kind,columns,built,rows,memory_bytes,create_sql,building
posts_thread_time,posts,INDEX,"thread_id, created_at, id",true,8,400,"CREATE INDEX posts_thread_time ON posts (thread_id, created_at, id)",false
posts_parent,posts,INDEX,parent_id,false,,,CREATE INDEX posts_parent ON posts (parent_id),false
posts_author,posts,INDEX,author,false,,,CREATE INDEX posts_author ON posts (author),false
create_sql
"CREATE TABLE users (
  id LONG PRIMARY KEY,
  email STRING NOT NULL,
  name STRING
)"
CREATE UNIQUE INDEX users_email ON users (email)

13-drop-index.sql:

DROP INDEX posts_author;
DROP INDEX IF EXISTS posts_author;
ALTER TABLE posts DROP COLUMN parent_id;
index 'posts_author' dropped

index 'posts_author' does not exist
error: cannot drop column 'parent_id' from table 'posts': index 'posts_parent' uses it; DROP INDEX posts_parent first
[exit code 1]

How fast

Measured with kdb_bench_indexes (bench/bench_indexes.cpp) on the test PC (Windows, 16 threads for KDB, release build). Query times are the middle value of many runs and include parsing the SQL.

WhatRowsTime
CREATE INDEX posts_thread ON posts (thread_id, created_at, id)1,000,000 posts170 ms
Build of that index when the database opens (kdb sql: by the first query)1,000,000 posts150-320 ms, once
SELECT * FROM posts WHERE author = 'user123' (10 rows)1,000,000 posts0.03-0.04 ms
Forum page: WHERE thread_id = x ORDER BY created_at DESC LIMIT 201,000,000 posts0.05-0.06 ms
Keyset page: ... AND (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 201,000,000 posts0.06-0.07 ms
INSERT of one post (two indexes kept up to date, log saved to disk)1,000,000 posts0.3-1.8 ms (mostly the disk)
CREATE FULLTEXT INDEX news_text ON news (title, body)200,000 articles, 216 MB of text1.0-1.1 s
Build of that index when the database opens (kdb sql: by the first search)200,000 articles1.6 s, once
Top 20 by search_rank for common words200,000 articles (23,000 to 199,000 matches)6-15 ms
UPDATE of one article (full-text index kept up to date)200,000 articles0.4-2 ms
Memory: posts_thread / posts_author / news_text50 MB / 35 MB / 102 MB

Run it yourself: .\build\bin\kdb_bench_indexes.exe 1000000 200000.

Tips

What you learned